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
+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>
);
}