Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { api } from "@/lib/trpc";
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
export function TimeClockLiveActivitySync() {
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 60_000,
});
const running = runningQuery.data;
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity(running, seconds);
};
sync();
const interval = setInterval(sync, 60_000);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") sync();
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [running]);
return null;
}
@@ -0,0 +1,999 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
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 { 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";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
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,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
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;
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 = "",
compact = false,
header,
}: 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,
});
const clientsQuery = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
const [agoMinutes, setAgoMinutes] = useState(60);
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);
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
const clients = clientsQuery.data ?? [];
const activeClientId = running?.clientId ?? clientId;
const billableQuery = api.invoices.getBillable.useQuery(
activeClientId ? { clientId: activeClientId } : undefined,
);
const billableInvoices = billableQuery.data ?? [];
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
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 () => {
await utils.timeEntries.getRunning.invalidate();
},
});
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();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
rate: data.rate,
invoice: data.invoice,
});
Alert.alert(
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
message,
);
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setStopNote("");
},
});
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 ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
setRunningStartedAt(new Date(running.startedAt));
}, [running]);
useEffect(() => {
if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
if (!client?.defaultHourlyRate) return;
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
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]);
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 featuredClients = useMemo(
() =>
featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
);
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: { rate?: string; start?: string } = {};
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;
}, [agoMinutes, 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);
if (running) {
updateRunning.mutate({
clientId: nextClientId,
invoiceId: "",
rate: client?.defaultHourlyRate ?? undefined,
});
return;
}
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
if (nextClientId) {
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);
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) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try {
const backdated =
startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
if (clientId) {
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");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: stopNote.trim() ? stopNote.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const runningTitle = formatRunningTimerLabel(running?.description);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ");
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
);
}
return (
<TabScrollView
style={styles.scroll}
header={header}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
onRefresh={() => {
void runningQuery.refetch();
void clientsQuery.refetch();
void billableQuery.refetch();
void entriesQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={[styles.hero, running && styles.heroRunning]}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabelRunning}>Timer running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</>
) : (
<Text style={styles.idleHint}>
Start the timer anytime add client, invoice, and details later.
</Text>
)}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{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}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : (
<>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done"
style={[styles.titleField, { color: colors.foreground }]}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
No clients yet you can still start the timer and assign a client later.
</Text>
) : (
<>
<View style={styles.chipWrap}>
<FilterChip
label="No client"
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
</View>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>
No invoice for now. Add a client and invoice later if this becomes billable.
</Text>
) : (
<View style={styles.chipWrap}>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
<FilterChip
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
</View>
)}
</View>
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate}
/>
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/>
) : null}
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text>
</View>
</View>
) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</GlassSurface>
{todayEntries.length > 0 ? (
<Card title="Today's entries">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
return (
<SwipeableRow
key={entry.id}
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);
}
},
},
]}
>
<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>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
},
hero: {
padding: spacing.lg,
gap: spacing.sm,
},
heroRunning: {
alignItems: "center",
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
heroLabelRunning: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
color: colors.primary,
},
timerValue: {
fontSize: 56,
lineHeight: 60,
fontFamily: fonts.mono,
color: colors.primary,
fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
},
runningMeta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
setupCard: {
padding: spacing.lg,
gap: spacing.lg,
},
cardTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
formSection: {
gap: spacing.md,
},
titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48,
paddingVertical: spacing.xs,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
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,
},
});
@@ -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,
},
});