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
co-authored by Cursor
parent 0b2d65a4e9
commit 06bc91ac13
33 changed files with 1844 additions and 320 deletions
+84 -7
View File
@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
@@ -11,8 +12,9 @@ import {
import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useSession } from "@/contexts/AuthContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode";
@@ -34,15 +36,19 @@ function displayName(name: string, email: string) {
/** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const label = displayName(
activeAccount?.name ?? session?.user.name ?? "",
@@ -67,6 +73,32 @@ export function AccountSwitcher() {
await switchAccount(accountId);
}
async function handleRefresh() {
setRefreshing(true);
try {
await refreshAccounts();
} finally {
setRefreshing(false);
}
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
if (result.remainingCount === 0) {
setOpen(false);
}
await finishAccountRemoval({
result,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
return (
<>
<Pressable
@@ -100,9 +132,25 @@ export function AccountSwitcher() {
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
<View style={styles.sheetActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
disabled={refreshing}
hitSlop={8}
onPress={() => void handleRefresh()}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
{refreshing ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="refresh" size={20} color={colors.primary} />
)}
</Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
@@ -138,9 +186,22 @@ export function AccountSwitcher() {
{formatServerHost(account.instanceUrl)}
</Text>
</View>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
<View style={styles.accountActions}>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemove(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</Pressable>
);
})}
@@ -218,6 +279,17 @@ const styles = StyleSheet.create({
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
sheetActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
iconButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 28,
minHeight: 28,
},
done: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
@@ -234,6 +306,11 @@ const styles = StyleSheet.create({
flex: 1,
gap: 2,
},
accountActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountName: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
+65
View File
@@ -0,0 +1,65 @@
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
router.push(`/(app)/invoices/${invoiceId}`);
}
/** Schedules local iOS/Android notifications for draft invoice send reminders. */
export function InvoiceReminderSync() {
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery(
{ status: "draft" },
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
});
return () => responseSubscription.remove();
}, []);
return null;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useSession } from "@/contexts/AuthContext";
/** Refetch auth session when the app returns to the foreground. */
export function SessionSync() {
const { refetch } = useSession();
const wasBackgrounded = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void refetch();
});
return () => subscription.remove();
}, [refetch]);
return null;
}
+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;
}
+9 -4
View File
@@ -23,6 +23,7 @@ type LineItemEditorProps = {
onToggle: () => void;
onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void;
readOnly?: boolean;
};
export function LineItemEditor({
@@ -32,6 +33,7 @@ export function LineItemEditor({
onToggle,
onChange,
onRemove,
readOnly = false,
}: LineItemEditorProps) {
const { colors } = useAppTheme();
const hours = Number(item.hours) || 0;
@@ -39,12 +41,13 @@ export function LineItemEditor({
const amount = hours * rate;
const borderStyle = { borderTopColor: colors.border };
if (!expanded) {
if (!expanded || readOnly) {
return (
<Pressable
accessibilityRole="button"
onPress={onToggle}
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]}
onPress={readOnly ? undefined : onToggle}
disabled={readOnly}
style={({ pressed }) => [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]}
>
<View style={styles.rowMain}>
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
@@ -57,7 +60,9 @@ export function LineItemEditor({
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
{!readOnly ? (
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
) : null}
</Pressable>
);
}
File diff suppressed because it is too large Load Diff