06bc91ac13
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable client picker with last-client defaults, account refresh/remove, and softer session handling on unauthorized API responses. Co-authored-by: Cursor <cursoragent@cursor.com>
942 lines
29 KiB
TypeScript
942 lines
29 KiB
TypeScript
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<StartMode>("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<string[]>([]);
|
||
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(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<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(),
|
||
]);
|
||
},
|
||
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 <LoadingScreen message="Loading time clock…" />;
|
||
}
|
||
|
||
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 (
|
||
<FilterChip
|
||
key={client.id}
|
||
label={client.name}
|
||
active={clientId === client.id}
|
||
onPress={() => {
|
||
if (controlsDisabled) return;
|
||
if (running) void handleRunningClientChange(client.id);
|
||
else 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 ? (
|
||
<>
|
||
<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.runningMeta}>
|
||
Started {formatDateTime(running.startedAt)}
|
||
{runningMeta ? ` · ${runningMeta}` : ""}
|
||
</Text>
|
||
</>
|
||
) : (
|
||
<Text style={styles.idleHint}>
|
||
Choose a client and clock in. A draft invoice is created automatically if needed.
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</GlassSurface>
|
||
) : null}
|
||
|
||
<GlassSurface style={styles.setupCard}>
|
||
<Input
|
||
label="Title"
|
||
value={description}
|
||
onChangeText={setDescription}
|
||
placeholder="What are you working on?"
|
||
returnKeyType="done"
|
||
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
|
||
/>
|
||
|
||
<View style={styles.setupSection}>
|
||
<Text style={styles.sectionLabel}>Client</Text>
|
||
{clients.length === 0 ? (
|
||
<Text style={styles.emptyClients}>
|
||
Add a client first to start tracking time.
|
||
</Text>
|
||
) : (
|
||
<>
|
||
<View style={styles.chipWrap}>
|
||
{featuredClients.map((client) => renderClientChip(client))}
|
||
{moreClients.length > 0 ? (
|
||
<FilterChip
|
||
label={clientsExpanded ? "Show less" : "Show more"}
|
||
active={clientsExpanded}
|
||
onPress={() => {
|
||
if (controlsDisabled) return;
|
||
setClientsExpanded((open) => !open);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
{clientsExpanded && moreClients.length > 0 ? (
|
||
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
|
||
{moreClients.map((client) => renderClientChip(client))}
|
||
</View>
|
||
) : null}
|
||
</>
|
||
)}
|
||
{clockInErrors.clientId && !running ? (
|
||
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
||
) : null}
|
||
</View>
|
||
|
||
{clientId ? (
|
||
<View style={styles.setupSection}>
|
||
<Text style={styles.sectionLabel}>Invoice</Text>
|
||
<View style={styles.chipWrap}>
|
||
<FilterChip
|
||
label="Entry only"
|
||
active={!invoiceId}
|
||
onPress={() => {
|
||
if (controlsDisabled) return;
|
||
if (running) void handleRunningInvoiceChange("");
|
||
else setInvoiceId("");
|
||
}}
|
||
/>
|
||
{billableInvoices.map((invoice) => {
|
||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||
return (
|
||
<FilterChip
|
||
key={invoice.id}
|
||
label={label}
|
||
active={invoiceId === invoice.id}
|
||
onPress={() => {
|
||
if (controlsDisabled) return;
|
||
if (running) void handleRunningInvoiceChange(invoice.id);
|
||
else setInvoiceId(invoice.id);
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{!running && clientId ? (
|
||
<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>
|
||
) : null}
|
||
</GlassSurface>
|
||
|
||
{running ? (
|
||
<Button
|
||
title="Clock out"
|
||
variant="danger"
|
||
loading={clockOut.isPending}
|
||
onPress={handleClockOut}
|
||
/>
|
||
) : (
|
||
<Button
|
||
title="Clock in"
|
||
loading={clockIn.isPending}
|
||
disabled={!canClockIn || clients.length === 0}
|
||
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}>{resolveClockDescription(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>
|
||
</>
|
||
);
|
||
|
||
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}
|
||
</TabScrollView>
|
||
);
|
||
}
|
||
|
||
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"],
|
||
},
|
||
runningMeta: {
|
||
fontSize: 13,
|
||
fontFamily: fonts.body,
|
||
color: colors.mutedForeground,
|
||
},
|
||
idleHint: {
|
||
fontSize: 14,
|
||
fontFamily: fonts.body,
|
||
color: colors.mutedForeground,
|
||
lineHeight: 20,
|
||
},
|
||
setupCard: {
|
||
padding: spacing.md,
|
||
gap: spacing.lg,
|
||
},
|
||
setupSection: {
|
||
gap: spacing.sm,
|
||
paddingTop: spacing.lg,
|
||
},
|
||
titleInput: {
|
||
minHeight: 44,
|
||
textAlignVertical: "center",
|
||
},
|
||
titleInputPlaceholder: {
|
||
textAlign: "center",
|
||
},
|
||
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,
|
||
},
|
||
});
|