import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Alert, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; import { router } from "expo-router"; import { GlassSurface } from "@/components/GlassSurface"; import { LoadingScreen } from "@/components/LoadingScreen"; 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 { SelectField } from "@/components/ui/SelectField"; import { fonts, spacing } from "@/constants/theme"; import { useAppTheme } from "@/contexts/ThemeContext"; import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { tabLayout } from "@/lib/tab-layout"; import { formatDateTime } from "@/lib/format"; import { parseNonNegativeNumber } from "@/lib/form-validation"; import type { ThemeColors } from "@/lib/theme-palette"; import { useThemedStyles } from "@/lib/use-themed-styles"; import { endTimeClockLiveActivity, syncTimeClockLiveActivity, } from "@/lib/time-clock-live-activity"; import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock"; import { useRunningElapsed } from "@/lib/use-running-elapsed"; import { api } from "@/lib/trpc"; export type TimeClockPanelProps = { defaultClientId?: string; defaultInvoiceId?: string; /** Hides the in-panel title card when idle (tab screen already has PageHeader). */ compact?: boolean; header?: ReactNode; }; export function TimeClockPanel({ defaultClientId = "", defaultInvoiceId = "", compact = false, header, }: TimeClockPanelProps) { const { colors } = useAppTheme(); const styles = useThemedStyles(createTimeClockStyles); 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(DEFAULT_CLOCK_DESCRIPTION); const [rateText, setRateText] = useState(""); const [startedAt, setStartedAt] = useState(() => new Date()); 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 todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart }); const scrollPadding = useTabBarScrollPadding(); 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(); 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(DEFAULT_CLOCK_DESCRIPTION); }, }); useEffect(() => { if (!running) return; setClientId(running.clientId ?? ""); setInvoiceId(running.invoiceId ?? ""); setDescription(running.description?.trim() || DEFAULT_CLOCK_DESCRIPTION); setRateText(running.rate != null ? String(running.rate) : ""); }, [running]); useEffect(() => { if (running || !clientId || rateText) return; const client = clients.find((c) => c.id === clientId); if (client?.defaultHourlyRate) { setRateText(String(client.defaultHourlyRate)); } }, [clientId, clients, rateText, running]); 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 rate = parseFloat(rateText) || 0; const displayRate = running ? (running.rate ?? 0) : rate; const clientOptions = useMemo( () => clients.map((client) => ({ label: client.name, value: client.id })), [clients], ); const invoiceOptions = useMemo( () => [ { label: "No invoice — save entry only", value: "" }, ...billableInvoices.map((invoice) => ({ label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber} (${invoice.status})`, value: invoice.id, })), ], [billableInvoices], ); const clockInErrors = useMemo(() => { const next: { clientId?: string; rate?: string } = {}; if (!clientId) next.clientId = "Select a client"; if (rateText.trim() && parseNonNegativeNumber(rateText) === null) { next.rate = "Enter a valid hourly rate"; } return next; }, [clientId, rateText]); const canClockIn = Object.keys(clockInErrors).length === 0; async function handleClockIn() { if (!canClockIn) return; try { const backdated = Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined; await clockIn.mutateAsync({ description: resolveClockDescription(description), clientId: clientId || "", invoiceId: invoiceId || undefined, rate: rate || undefined, startedAt: backdated, }); setStartedAt(new Date()); } 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); if (client?.defaultHourlyRate != null) { setRateText(String(client.defaultHourlyRate)); } } 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 todayEntries = (todayQuery.data ?? []).filter((entry) => entry.endedAt); 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(" · "); return ( { void runningQuery.refetch(); void clientsQuery.refetch(); void billableQuery.refetch(); void todayQuery.refetch(); }} tintColor={colors.primary} /> } > {header} {running || !compact ? ( {running ? ( <> Running {formatElapsedSeconds(elapsed)} {resolveClockDescription(description)} Started {formatDateTime(running.startedAt)} {runningMeta ? ` · ${runningMeta}` : ""} ) : ( Track billable time and link it to invoices. )} ) : null} {running ? ( void handleRunningClientChange(next)} /> void handleRunningInvoiceChange(next)} /> ) : ( { setClientId(next); setInvoiceId(""); const client = clients.find((c) => c.id === next); setRateText( client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "", ); }} /> Set an earlier time if you forgot to clock in when you started working. )} {running ? (