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,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);
|
||||
})();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user