Stabilize mobile auth session handling

This commit is contained in:
2026-06-29 17:58:50 -04:00
parent 477459edd4
commit 57e1aa658a
25 changed files with 1126 additions and 192 deletions
+2 -1
View File
@@ -92,8 +92,9 @@ export function AccountSwitcher() {
}
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
signOut: () => authClient.signOut(),
activeAccountId,
});
},
);
+29 -4
View File
@@ -1,12 +1,18 @@
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useSession } from "@/contexts/AuthContext";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { performAuthReset } from "@/lib/auth-session";
import { isRateLimitError } from "@/lib/trpc-errors";
/** Refetch auth session when the app returns to the foreground. */
export function SessionSync() {
const { refetch } = useSession();
const authClient = useAuthClient();
const { activeAccountId, clearActiveAccount } = useAccounts();
const { data: session, refetch } = useSession();
const wasBackgrounded = useRef(false);
const resettingRef = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
@@ -17,11 +23,30 @@ export function SessionSync() {
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void refetch();
void (async () => {
await refetch();
const next = await authClient.getSession();
if (next.error && isRateLimitError(next.error)) return;
if (next.data?.user) return;
if (!session?.user || resettingRef.current) return;
resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
}
})();
});
return () => subscription.remove();
}, [refetch]);
}, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
return null;
}
+33 -1
View File
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { CompactDateField } from "@/components/ui/CompactDateField";
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
import { SwipeableRow } from "@/components/SwipeableRow";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatShortDate } from "@/lib/format";
@@ -21,6 +22,7 @@ type LineItemEditorProps = {
currency: string;
onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void;
onDuplicate?: () => void;
readOnly?: boolean;
isLast?: boolean;
};
@@ -38,6 +40,7 @@ export function LineItemEditor({
currency,
onChange,
onRemove,
onDuplicate,
readOnly = false,
isLast = false,
}: LineItemEditorProps) {
@@ -70,7 +73,7 @@ export function LineItemEditor({
);
}
return (
const content = (
<View
style={[
styles.editBlock,
@@ -156,6 +159,35 @@ export function LineItemEditor({
</View>
</View>
);
const swipeActions = [
...(onDuplicate
? [
{
key: "duplicate",
label: "Copy",
icon: "copy-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: onDuplicate,
},
]
: []),
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: onRemove,
},
];
return (
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
{content}
</SwipeableRow>
);
}
const styles = StyleSheet.create({
+160 -52
View File
@@ -13,7 +13,9 @@ import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
@@ -98,6 +100,8 @@ export function TimeClockPanel({
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
@@ -140,6 +144,15 @@ export function TimeClockPanel({
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
@@ -184,8 +197,10 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
setRunningStartedAt(new Date(running.startedAt));
}, [running]);
useEffect(() => {
@@ -269,8 +284,7 @@ export function TimeClockPanel({
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string; start?: string } = {};
if (!clientId) next.clientId = "Choose a client to start";
const next: { rate?: string; start?: string } = {};
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
@@ -278,7 +292,7 @@ export function TimeClockPanel({
next.start = "Enter how long ago you started";
}
return next;
}, [agoMinutes, clientId, rateText, startMode]);
}, [agoMinutes, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
@@ -307,6 +321,14 @@ export function TimeClockPanel({
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
if (running) {
updateRunning.mutate({
clientId: nextClientId,
invoiceId: "",
rate: client?.defaultHourlyRate ?? undefined,
});
return;
}
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
@@ -316,6 +338,27 @@ export function TimeClockPanel({
void persistClientChoice(nextClientId);
}
function selectInvoice(nextInvoiceId: string) {
if (running) {
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
return;
}
setInvoiceId(nextInvoiceId);
}
function handleRunningDescriptionBlur() {
if (!running) return;
const next = resolveClockDescription(description);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtChange(date: Date) {
setRunningStartedAt(date);
if (!running || date > new Date()) return;
updateRunning.mutate({ startedAt: date });
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
@@ -363,7 +406,9 @@ export function TimeClockPanel({
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
await persistClientChoice(clientId);
if (clientId) {
await persistClientChoice(clientId);
}
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
@@ -445,7 +490,7 @@ export function TimeClockPanel({
</>
) : (
<Text style={styles.idleHint}>
Choose a client and clock in. A draft invoice is created automatically if needed.
Start the timer anytime add client, invoice, and details later.
</Text>
)}
</View>
@@ -457,6 +502,62 @@ export function TimeClockPanel({
{running ? (
<View style={styles.formSection}>
<Input
label="What are you working on?"
value={description}
onChangeText={setDescription}
onBlur={handleRunningDescriptionBlur}
placeholder="What are you working on?"
returnKeyType="done"
/>
<DateTimeField
label="Started at"
value={runningStartedAt}
maximumDate={new Date()}
onChange={handleRunningStartedAtChange}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => selectClient("")}
/>
{clients.map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
))}
</View>
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => selectInvoice("")}
/>
{billableInvoices.map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => selectInvoice(invoice.id)}
/>
))}
</View>
</View>
) : null}
<Input
label="Note on stop (optional)"
value={stopNote}
@@ -490,7 +591,7 @@ export function TimeClockPanel({
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
Add a client first to start tracking time.
No clients yet you can still start the timer and assign a client later.
</Text>
) : (
<>
@@ -511,20 +612,15 @@ export function TimeClockPanel({
) : null}
</>
)}
{clockInErrors.clientId ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>Pick a client to attach a draft invoice.</Text>
) : (
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => setInvoiceId("")}
/>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
@@ -537,11 +633,10 @@ export function TimeClockPanel({
);
})}
</View>
</View>
) : null}
)}
</View>
{clientId ? (
<View style={styles.setupSection}>
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
@@ -647,12 +742,11 @@ export function TimeClockPanel({
</View>
) : null}
</View>
) : null}
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
disabled={!canClockIn}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
@@ -667,41 +761,55 @@ export function TimeClockPanel({
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</>
);
if (!entry.invoice) {
return (
<View key={entry.id} style={styles.entryRow}>
{row}
</View>
);
}
return (
<Pressable
<SwipeableRow
key={entry.id}
accessibilityRole="button"
accessibilityLabel={`View invoice ${invoiceLabel}`}
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => setEditEntryId(entry.id),
},
{
key: "invoice",
label: "Invoice",
icon: "document-text-outline",
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => {
if (entry.invoice?.id) {
router.push(`/(app)/invoices/${entry.invoice.id}`);
} else {
setEditEntryId(entry.id);
}
},
},
]}
>
{row}
</Pressable>
<View style={styles.entryRow}>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</View>
</SwipeableRow>
);
})}
</Card>
) : null}
<TimeEntryEditSheet
entryId={editEntryId}
visible={editEntryId != null}
onClose={() => setEditEntryId(null)}
/>
</TabScrollView>
);
}
@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { Button } from "@/components/ui/Button";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type TimeEntryEditSheetProps = {
entryId: string | null;
visible: boolean;
onClose: () => void;
};
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
const utils = api.useUtils();
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: visible && Boolean(entryId) },
);
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
const [description, setDescription] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceId, setInvoiceId] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [endedAt, setEndedAt] = useState(() => new Date());
const billableQuery = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: visible && Boolean(clientId) },
);
useEffect(() => {
const entry = entryQuery.data;
if (!entry) return;
setDescription(entry.description ?? "");
setClientId(entry.clientId ?? "");
setInvoiceId(entry.invoiceId ?? "");
setRateText(entry.rate != null ? String(entry.rate) : "");
setStartedAt(new Date(entry.startedAt));
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
}, [entryQuery.data]);
const hoursPreview = useMemo(() => {
if (endedAt <= startedAt) return null;
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const rate = parseNonNegativeNumber(rateText);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not save", err.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not delete", err.message),
});
function handleSave() {
if (!entryId) return;
if (endedAt <= startedAt) {
Alert.alert("Invalid times", "End time must be after start time.");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate: rate ?? undefined,
startedAt,
endedAt,
hours: hoursPreview ?? undefined,
});
}
function confirmDelete() {
if (!entryId) return;
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteEntry.mutate({ id: entryId }),
},
]);
}
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{entryQuery.isLoading ? (
<Text style={{ color: colors.mutedForeground }}>Loading</Text>
) : (
<>
<Input label="Description" value={description} onChangeText={setDescription} />
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => {
setClientId("");
setInvoiceId("");
}}
/>
{(clientsQuery.data ?? []).map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
setClientId(client.id);
setInvoiceId("");
}}
/>
))}
</View>
{clientId ? (
<>
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Not on invoice"
active={!invoiceId}
onPress={() => setInvoiceId("")}
/>
{(billableQuery.data ?? []).map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
))}
</View>
</>
) : null}
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
/>
<DateTimeField
label="Started"
value={startedAt}
maximumDate={endedAt}
onChange={setStartedAt}
/>
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
{hoursPreview != null ? (
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
{hoursPreview.toFixed(2)}h
{rate != null && rate > 0
? ` · ${formatCurrency(hoursPreview * rate)}`
: ""}
</Text>
) : null}
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
<Button
title="Delete entry"
variant="danger"
loading={deleteEntry.isPending}
onPress={confirmDelete}
/>
</>
)}
</ScrollView>
</View>
</Modal>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
},
close: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
body: {
padding: spacing.lg,
gap: spacing.md,
},
label: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
preview: {
fontFamily: fonts.body,
fontSize: 14,
},
});