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 { TabScrollView } from "@/components/TabScrollView"; 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, syncTimeClockLiveActivity, } from "@/lib/time-clock-live-activity"; import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, 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 [rateText, setRateText] = useState(""); const [startedAt, setStartedAt] = useState(() => new Date()); const [startMode, setStartMode] = useState("now"); const [agoMinutes, setAgoMinutes] = useState(60); const [agoMinutesText, setAgoMinutesText] = useState("60"); const [optionsExpanded, setOptionsExpanded] = useState(false); const [clientsExpanded, setClientsExpanded] = useState(false); const [featuredClientIds, setFeaturedClientIds] = useState([]); const [storedLastClientId, setStoredLastClientId] = useState(null); const [prefsLoaded, setPrefsLoaded] = useState(false); const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId)); 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(); 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(), ]); }, onError: (err) => { Alert.alert("Could not update timer", err.message); }, }); 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(""); }, }); 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?.trim() ?? ""); setRateText(running.rate != null ? String(running.rate) : ""); }, [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 (running || defaultClientId || initialClientResolved) return; if (!prefsLoaded || clients.length === 0) return; const preferredId = storedLastClientId && clients.some((client) => client.id === storedLastClientId) ? storedLastClientId : recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null; if (preferredId) { const client = clients.find((c) => c.id === preferredId); setClientId(preferredId); setRateText(clientRateText(client)); } setInitialClientResolved(true); }, [ clients, defaultClientId, initialClientResolved, prefsLoaded, recentClientIds, running, storedLastClientId, ]); 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]); useEffect(() => { if (!running) { void endTimeClockLiveActivity(); return; } const sync = () => { const seconds = Math.floor( (Date.now() - new Date(running.startedAt).getTime()) / 1000, ); void syncTimeClockLiveActivity({ ...running, description }, seconds); }; sync(); const interval = setInterval(sync, 15_000); return () => clearInterval(interval); }, [running, description]); 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: { clientId?: string; rate?: string; start?: string } = {}; if (!clientId) next.clientId = "Choose a client to start"; 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, clientId, 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); setClientId(nextClientId); setInvoiceId(""); setRateText(clientRateText(client)); if (!featuredClientIds.includes(nextClientId)) { setClientsExpanded(true); } void persistClientChoice(nextClientId); } 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, }); 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: description.trim() ? description.trim() : undefined, }); } catch (err) { Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again"); } } async function handleRunningClientChange(nextClientId: string) { if (!running) return; setClientId(nextClientId); setInvoiceId(""); try { await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" }); const client = clients.find((c) => c.id === nextClientId); setRateText(clientRateText(client)); await persistClientChoice(nextClientId); } catch { setClientId(running.clientId ?? ""); setInvoiceId(running.invoiceId ?? ""); } } async function handleRunningInvoiceChange(nextInvoiceId: string) { if (!running) return; const previous = invoiceId; setInvoiceId(nextInvoiceId); try { await updateRunning.mutateAsync({ invoiceId: nextInvoiceId }); } catch { setInvoiceId(previous); } } if (runningQuery.isLoading || clientsQuery.isLoading) { return ; } 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(" · "); const controlsDisabled = Boolean(running && updateRunning.isPending); function renderClientChip(client: (typeof clients)[number]) { return ( { if (controlsDisabled) return; if (running) void handleRunningClientChange(client.id); else selectClient(client.id); }} /> ); } return ( { void runningQuery.refetch(); void clientsQuery.refetch(); void billableQuery.refetch(); void entriesQuery.refetch(); }} tintColor={colors.primary} /> } > {running || !compact ? ( {running ? ( <> Running {formatElapsedSeconds(elapsed)} Started {formatDateTime(running.startedAt)} {runningMeta ? ` · ${runningMeta}` : ""} ) : ( Choose a client and clock in. A draft invoice is created automatically if needed. )} ) : null} Client {clients.length === 0 ? ( Add a client first to start tracking time. ) : ( <> {featuredClients.map((client) => renderClientChip(client))} {moreClients.length > 0 ? ( { if (controlsDisabled) return; setClientsExpanded((open) => !open); }} /> ) : null} {clientsExpanded && moreClients.length > 0 ? ( {moreClients.map((client) => renderClientChip(client))} ) : null} )} {clockInErrors.clientId && !running ? ( {clockInErrors.clientId} ) : null} {clientId ? ( Invoice { if (controlsDisabled) return; if (running) void handleRunningInvoiceChange(""); else setInvoiceId(""); }} /> {billableInvoices.map((invoice) => { const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`; return ( { if (controlsDisabled) return; if (running) void handleRunningInvoiceChange(invoice.id); else setInvoiceId(invoice.id); }} /> ); })} ) : null} {!running && clientId ? ( setOptionsExpanded((open) => !open)} style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]} > Rate & start time {!optionsExpanded ? ( {optionsSummary} ) : null} {optionsExpanded ? "−" : "+"} {optionsExpanded ? ( {selectedClient?.defaultHourlyRate != null && !rateText.trim() ? ( Defaults to{" "} {formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client ) : null} Start selectStartMode("now")} /> selectStartMode("at")} /> selectStartMode("ago")} /> {startMode === "at" ? ( { setStartedAt(date); setStartMode("at"); }} /> ) : null} {startMode === "ago" ? ( {AGO_PRESETS.map((preset) => ( selectAgoPreset(preset.minutes)} /> ))} Started min ago ) : null} {clockInErrors.start ? ( {clockInErrors.start} ) : null} ) : null} ) : null} {running ? (