14c880123c
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities. Co-authored-by: Cursor <cursoragent@cursor.com>
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
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 <LoadingScreen message="Loading time clock…" />;
|
|
}
|
|
|
|
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 (
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]}
|
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={runningQuery.isRefetching}
|
|
onRefresh={() => {
|
|
void runningQuery.refetch();
|
|
void clientsQuery.refetch();
|
|
void billableQuery.refetch();
|
|
void todayQuery.refetch();
|
|
}}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
{header}
|
|
<View style={tabLayout.scrollBody}>
|
|
{running || !compact ? (
|
|
<GlassSurface style={running ? styles.runningCard : undefined}>
|
|
<View style={styles.hero}>
|
|
{running ? (
|
|
<>
|
|
<View style={styles.heroHeader}>
|
|
<View style={styles.pulseDot} />
|
|
<Text style={styles.heroLabel}>Running</Text>
|
|
</View>
|
|
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
|
<Text style={styles.runningTitle}>
|
|
{description.trim() || "No description"}
|
|
</Text>
|
|
<Text style={styles.runningMeta}>
|
|
Started {formatDateTime(running.startedAt)}
|
|
{runningMeta ? ` · ${runningMeta}` : ""}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<Text style={styles.idleHint}>Track billable time and link it to invoices.</Text>
|
|
)}
|
|
</View>
|
|
</GlassSurface>
|
|
) : null}
|
|
|
|
{running ? (
|
|
<Card style={styles.formCard}>
|
|
<View style={styles.formFields}>
|
|
<SelectField
|
|
label="Client"
|
|
placeholder="Select client…"
|
|
value={clientId}
|
|
options={clientOptions}
|
|
disabled={updateRunning.isPending}
|
|
onValueChange={(next) => void handleRunningClientChange(next)}
|
|
/>
|
|
|
|
<SelectField
|
|
label="Invoice"
|
|
placeholder="No invoice — save entry only"
|
|
value={invoiceId}
|
|
options={invoiceOptions}
|
|
disabled={updateRunning.isPending}
|
|
onValueChange={(next) => void handleRunningInvoiceChange(next)}
|
|
/>
|
|
|
|
<Input
|
|
label="Description"
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
placeholder="What are you working on?"
|
|
/>
|
|
</View>
|
|
</Card>
|
|
) : (
|
|
<Card style={styles.formCard}>
|
|
<View style={styles.formFields}>
|
|
<SelectField
|
|
label="Client"
|
|
placeholder="Select client…"
|
|
value={clientId}
|
|
options={clientOptions}
|
|
onValueChange={(next) => {
|
|
setClientId(next);
|
|
setInvoiceId("");
|
|
const client = clients.find((c) => c.id === next);
|
|
setRateText(
|
|
client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<SelectField
|
|
label="Invoice"
|
|
placeholder="No invoice — save entry only"
|
|
value={invoiceId}
|
|
options={invoiceOptions}
|
|
disabled={!clientId}
|
|
onValueChange={setInvoiceId}
|
|
/>
|
|
|
|
<Input
|
|
label="Description"
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
placeholder="What are you working on?"
|
|
/>
|
|
|
|
<Input
|
|
label="Hourly rate"
|
|
value={rateText}
|
|
onChangeText={setRateText}
|
|
keyboardType="decimal-pad"
|
|
placeholder="0.00"
|
|
/>
|
|
|
|
<DateTimeField
|
|
label="Started at"
|
|
value={startedAt}
|
|
maximumDate={new Date()}
|
|
onChange={setStartedAt}
|
|
/>
|
|
<Text style={styles.startedHint}>
|
|
Set an earlier time if you forgot to clock in when you started working.
|
|
</Text>
|
|
</View>
|
|
</Card>
|
|
)}
|
|
|
|
{running ? (
|
|
<Button
|
|
title="Clock out"
|
|
variant="danger"
|
|
loading={clockOut.isPending}
|
|
onPress={handleClockOut}
|
|
/>
|
|
) : (
|
|
<Button title="Clock in" loading={clockIn.isPending} onPress={handleClockIn} />
|
|
)}
|
|
|
|
{todayEntries.length > 0 ? (
|
|
<Card title="Today">
|
|
{todayEntries.map((entry) => {
|
|
const invoiceLabel = entry.invoice
|
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
|
: null;
|
|
|
|
const row = (
|
|
<>
|
|
<View style={styles.entryMeta}>
|
|
<Text style={styles.entryTitle}>{entry.description || "No 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>
|
|
</>
|
|
);
|
|
|
|
if (!entry.invoice) {
|
|
return (
|
|
<View key={entry.id} style={styles.entryRow}>
|
|
{row}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
key={entry.id}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`View invoice ${invoiceLabel}`}
|
|
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
|
|
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
|
|
>
|
|
{row}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</Card>
|
|
) : null}
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|