Redesign mobile time clock, add shortcuts, and improve account management.

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>
This commit is contained in:
2026-06-22 16:06:17 -04:00
parent 0b2d65a4e9
commit 06bc91ac13
33 changed files with 1844 additions and 320 deletions
+141
View File
@@ -0,0 +1,141 @@
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;
}