Add beenvoice mobile companion app with full dark mode support.
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>
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user