diff --git a/README.md b/README.md
index 05d1ab6..6e9d76b 100644
--- a/README.md
+++ b/README.md
@@ -84,12 +84,25 @@ bun run ios
Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model)
-## Deep links
+## Deep links & Shortcuts
-| Scheme | Screen |
-|--------|--------|
+| URL | Action |
+|-----|--------|
| `beenvoice://reset-password?token=…` | Reset password |
-| `beenvoice://timer` | Timer tab (from Live Activity) |
+| `beenvoice://timer` | Open time clock |
+| `beenvoice://shortcuts/clock-in` | Clock in (last client) |
+| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title |
+| `beenvoice://shortcuts/clock-out` | Clock out running timer |
+
+**iOS Shortcuts / Siri** (dev build after `npx expo prebuild`):
+
+- **Clock In** — starts the timer with your last client
+- **Clock Out** — stops the running timer
+- **Open Time Clock** — opens the timer tab
+
+Try “Hey Siri, clock in with beenvoice” or add actions from the Shortcuts app under beenvoice.
+
+Rebuild iOS after pulling shortcut changes: `npx expo prebuild --platform ios && bun run ios`
## Project layout
diff --git a/app.json b/app.json
index c7601e4..ef74f72 100644
--- a/app.json
+++ b/app.json
@@ -12,7 +12,8 @@
"bundleIdentifier": "com.beenvoice.app",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
- "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app."
+ "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
+ "NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
}
},
"android": {
@@ -35,6 +36,14 @@
},
"plugins": [
"expo-dev-client",
+ [
+ "expo-build-properties",
+ {
+ "ios": {
+ "buildReactNativeFromSource": true
+ }
+ }
+ ],
"expo-router",
"expo-secure-store",
[
@@ -58,7 +67,16 @@
"faceIDPermission": "Unlock beenvoice with Face ID when returning to the app."
}
],
- "@react-native-community/datetimepicker"
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/images/icon.png",
+ "color": "#18181B",
+ "sounds": []
+ }
+ ],
+ "@react-native-community/datetimepicker",
+ "./plugins/withAppIntents.js"
],
"experiments": {
"typedRoutes": true
diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx
index d676301..b9d1182 100644
--- a/app/(app)/_layout.tsx
+++ b/app/(app)/_layout.tsx
@@ -2,6 +2,8 @@ import { Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
+import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
+import { ShortcutHandler } from "@/components/ShortcutHandler";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -71,6 +73,8 @@ export default function AppLayout() {
Settings
+
+
);
diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx
index bba5587..bdb64b8 100644
--- a/app/(app)/index.tsx
+++ b/app/(app)/index.tsx
@@ -62,6 +62,7 @@ export default function DashboardScreen() {
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
+ const sendReminderDue = stats.sendReminderDue ?? [];
return (
@@ -118,6 +119,26 @@ export default function DashboardScreen() {
) : null}
+ {sendReminderDue.length > 0 ? (
+
+
+
+ {sendReminderDue.length} draft{" "}
+ {sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
+
+
+ {sendReminderDue
+ .slice(0, 2)
+ .map(
+ (inv) =>
+ `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
+ )
+ .join(" · ")}
+
+
+
+ ) : null}
+
- {isActive ? (
-
- ) : null}
+
+ {isActive ? (
+
+ ) : null}
+
+ handleRemove(account.id, account.name || account.email)
+ }
+ style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
+ >
+
+
+
);
})}
@@ -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,
diff --git a/components/InvoiceReminderSync.tsx b/components/InvoiceReminderSync.tsx
new file mode 100644
index 0000000..45fc138
--- /dev/null
+++ b/components/InvoiceReminderSync.tsx
@@ -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 | 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,
+ );
+ },
+ );
+
+ void Notifications.getLastNotificationResponseAsync().then((response) => {
+ if (!response) return;
+ openInvoiceFromNotification(
+ response.notification.request.content.data as Record,
+ );
+ });
+
+ return () => responseSubscription.remove();
+ }, []);
+
+ return null;
+}
diff --git a/components/SessionSync.tsx b/components/SessionSync.tsx
new file mode 100644
index 0000000..a52da8d
--- /dev/null
+++ b/components/SessionSync.tsx
@@ -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;
+}
diff --git a/components/ShortcutHandler.tsx b/components/ShortcutHandler.tsx
new file mode 100644
index 0000000..672c5e4
--- /dev/null
+++ b/components/ShortcutHandler.tsx
@@ -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(null);
+ const pendingRef = useRef(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;
+}
diff --git a/components/invoices/LineItemEditor.tsx b/components/invoices/LineItemEditor.tsx
index f41fd8e..8f860f4 100644
--- a/components/invoices/LineItemEditor.tsx
+++ b/components/invoices/LineItemEditor.tsx
@@ -23,6 +23,7 @@ type LineItemEditorProps = {
onToggle: () => void;
onChange: (patch: Partial) => 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 (
[styles.row, borderStyle, pressed && styles.rowPressed]}
+ onPress={readOnly ? undefined : onToggle}
+ disabled={readOnly}
+ style={({ pressed }) => [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]}
>
@@ -57,7 +60,9 @@ export function LineItemEditor({
{formatCurrency(amount, currency)}
-
+ {!readOnly ? (
+
+ ) : null}
);
}
diff --git a/components/time-clock/TimeClockPanel.tsx b/components/time-clock/TimeClockPanel.tsx
index 50e27e1..11a85bc 100644
--- a/components/time-clock/TimeClockPanel.tsx
+++ b/components/time-clock/TimeClockPanel.tsx
@@ -1,7 +1,16 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
-import { Alert, Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
+import {
+ Alert,
+ Pressable,
+ RefreshControl,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
import { router } from "expo-router";
+import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView";
@@ -9,29 +18,60 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
-import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
+import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
-import { formatDateTime } from "@/lib/format";
+import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
+import {
+ getLastTimeClockClientId,
+ setLastTimeClockClientId,
+} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
-import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock";
+import {
+ DEFAULT_CLOCK_DESCRIPTION,
+ describeClockOutOutcome,
+ formatElapsedSeconds,
+ resolveClockDescription,
+ resolveEffectiveHourlyRate,
+ startedAtFromMinutesAgo,
+} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
- /** Hides the in-panel title card when idle (tab screen already has PageHeader). */
compact?: boolean;
header?: ReactNode;
};
+type ClientRow = {
+ id: string;
+ name: string;
+ defaultHourlyRate: number | null;
+ currency?: string;
+};
+
+type StartMode = "now" | "at" | "ago";
+
+const AGO_PRESETS = [
+ { label: "15m", minutes: 15 },
+ { label: "30m", minutes: 30 },
+ { label: "1h", minutes: 60 },
+ { label: "2h", minutes: 120 },
+ { label: "4h", minutes: 240 },
+] as const;
+
+function clientRateText(client: ClientRow | undefined): string {
+ return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
+}
+
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
@@ -40,6 +80,7 @@ export function TimeClockPanel({
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
+ const { activeAccountId } = useAccounts();
const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
@@ -48,9 +89,18 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
- const [description, setDescription] = useState(DEFAULT_CLOCK_DESCRIPTION);
+ const [description, setDescription] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
+ const [startMode, setStartMode] = useState("now");
+ const [agoMinutes, setAgoMinutes] = useState(60);
+ const [agoMinutesText, setAgoMinutesText] = useState("60");
+ const [optionsExpanded, setOptionsExpanded] = useState(false);
+ const [clientsExpanded, setClientsExpanded] = useState(false);
+ const [featuredClientIds, setFeaturedClientIds] = useState([]);
+ const [storedLastClientId, setStoredLastClientId] = useState(null);
+ const [prefsLoaded, setPrefsLoaded] = useState(false);
+ const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
@@ -68,7 +118,20 @@ export function TimeClockPanel({
return d;
}, []);
- const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
+ const entriesQuery = api.timeEntries.getAll.useQuery();
+
+ const recentClientIds = useMemo(() => {
+ const seen = new Set();
+ const ids: string[] = [];
+ for (const entry of entriesQuery.data ?? []) {
+ if (entry.clientId && !seen.has(entry.clientId)) {
+ seen.add(entry.clientId);
+ ids.push(entry.clientId);
+ if (ids.length >= 2) break;
+ }
+ }
+ return ids;
+ }, [entriesQuery.data]);
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
@@ -91,6 +154,9 @@ export function TimeClockPanel({
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
+ if (running?.clientId && activeAccountId) {
+ await setLastTimeClockClientId(activeAccountId, running.clientId);
+ }
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
@@ -108,25 +174,78 @@ export function TimeClockPanel({
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
- setDescription(DEFAULT_CLOCK_DESCRIPTION);
+ setDescription("");
},
});
+ useEffect(() => {
+ if (!activeAccountId) {
+ setPrefsLoaded(true);
+ return;
+ }
+ setPrefsLoaded(false);
+ void getLastTimeClockClientId(activeAccountId).then((id) => {
+ setStoredLastClientId(id);
+ setPrefsLoaded(true);
+ });
+ }, [activeAccountId]);
+
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
- setDescription(running.description?.trim() || DEFAULT_CLOCK_DESCRIPTION);
+ setDescription(running.description?.trim() ?? "");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
useEffect(() => {
- if (running || !clientId || rateText) return;
+ if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
- if (client?.defaultHourlyRate) {
- setRateText(String(client.defaultHourlyRate));
+ if (!client?.defaultHourlyRate) return;
+ setRateText((current) => current.trim() || clientRateText(client));
+ }, [clientId, clients, running]);
+
+ useEffect(() => {
+ if (running || defaultClientId || initialClientResolved) return;
+ if (!prefsLoaded || clients.length === 0) return;
+
+ const preferredId =
+ storedLastClientId && clients.some((client) => client.id === storedLastClientId)
+ ? storedLastClientId
+ : recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
+
+ if (preferredId) {
+ const client = clients.find((c) => c.id === preferredId);
+ setClientId(preferredId);
+ setRateText(clientRateText(client));
}
- }, [clientId, clients, rateText, running]);
+
+ setInitialClientResolved(true);
+ }, [
+ clients,
+ defaultClientId,
+ initialClientResolved,
+ prefsLoaded,
+ recentClientIds,
+ running,
+ storedLastClientId,
+ ]);
+
+ useEffect(() => {
+ if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
+
+ const ids: string[] = [];
+ const add = (id: string | null | undefined) => {
+ if (!id || ids.includes(id)) return;
+ if (!clients.some((client) => client.id === id)) return;
+ ids.push(id);
+ };
+
+ add(storedLastClientId);
+ for (const id of recentClientIds) add(id);
+
+ setFeaturedClientIds(ids.slice(0, 1));
+ }, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
useEffect(() => {
if (!running) {
@@ -138,10 +257,7 @@ export function TimeClockPanel({
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
- void syncTimeClockLiveActivity(
- { ...running, description },
- seconds,
- );
+ void syncTimeClockLiveActivity({ ...running, description }, seconds);
};
sync();
@@ -149,49 +265,137 @@ export function TimeClockPanel({
return () => clearInterval(interval);
}, [running, description]);
- const rate = parseFloat(rateText) || 0;
- const displayRate = running ? (running.rate ?? 0) : rate;
+ const selectedClient = clients.find((client) => client.id === clientId);
+ const rateCurrency = selectedClient?.currency ?? "USD";
+ const effectiveRate = resolveEffectiveHourlyRate(
+ rateText,
+ selectedClient?.defaultHourlyRate,
+ );
+ const displayRate = running
+ ? (running.rate ?? effectiveRate ?? 0)
+ : (effectiveRate ?? 0);
- const clientOptions = useMemo(
- () => clients.map((client) => ({ label: client.name, value: client.id })),
- [clients],
+ const featuredClients = useMemo(
+ () =>
+ featuredClientIds
+ .map((id) => clients.find((client) => client.id === id))
+ .filter((client) => client != null),
+ [clients, featuredClientIds],
);
- const invoiceOptions = useMemo(
- () => [
- { label: "No invoice — save entry only", value: "" },
- ...billableInvoices.map((invoice) => ({
- label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber} (${invoice.status})`,
- value: invoice.id,
- })),
- ],
- [billableInvoices],
- );
+ const moreClients = useMemo(() => {
+ const featuredIds = new Set(featuredClientIds);
+ return clients
+ .filter((client) => !featuredIds.has(client.id))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }, [clients, featuredClientIds]);
+
+ const resolvedStartAt = useMemo(() => {
+ if (startMode === "now") return new Date();
+ if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
+ return startedAt;
+ }, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
- const next: { clientId?: string; rate?: string } = {};
- if (!clientId) next.clientId = "Select a client";
+ const next: { clientId?: string; rate?: string; start?: string } = {};
+ if (!clientId) next.clientId = "Choose a client to start";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
+ if (startMode === "ago" && agoMinutes <= 0) {
+ next.start = "Enter how long ago you started";
+ }
return next;
- }, [clientId, rateText]);
+ }, [agoMinutes, clientId, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
+ const optionsSummary = useMemo(() => {
+ const rate =
+ effectiveRate ??
+ (selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
+ const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
+ const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
+ return `${rateLabel} · ${startLabel}`;
+ }, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
+
+ const todayEntries = useMemo(
+ () =>
+ (entriesQuery.data ?? []).filter(
+ (entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
+ ),
+ [entriesQuery.data, todayStart],
+ );
+
+ async function persistClientChoice(nextClientId: string, syncState = false) {
+ if (!activeAccountId || !nextClientId) return;
+ await setLastTimeClockClientId(activeAccountId, nextClientId);
+ if (syncState) setStoredLastClientId(nextClientId);
+ }
+
+ function selectClient(nextClientId: string) {
+ const client = clients.find((c) => c.id === nextClientId);
+ setClientId(nextClientId);
+ setInvoiceId("");
+ setRateText(clientRateText(client));
+ if (!featuredClientIds.includes(nextClientId)) {
+ setClientsExpanded(true);
+ }
+ void persistClientChoice(nextClientId);
+ }
+
+ function selectStartMode(mode: StartMode) {
+ setStartMode(mode);
+ if (mode !== "now") setOptionsExpanded(true);
+ if (mode === "now") {
+ setStartedAt(new Date());
+ return;
+ }
+ if (mode === "ago") {
+ setStartedAt(startedAtFromMinutesAgo(agoMinutes));
+ return;
+ }
+ setStartedAt((current) =>
+ Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
+ );
+ }
+
+ function selectAgoPreset(minutes: number) {
+ setStartMode("ago");
+ setAgoMinutes(minutes);
+ setAgoMinutesText(String(minutes));
+ setStartedAt(startedAtFromMinutesAgo(minutes));
+ }
+
+ function handleAgoMinutesChange(text: string) {
+ setAgoMinutesText(text);
+ const parsed = Number(text);
+ if (!Number.isNaN(parsed) && parsed > 0) {
+ setAgoMinutes(parsed);
+ setStartedAt(startedAtFromMinutesAgo(parsed));
+ }
+ }
+
async function handleClockIn() {
- if (!canClockIn) return;
+ if (!canClockIn) {
+ if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
+ return;
+ }
try {
const backdated =
- Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
+ startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
- rate: rate || undefined,
+ rate: effectiveRate ?? undefined,
startedAt: backdated,
});
+ await persistClientChoice(clientId);
+ setStartMode("now");
setStartedAt(new Date());
+ setAgoMinutes(60);
+ setAgoMinutesText("60");
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
@@ -214,9 +418,8 @@ export function TimeClockPanel({
try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId);
- if (client?.defaultHourlyRate != null) {
- setRateText(String(client.defaultHourlyRate));
- }
+ setRateText(clientRateText(client));
+ await persistClientChoice(nextClientId);
} catch {
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
@@ -238,7 +441,6 @@ export function TimeClockPanel({
return ;
}
- const todayEntries = (todayQuery.data ?? []).filter((entry) => entry.endedAt);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
@@ -249,6 +451,23 @@ export function TimeClockPanel({
.filter(Boolean)
.join(" · ");
+ const controlsDisabled = Boolean(running && updateRunning.isPending);
+
+ function renderClientChip(client: (typeof clients)[number]) {
+ return (
+ {
+ if (controlsDisabled) return;
+ if (running) void handleRunningClientChange(client.id);
+ else selectClient(client.id);
+ }}
+ />
+ );
+ }
+
return (
@@ -276,107 +495,204 @@ export function TimeClockPanel({
Running
{formatElapsedSeconds(elapsed)}
-
- {resolveClockDescription(description)}
-
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
>
) : (
- Track billable time and link it to invoices.
+
+ Choose a client and clock in. A draft invoice is created automatically if needed.
+
)}
) : null}
- {running ? (
-
-
- void handleRunningClientChange(next)}
- />
+
+
- void handleRunningInvoiceChange(next)}
- />
-
-
-
-
- ) : (
-
-
- {
- setClientId(next);
- setInvoiceId("");
- const client = clients.find((c) => c.id === next);
- setRateText(
- client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
- );
- }}
- />
-
-
-
-
-
-
-
-
-
- Set an earlier time if you forgot to clock in when you started working.
+
+ Client
+ {clients.length === 0 ? (
+
+ Add a client first to start tracking time.
+ ) : (
+ <>
+
+ {featuredClients.map((client) => renderClientChip(client))}
+ {moreClients.length > 0 ? (
+ {
+ if (controlsDisabled) return;
+ setClientsExpanded((open) => !open);
+ }}
+ />
+ ) : null}
+
+ {clientsExpanded && moreClients.length > 0 ? (
+
+ {moreClients.map((client) => renderClientChip(client))}
+
+ ) : null}
+ >
+ )}
+ {clockInErrors.clientId && !running ? (
+ {clockInErrors.clientId}
+ ) : null}
+
+
+ {clientId ? (
+
+ Invoice
+
+ {
+ if (controlsDisabled) return;
+ if (running) void handleRunningInvoiceChange("");
+ else setInvoiceId("");
+ }}
+ />
+ {billableInvoices.map((invoice) => {
+ const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
+ return (
+ {
+ if (controlsDisabled) return;
+ if (running) void handleRunningInvoiceChange(invoice.id);
+ else setInvoiceId(invoice.id);
+ }}
+ />
+ );
+ })}
+
-
- )}
+ ) : null}
+
+ {!running && clientId ? (
+
+ setOptionsExpanded((open) => !open)}
+ style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
+ >
+
+ Rate & start time
+ {!optionsExpanded ? (
+ {optionsSummary}
+ ) : null}
+
+ {optionsExpanded ? "−" : "+"}
+
+
+ {optionsExpanded ? (
+
+
+ {selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
+
+ Defaults to{" "}
+ {formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
+
+ ) : null}
+
+ Start
+
+ selectStartMode("now")}
+ />
+ selectStartMode("at")}
+ />
+ selectStartMode("ago")}
+ />
+
+
+ {startMode === "at" ? (
+ {
+ setStartedAt(date);
+ setStartMode("at");
+ }}
+ />
+ ) : null}
+
+ {startMode === "ago" ? (
+
+
+ {AGO_PRESETS.map((preset) => (
+ selectAgoPreset(preset.minutes)}
+ />
+ ))}
+
+
+
+ Started
+
+
+
+ min ago
+
+
+
+ ) : null}
+
+ {clockInErrors.start ? (
+ {clockInErrors.start}
+ ) : null}
+
+ ) : null}
+
+ ) : null}
+
{running ? (
)}
@@ -442,98 +758,184 @@ export function TimeClockPanel({
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
- scroll: {
- flex: 1,
- },
- runningCard: {
- borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
- },
- hero: {
- padding: spacing.md,
- gap: spacing.sm,
- },
- heroHeader: {
- flexDirection: "row",
- alignItems: "center",
- gap: spacing.sm,
- },
- pulseDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
- backgroundColor: colors.primary,
- },
- heroLabel: {
- fontSize: 13,
- fontFamily: fonts.bodyMedium,
- color: colors.mutedForeground,
- textTransform: "uppercase",
- letterSpacing: 0.4,
- },
- timerValue: {
- fontSize: 52,
- lineHeight: 56,
- fontFamily: fonts.mono,
- color: colors.foreground,
- fontVariant: ["tabular-nums"],
- },
- runningTitle: {
- fontSize: 16,
- fontFamily: fonts.bodySemiBold,
- color: colors.foreground,
- },
- runningMeta: {
- fontSize: 13,
- fontFamily: fonts.body,
- color: colors.mutedForeground,
- },
- idleHint: {
- fontSize: 14,
- fontFamily: fonts.body,
- color: colors.mutedForeground,
- marginTop: spacing.xs,
- },
- startedHint: {
- fontSize: 12,
- fontFamily: fonts.body,
- color: colors.mutedForeground,
- lineHeight: 18,
- marginTop: -spacing.xs,
- },
- formCard: {
- gap: 0,
- },
- formFields: {
- gap: spacing.md,
- },
- entryRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- gap: spacing.md,
- paddingVertical: spacing.sm,
- borderTopWidth: 1,
- borderTopColor: colors.border,
- },
- entryRowPressed: {
- opacity: 0.65,
- },
- entryMeta: {
- flex: 1,
- gap: 2,
- },
- entryTitle: {
- fontFamily: fonts.bodySemiBold,
- color: colors.foreground,
- fontSize: 14,
- },
- entrySub: {
- fontFamily: fonts.body,
- color: colors.mutedForeground,
- fontSize: 12,
- },
- entryHours: {
- fontFamily: fonts.bodySemiBold,
- color: colors.foreground,
- fontSize: 14,
- },
-});
+ scroll: {
+ flex: 1,
+ },
+ runningCard: {
+ borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
+ },
+ hero: {
+ padding: spacing.md,
+ gap: spacing.sm,
+ },
+ heroHeader: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ },
+ pulseDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.primary,
+ },
+ heroLabel: {
+ fontSize: 13,
+ fontFamily: fonts.bodyMedium,
+ color: colors.mutedForeground,
+ textTransform: "uppercase",
+ letterSpacing: 0.4,
+ },
+ timerValue: {
+ fontSize: 52,
+ lineHeight: 56,
+ fontFamily: fonts.mono,
+ color: colors.foreground,
+ fontVariant: ["tabular-nums"],
+ },
+ runningMeta: {
+ fontSize: 13,
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ },
+ idleHint: {
+ fontSize: 14,
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ lineHeight: 20,
+ },
+ setupCard: {
+ padding: spacing.md,
+ gap: spacing.lg,
+ },
+ setupSection: {
+ gap: spacing.sm,
+ paddingTop: spacing.lg,
+ },
+ titleInput: {
+ minHeight: 44,
+ textAlignVertical: "center",
+ },
+ titleInputPlaceholder: {
+ textAlign: "center",
+ },
+ sectionLabel: {
+ fontSize: 11,
+ fontFamily: fonts.bodySemiBold,
+ color: colors.mutedForeground,
+ textTransform: "uppercase",
+ letterSpacing: 0.6,
+ },
+ sectionLabelInset: {
+ marginTop: spacing.sm,
+ },
+ chipWrap: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: spacing.sm,
+ },
+ moreClientsWrap: {
+ paddingTop: spacing.xs,
+ },
+ emptyClients: {
+ fontSize: 14,
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ },
+ fieldError: {
+ fontSize: 12,
+ fontFamily: fonts.body,
+ color: colors.destructive,
+ },
+ optionsToggle: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: spacing.md,
+ paddingVertical: spacing.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.border,
+ },
+ optionsTogglePressed: {
+ opacity: 0.7,
+ },
+ optionsToggleText: {
+ flex: 1,
+ gap: 2,
+ },
+ optionsToggleLabel: {
+ fontSize: 14,
+ fontFamily: fonts.bodySemiBold,
+ color: colors.foreground,
+ },
+ optionsToggleSummary: {
+ fontSize: 12,
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ },
+ optionsChevron: {
+ fontSize: 20,
+ lineHeight: 22,
+ fontFamily: fonts.bodyMedium,
+ color: colors.mutedForeground,
+ },
+ optionsBody: {
+ gap: spacing.md,
+ paddingBottom: spacing.xs,
+ },
+ rateHint: {
+ fontSize: 12,
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ },
+ agoBlock: {
+ gap: spacing.sm,
+ },
+ agoCustomRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ },
+ agoCustomLabel: {
+ fontSize: 14,
+ fontFamily: fonts.body,
+ },
+ agoInput: {
+ minWidth: 56,
+ fontSize: 16,
+ fontFamily: fonts.bodySemiBold,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ paddingVertical: 4,
+ textAlign: "center",
+ },
+ entryRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: spacing.md,
+ paddingVertical: spacing.sm,
+ borderTopWidth: 1,
+ borderTopColor: colors.border,
+ },
+ entryRowPressed: {
+ opacity: 0.65,
+ },
+ entryMeta: {
+ flex: 1,
+ gap: 2,
+ },
+ entryTitle: {
+ fontFamily: fonts.bodySemiBold,
+ color: colors.foreground,
+ fontSize: 14,
+ },
+ entrySub: {
+ fontFamily: fonts.body,
+ color: colors.mutedForeground,
+ fontSize: 12,
+ },
+ entryHours: {
+ fontFamily: fonts.bodySemiBold,
+ color: colors.foreground,
+ fontSize: 14,
+ },
+ });
diff --git a/contexts/AccountsContext.tsx b/contexts/AccountsContext.tsx
index b90fd82..fc5d0d9 100644
--- a/contexts/AccountsContext.tsx
+++ b/contexts/AccountsContext.tsx
@@ -21,7 +21,14 @@ import {
type SavedAccount,
} from "@/lib/accounts";
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
+import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
+import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
+
+export type RemoveAccountResult = {
+ wasActive: boolean;
+ remainingCount: number;
+};
type AccountsContextValue = {
accounts: SavedAccount[];
@@ -37,7 +44,8 @@ type AccountsContextValue = {
email: string;
name: string;
}) => Promise;
- removeAccount: (accountId: string) => Promise;
+ removeAccount: (accountId: string) => Promise;
+ refreshAccounts: () => Promise;
clearActiveAccount: () => Promise;
};
@@ -148,12 +156,17 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
);
const removeAccount = useCallback(
- async (accountId: string) => {
+ async (accountId: string): Promise => {
+ const wasActive = activeAccountId === accountId;
+
+ await clearAuthStorage(authStoragePrefix(accountId));
+ await clearTimeClockPrefsForAccount(accountId);
+
const nextAccounts = accounts.filter((account) => account.id !== accountId);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
- if (activeAccountId === accountId) {
+ if (wasActive) {
const fallback = nextAccounts[0] ?? null;
await saveActiveAccountId(fallback?.id ?? null);
setActiveAccountId(fallback?.id ?? null);
@@ -162,10 +175,30 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
setApiUrl(fallback.instanceUrl);
}
}
+
+ return { wasActive, remainingCount: nextAccounts.length };
},
[accounts, activeAccountId],
);
+ const refreshAccounts = useCallback(async () => {
+ const stored = await loadAccounts();
+ const refreshed = await Promise.all(
+ stored.map(async (account) => {
+ const user = await readStoredSessionUser(authStoragePrefix(account.id));
+ if (!user?.name && !user?.email) return account;
+ return {
+ ...account,
+ name: user.name?.trim() || account.name,
+ email: user.email?.trim() || account.email,
+ };
+ }),
+ );
+
+ setAccounts(refreshed);
+ await saveAccounts(refreshed);
+ }, []);
+
const clearActiveAccount = useCallback(async () => {
await saveActiveAccountId(null);
setActiveAccountId(null);
@@ -184,6 +217,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
switchAccount,
registerAccount,
removeAccount,
+ refreshAccounts,
clearActiveAccount,
}),
[
@@ -195,6 +229,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
switchAccount,
registerAccount,
removeAccount,
+ refreshAccounts,
clearActiveAccount,
],
);
diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx
index a455075..195f7e8 100644
--- a/contexts/AuthContext.tsx
+++ b/contexts/AuthContext.tsx
@@ -15,11 +15,13 @@ function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient
return createAuthClient({
baseURL: apiUrl,
plugins: [
- expoClient({
- scheme: "beenvoice",
- storagePrefix,
- storage: SecureStore,
- }),
+ expoClient({
+ scheme: "beenvoice",
+ storagePrefix,
+ storage: SecureStore,
+ // Avoid showing a cached session when cookies have already expired.
+ disableCache: true,
+ }),
genericOAuthClient(),
],
});
diff --git a/lib/account-actions.ts b/lib/account-actions.ts
new file mode 100644
index 0000000..0f3de42
--- /dev/null
+++ b/lib/account-actions.ts
@@ -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;
+ signOut: () => Promise;
+};
+
+/** Navigate to sign-in when the last saved account was removed. */
+export async function finishAccountRemoval({
+ result,
+ clearActiveAccount,
+ signOut,
+}: FinishAccountRemovalInput): Promise {
+ if (result.remainingCount > 0) return;
+
+ await signOut();
+ await clearActiveAccount();
+ router.replace("/(auth)/sign-in");
+}
+
+export function confirmRemoveAccount(
+ label: string,
+ onRemove: () => Promise,
+ onFinished: (result: RemoveAccountResult) => Promise,
+) {
+ 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);
+ })();
+ },
+ },
+ ]);
+}
diff --git a/lib/auth-storage.ts b/lib/auth-storage.ts
index bd4b3f2..f5e0c49 100644
--- a/lib/auth-storage.ts
+++ b/lib/auth-storage.ts
@@ -12,6 +12,20 @@ function storageKeyForPrefix(prefix: string, suffix: (typeof AUTH_STORAGE_SUFFIX
return normalizeSecureStoreKey(`${prefix}${suffix}`);
}
+async function readSecureStoreValue(key: string): Promise {
+ 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 {
const value = await SecureStore.getItemAsync(fromKey);
if (value == null) return;
@@ -31,6 +45,27 @@ async function copySecureStoreEntry(fromKey: string, toKey: string): Promise {
+ 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 {
if (fromPrefix === toPrefix) return;
diff --git a/lib/invoice-send-reminders.ts b/lib/invoice-send-reminders.ts
new file mode 100644
index 0000000..57fed9a
--- /dev/null
+++ b/lib/invoice-send-reminders.ts
@@ -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 {
+ 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();
+ 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}`);
+}
diff --git a/lib/query-client.ts b/lib/query-client.ts
new file mode 100644
index 0000000..980fb77
--- /dev/null
+++ b/lib/query-client.ts
@@ -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;
+ },
+ },
+ },
+ });
+}
diff --git a/lib/shortcuts.ts b/lib/shortcuts.ts
new file mode 100644
index 0000000..a9a2387
--- /dev/null
+++ b/lib/shortcuts.ts
@@ -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;
diff --git a/lib/time-clock-prefs.ts b/lib/time-clock-prefs.ts
new file mode 100644
index 0000000..13fe03e
--- /dev/null
+++ b/lib/time-clock-prefs.ts
@@ -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 {
+ return AsyncStorage.getItem(storageKey(accountId));
+}
+
+export async function setLastTimeClockClientId(
+ accountId: string,
+ clientId: string,
+): Promise {
+ if (!clientId) return;
+ await AsyncStorage.setItem(storageKey(accountId), clientId);
+}
+
+export async function clearTimeClockPrefsForAccount(accountId: string): Promise {
+ await AsyncStorage.removeItem(storageKey(accountId));
+}
diff --git a/lib/time-clock.ts b/lib/time-clock.ts
index 2f0feda..86d7fae 100644
--- a/lib/time-clock.ts
+++ b/lib/time-clock.ts
@@ -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;
diff --git a/lib/trpc-errors.ts b/lib/trpc-errors.ts
new file mode 100644
index 0000000..c312751
--- /dev/null
+++ b/lib/trpc-errors.ts
@@ -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")
+ );
+}
diff --git a/lib/trpc.tsx b/lib/trpc.tsx
index 5b3520f..c09201e 100644
--- a/lib/trpc.tsx
+++ b/lib/trpc.tsx
@@ -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();
-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 (
- {children}
+ {children}
);
}
diff --git a/package.json b/package.json
index a684c8d..209ebed 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"better-auth": "^1.6.19",
"expo": "~56.0.12",
"expo-blur": "~56.0.3",
+ "expo-build-properties": "^56.0.19",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-font": "~56.0.7",
@@ -25,6 +26,7 @@
"expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4",
"expo-network": "^56.0.5",
+ "expo-notifications": "^56.0.18",
"expo-router": "~56.2.11",
"expo-secure-store": "^56.0.4",
"expo-splash-screen": "~56.0.10",
diff --git a/plugins/app-intents/BeenVoiceShortcuts.swift b/plugins/app-intents/BeenVoiceShortcuts.swift
new file mode 100644
index 0000000..371a6ad
--- /dev/null
+++ b/plugins/app-intents/BeenVoiceShortcuts.swift
@@ -0,0 +1,36 @@
+import AppIntents
+
+@available(iOS 16.0, *)
+struct BeenVoiceShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ [
+ AppShortcut(
+ intent: ClockInIntent(),
+ phrases: [
+ "Clock in with \(.applicationName)",
+ "Start timer in \(.applicationName)",
+ ],
+ shortTitle: "Clock In",
+ systemImageName: "play.circle.fill"
+ ),
+ AppShortcut(
+ intent: ClockOutIntent(),
+ phrases: [
+ "Clock out in \(.applicationName)",
+ "Stop timer in \(.applicationName)",
+ ],
+ shortTitle: "Clock Out",
+ systemImageName: "stop.circle.fill"
+ ),
+ AppShortcut(
+ intent: OpenTimerIntent(),
+ phrases: [
+ "Open time clock in \(.applicationName)",
+ "Open timer in \(.applicationName)",
+ ],
+ shortTitle: "Time Clock",
+ systemImageName: "timer"
+ ),
+ ]
+ }
+}
diff --git a/plugins/app-intents/ClockInIntent.swift b/plugins/app-intents/ClockInIntent.swift
new file mode 100644
index 0000000..71132da
--- /dev/null
+++ b/plugins/app-intents/ClockInIntent.swift
@@ -0,0 +1,33 @@
+import AppIntents
+import UIKit
+
+@available(iOS 16.0, *)
+struct ClockInIntent: AppIntent {
+ static var title: LocalizedStringResource = "Clock In"
+ static var description = IntentDescription("Start the beenvoice time clock with your last client.")
+ static var openAppWhenRun: Bool = false
+
+ @Parameter(title: "Title")
+ var title: String?
+
+ func perform() async throws -> some IntentResult {
+ var components = URLComponents()
+ components.scheme = "beenvoice"
+ components.host = "shortcuts"
+ components.path = "/clock-in"
+
+ if let title, !title.isEmpty {
+ components.queryItems = [URLQueryItem(name: "title", value: title)]
+ }
+
+ guard let url = components.url else {
+ return .result()
+ }
+
+ await MainActor.run {
+ UIApplication.shared.open(url)
+ }
+
+ return .result()
+ }
+}
diff --git a/plugins/app-intents/ClockOutIntent.swift b/plugins/app-intents/ClockOutIntent.swift
new file mode 100644
index 0000000..f0dff9c
--- /dev/null
+++ b/plugins/app-intents/ClockOutIntent.swift
@@ -0,0 +1,21 @@
+import AppIntents
+import UIKit
+
+@available(iOS 16.0, *)
+struct ClockOutIntent: AppIntent {
+ static var title: LocalizedStringResource = "Clock Out"
+ static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
+ static var openAppWhenRun: Bool = false
+
+ func perform() async throws -> some IntentResult {
+ guard let url = URL(string: "beenvoice://shortcuts/clock-out") else {
+ return .result()
+ }
+
+ await MainActor.run {
+ UIApplication.shared.open(url)
+ }
+
+ return .result()
+ }
+}
diff --git a/plugins/app-intents/OpenTimerIntent.swift b/plugins/app-intents/OpenTimerIntent.swift
new file mode 100644
index 0000000..75c3925
--- /dev/null
+++ b/plugins/app-intents/OpenTimerIntent.swift
@@ -0,0 +1,21 @@
+import AppIntents
+import UIKit
+
+@available(iOS 16.0, *)
+struct OpenTimerIntent: AppIntent {
+ static var title: LocalizedStringResource = "Open Time Clock"
+ static var description = IntentDescription("Open the beenvoice time clock.")
+ static var openAppWhenRun: Bool = false
+
+ func perform() async throws -> some IntentResult {
+ guard let url = URL(string: "beenvoice://timer") else {
+ return .result()
+ }
+
+ await MainActor.run {
+ UIApplication.shared.open(url)
+ }
+
+ return .result()
+ }
+}
diff --git a/plugins/withAppIntents.js b/plugins/withAppIntents.js
new file mode 100644
index 0000000..3ad33f7
--- /dev/null
+++ b/plugins/withAppIntents.js
@@ -0,0 +1,76 @@
+// @ts-check
+const {
+ withDangerousMod,
+ withXcodeProject,
+ IOSConfig,
+} = require("@expo/config-plugins");
+const fs = require("fs");
+const path = require("path");
+
+const SWIFT_FILES = [
+ "ClockInIntent.swift",
+ "ClockOutIntent.swift",
+ "OpenTimerIntent.swift",
+ "BeenVoiceShortcuts.swift",
+];
+
+/** @type {import('@expo/config-plugins').ConfigPlugin} */
+function withAppIntents(config) {
+ const appIntentsSource = path.join(
+ config._internal?.projectRoot ?? process.cwd(),
+ "plugins",
+ "app-intents",
+ );
+
+ config = withDangerousMod(config, [
+ "ios",
+ async (config) => {
+ const platformRoot = config.modRequest.platformProjectRoot;
+ const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
+ const targetDir = path.join(platformRoot, projectName);
+
+ fs.mkdirSync(targetDir, { recursive: true });
+
+ for (const file of SWIFT_FILES) {
+ fs.copyFileSync(path.join(appIntentsSource, file), path.join(targetDir, file));
+ }
+
+ return config;
+ },
+ ]);
+
+ return withXcodeProject(config, (config) => {
+ const project = config.modResults;
+ const platformRoot = config.modRequest.platformProjectRoot;
+ const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
+
+ for (const file of SWIFT_FILES) {
+ const filepath = `${projectName}/${file}`;
+ const absolutePath = path.join(platformRoot, filepath);
+
+ if (!fs.existsSync(absolutePath)) continue;
+
+ const fileRef = project.pbxFileReferenceSection();
+ const alreadyLinked = Object.values(fileRef).some(
+ (entry) =>
+ entry &&
+ typeof entry === "object" &&
+ "path" in entry &&
+ entry.path === file,
+ );
+
+ if (!alreadyLinked) {
+ IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
+ filepath,
+ groupName: projectName,
+ project,
+ verbose: true,
+ });
+ }
+ }
+
+ return config;
+ });
+}
+
+module.exports = withAppIntents;