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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user