Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable client picker with last-client defaults, account refresh/remove, and softer session handling on unauthorized API responses. Co-authored-by: Cursor <cursoragent@cursor.com>
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
import * as Linking from "expo-linking";
|
|
import { router } from "expo-router";
|
|
import { useEffect, useRef } from "react";
|
|
import { Alert, Platform } from "react-native";
|
|
|
|
import { useAccounts } from "@/contexts/AccountsContext";
|
|
import { useAppLock } from "@/contexts/AppLockContext";
|
|
import { DEFAULT_CLOCK_DESCRIPTION, resolveClockDescription, resolveEffectiveHourlyRate } from "@/lib/time-clock";
|
|
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
|
|
import { parseShortcutUrl, type ParsedShortcut } from "@/lib/shortcuts";
|
|
import { api } from "@/lib/trpc";
|
|
|
|
/**
|
|
* Handles deep links from the Shortcuts app, Siri, and Live Activities.
|
|
* Mounted inside the authenticated app shell.
|
|
*/
|
|
export function ShortcutHandler() {
|
|
const { activeAccountId } = useAccounts();
|
|
const { isLocked } = useAppLock();
|
|
const url = Linking.useURL();
|
|
const utils = api.useUtils();
|
|
const clientsQuery = api.clients.getAll.useQuery();
|
|
const runningQuery = api.timeEntries.getRunning.useQuery();
|
|
const processedRef = useRef<string | null>(null);
|
|
const pendingRef = useRef<ParsedShortcut | null>(null);
|
|
|
|
const clockIn = api.timeEntries.clockIn.useMutation();
|
|
const clockOut = api.timeEntries.clockOut.useMutation();
|
|
|
|
useEffect(() => {
|
|
void Linking.getInitialURL().then((initialUrl) => {
|
|
const parsed = parseShortcutUrl(initialUrl);
|
|
if (parsed) pendingRef.current = parsed;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const parsed = parseShortcutUrl(url);
|
|
if (parsed) pendingRef.current = parsed;
|
|
}, [url]);
|
|
|
|
useEffect(() => {
|
|
if (isLocked || !activeAccountId || clientsQuery.isLoading || runningQuery.isLoading) return;
|
|
|
|
const pending = pendingRef.current;
|
|
if (!pending) return;
|
|
|
|
const key = JSON.stringify(pending);
|
|
if (processedRef.current === key) return;
|
|
processedRef.current = key;
|
|
pendingRef.current = null;
|
|
|
|
void (async () => {
|
|
if (pending.action === "open-timer") {
|
|
router.push("/(app)/timer");
|
|
return;
|
|
}
|
|
|
|
if (pending.action === "clock-out") {
|
|
if (!runningQuery.data) {
|
|
router.push("/(app)/timer");
|
|
if (Platform.OS === "ios") {
|
|
Alert.alert("No timer running", "There is nothing to clock out.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await clockOut.mutateAsync({});
|
|
await Promise.all([
|
|
utils.timeEntries.getRunning.invalidate(),
|
|
utils.timeEntries.getAll.invalidate(),
|
|
utils.invoices.getAll.invalidate(),
|
|
utils.dashboard.getStats.invalidate(),
|
|
]);
|
|
router.push("/(app)/timer");
|
|
} catch (err) {
|
|
Alert.alert(
|
|
"Clock out failed",
|
|
err instanceof Error ? err.message : "Could not stop the timer.",
|
|
);
|
|
router.push("/(app)/timer");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pending.action === "clock-in") {
|
|
if (runningQuery.data) {
|
|
router.push("/(app)/timer");
|
|
if (Platform.OS === "ios") {
|
|
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const clientId =
|
|
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
|
|
|
|
if (!clientId) {
|
|
router.push("/(app)/timer");
|
|
Alert.alert(
|
|
"Choose a client",
|
|
"Open the time clock and pick a client once — shortcuts will use it next time.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
|
|
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
|
|
|
|
try {
|
|
await clockIn.mutateAsync({
|
|
clientId,
|
|
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
|
|
rate: rate ?? undefined,
|
|
});
|
|
await utils.timeEntries.getRunning.invalidate();
|
|
router.push("/(app)/timer");
|
|
} catch (err) {
|
|
Alert.alert(
|
|
"Clock in failed",
|
|
err instanceof Error ? err.message : "Could not start the timer.",
|
|
);
|
|
router.push("/(app)/timer");
|
|
}
|
|
}
|
|
})();
|
|
}, [
|
|
activeAccountId,
|
|
clockIn,
|
|
clockOut,
|
|
clientsQuery.data,
|
|
clientsQuery.isLoading,
|
|
isLocked,
|
|
runningQuery.data,
|
|
runningQuery.isLoading,
|
|
utils,
|
|
]);
|
|
|
|
return null;
|
|
}
|