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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { describeClockOutOutcome, formatElapsedSeconds } 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("");
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("");
},
});
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.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, 30_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],
);
async function handleClockIn() {
try {
const backdated =
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
await clockIn.mutateAsync({
description: description.trim(),
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() || 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)}
{description.trim() || "No 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 ? (
) : (
)}
{todayEntries.length > 0 ? (
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
const row = (
<>
{entry.description || "No description"}
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
{entry.hours ?? "—"}h
>
);
if (!entry.invoice) {
return (
{row}
);
}
return (
router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
>
{row}
);
})}
) : null}
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
},
hero: {
padding: spacing.md,
gap: spacing.sm,
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontFamily: fonts.mono,
color: colors.foreground,
fontVariant: ["tabular-nums"],
},
runningTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
runningMeta: {
fontSize: 13,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
marginTop: spacing.xs,
},
startedHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 18,
marginTop: -spacing.xs,
},
formCard: {
gap: 0,
},
formFields: {
gap: spacing.md,
},
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,
},
});