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
@@ -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,
},
});