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
+43
View File
@@ -0,0 +1,43 @@
import { router } from "expo-router";
import { Alert } from "react-native";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
clearActiveAccount: () => Promise<void>;
signOut: () => Promise<unknown>;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
clearActiveAccount,
signOut,
}: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return;
await signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
export function confirmRemoveAccount(
label: string,
onRemove: () => Promise<RemoveAccountResult>,
onFinished: (result: RemoveAccountResult) => Promise<void>,
) {
Alert.alert("Remove account", `Remove ${label} from this device?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => {
void (async () => {
const result = await onRemove();
await onFinished(result);
})();
},
},
]);
}
+35
View File
@@ -12,6 +12,20 @@ function storageKeyForPrefix(prefix: string, suffix: (typeof AUTH_STORAGE_SUFFIX
return normalizeSecureStoreKey(`${prefix}${suffix}`);
}
async function readSecureStoreValue(key: string): Promise<string | null> {
const value = await SecureStore.getItemAsync(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
const chunks = await Promise.all(
Array.from({ length: count }, (_, index) => SecureStore.getItemAsync(`${key}.${index}`)),
);
return chunks.map((chunk) => chunk ?? "").join("");
}
async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<void> {
const value = await SecureStore.getItemAsync(fromKey);
if (value == null) return;
@@ -31,6 +45,27 @@ async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<voi
}
}
export async function readStoredSessionUser(prefix: string): Promise<{
id?: string;
name?: string;
email?: string;
} | null> {
const raw = await readSecureStoreValue(storageKeyForPrefix(prefix, "_session_data"));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as {
user?: { id?: string; name?: string; email?: string };
session?: { user?: { id?: string; name?: string; email?: string } };
};
const user = parsed.user ?? parsed.session?.user;
if (!user) return null;
return { id: user.id, name: user.name, email: user.email };
} catch {
return null;
}
}
export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> {
if (fromPrefix === toPrefix) return;
+151
View File
@@ -0,0 +1,151 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
const REMINDER_PREFIX = "invoice-send-reminder:";
const FIRED_PREFIX = "invoice-reminder-fired:";
export type InvoiceSendReminderSource = {
id: string;
status: string;
invoiceNumber: string;
invoicePrefix: string | null;
sendReminderAt: Date | string | null | undefined;
client?: { name: string } | null;
};
export function invoiceSendReminderNotificationId(invoiceId: string) {
return `${REMINDER_PREFIX}${invoiceId}`;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
async function ensureAndroidChannel() {
if (Platform.OS !== "android") return;
await Notifications.setNotificationChannelAsync("invoice-reminders", {
name: "Invoice reminders",
importance: Notifications.AndroidImportance.HIGH,
sound: "default",
vibrationPattern: [0, 250, 250, 250],
});
}
export async function ensureNotificationPermissions(): Promise<boolean> {
if (Platform.OS === "web") return false;
await ensureAndroidChannel();
const { status: existing } = await Notifications.getPermissionsAsync();
if (existing === "granted") return true;
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: true,
},
});
return status === "granted";
}
function reminderContent(invoice: InvoiceSendReminderSource): Notifications.NotificationContentInput {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
const clientName = invoice.client?.name ?? "your client";
return {
title: "Time to send invoice",
body: `${label} for ${clientName} is ready to send.`,
data: {
invoiceId: invoice.id,
type: "invoice-send-reminder",
},
sound: true,
...(Platform.OS === "android" ? { channelId: "invoice-reminders" } : {}),
};
}
export async function syncInvoiceSendReminders(invoices: InvoiceSendReminderSource[]) {
if (Platform.OS === "web") return;
const granted = await ensureNotificationPermissions();
if (!granted) return;
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
const ourScheduled = new Set(
scheduled
.map((entry) => entry.identifier)
.filter((id): id is string => Boolean(id?.startsWith(REMINDER_PREFIX))),
);
const wanted = new Set<string>();
const now = Date.now();
for (const invoice of invoices) {
if (invoice.status !== "draft" || !invoice.sendReminderAt) continue;
const notificationId = invoiceSendReminderNotificationId(invoice.id);
wanted.add(notificationId);
const reminderAt = new Date(invoice.sendReminderAt);
const reminderMs = reminderAt.getTime();
if (Number.isNaN(reminderMs)) continue;
const firedKey = `${FIRED_PREFIX}${invoice.id}`;
const firedAt = await AsyncStorage.getItem(firedKey);
const content = reminderContent(invoice);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
if (reminderMs <= now) {
const alreadyFiredForThisDate = firedAt === reminderAt.toISOString();
if (alreadyFiredForThisDate) continue;
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 2,
},
});
await AsyncStorage.setItem(firedKey, reminderAt.toISOString());
continue;
}
if (firedAt && firedAt !== reminderAt.toISOString()) {
await AsyncStorage.removeItem(firedKey);
}
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: reminderAt,
},
});
}
for (const notificationId of ourScheduled) {
if (wanted.has(notificationId)) continue;
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
const invoiceId = notificationId.slice(REMINDER_PREFIX.length);
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
}
export async function cancelInvoiceSendReminder(invoiceId: string) {
if (Platform.OS === "web") return;
const notificationId = invoiceSendReminderNotificationId(invoiceId);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
+25
View File
@@ -0,0 +1,25 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { isUnauthorizedError } from "@/lib/trpc-errors";
export function createAppQueryClient(onUnauthorized: () => void) {
const handleError = (error: unknown) => {
if (isUnauthorizedError(error)) {
onUnauthorized();
}
};
return new QueryClient({
queryCache: new QueryCache({ onError: handleError }),
mutationCache: new MutationCache({ onError: handleError }),
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) => {
if (isUnauthorizedError(error)) return false;
return failureCount < 1;
},
},
},
});
}
+53
View File
@@ -0,0 +1,53 @@
import * as Linking from "expo-linking";
export type ShortcutAction = "clock-in" | "clock-out" | "open-timer";
export type ParsedShortcut = {
action: ShortcutAction;
title: string;
clientId: string;
};
function queryParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
}
/** Parse `beenvoice://shortcuts/clock-in` and related URLs from Shortcuts / Siri. */
export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut | null {
if (!url) return null;
const parsed = Linking.parse(url);
if (parsed.scheme !== "beenvoice") return null;
const path = (parsed.path ?? "").replace(/^\/+/, "");
const host = parsed.hostname ?? "";
if (path === "timer" || host === "timer") {
return { action: "open-timer", title: "", clientId: "" };
}
let shortcutAction: string | null = null;
if (host === "shortcuts" && path) {
shortcutAction = path;
} else {
const match = path.match(/^shortcuts\/(clock-in|clock-out)$/);
shortcutAction = match?.[1] ?? null;
}
if (shortcutAction === "clock-in" || shortcutAction === "clock-out") {
return {
action: shortcutAction,
title: queryParam(parsed.queryParams?.title),
clientId: queryParam(parsed.queryParams?.clientId),
};
}
return null;
}
export const SHORTCUT_URLS = {
timer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out",
} as const;
+21
View File
@@ -0,0 +1,21 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
function storageKey(accountId: string) {
return `beenvoice:time-clock:last-client:${accountId}`;
}
export async function getLastTimeClockClientId(accountId: string): Promise<string | null> {
return AsyncStorage.getItem(storageKey(accountId));
}
export async function setLastTimeClockClientId(
accountId: string,
clientId: string,
): Promise<void> {
if (!clientId) return;
await AsyncStorage.setItem(storageKey(accountId), clientId);
}
export async function clearTimeClockPrefsForAccount(accountId: string): Promise<void> {
await AsyncStorage.removeItem(storageKey(accountId));
}
+14
View File
@@ -25,6 +25,20 @@ export function formatElapsedHoursMinutes(seconds: number): string {
return `${h}:${String(m).padStart(2, "0")}`;
}
export function resolveEffectiveHourlyRate(
rateText: string,
clientDefaultRate?: number | null,
): number | null {
const parsed = rateText.trim() ? Number(rateText) : null;
if (parsed != null && !Number.isNaN(parsed) && parsed >= 0) return parsed;
if (clientDefaultRate != null && clientDefaultRate >= 0) return clientDefaultRate;
return null;
}
export function startedAtFromMinutesAgo(minutes: number): Date {
return new Date(Date.now() - minutes * 60_000);
}
export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome;
hours: number;
+8
View File
@@ -0,0 +1,8 @@
import { TRPCClientError } from "@trpc/client";
export function isUnauthorizedError(error: unknown): boolean {
return (
error instanceof TRPCClientError &&
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
);
}
+23 -16
View File
@@ -1,28 +1,35 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import SuperJSON from "superjson";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>();
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
}
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
const authClient = useAuthClient();
const [queryClient] = useState(createQueryClient);
const { refetch } = useSession();
const handleUnauthorized = useCallback(async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
await authClient.signOut();
await refetch();
}
}, [authClient, refetch]);
const onUnauthorizedRef = useRef(handleUnauthorized);
onUnauthorizedRef.current = handleUnauthorized;
const [queryClient] = useState(() =>
createAppQueryClient(() => {
void onUnauthorizedRef.current();
}),
);
const [trpcClient] = useState(() =>
api.createClient({
links: [
@@ -42,7 +49,7 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
{children}
</api.Provider>
);
}