Stabilize mobile auth session handling
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user