Polish mobile and web experience

This commit is contained in:
2026-08-17 00:18:55 -04:00
parent 6c74436092
commit 9929d7321d
28 changed files with 835 additions and 688 deletions
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
@@ -13,6 +12,7 @@ import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
@@ -20,16 +20,14 @@ 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 { 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 { setLastTimeClockClientId } from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
@@ -99,12 +97,8 @@ export function TimeClockPanel({
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
@@ -124,19 +118,6 @@ export function TimeClockPanel({
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();
@@ -180,18 +161,6 @@ export function TimeClockPanel({
},
});
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 ?? "");
@@ -209,22 +178,6 @@ export function TimeClockPanel({
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
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]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
@@ -235,21 +188,6 @@ export function TimeClockPanel({
? (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);
@@ -285,11 +223,14 @@ export function TimeClockPanel({
),
[entriesQuery.data, todayStart],
);
const todayHours = useMemo(
() => todayEntries.reduce((total, entry) => total + Number(entry.hours ?? 0), 0),
[todayEntries],
);
async function persistClientChoice(nextClientId: string, syncState = false) {
async function persistClientChoice(nextClientId: string) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
}
function selectClient(nextClientId: string) {
@@ -305,9 +246,6 @@ export function TimeClockPanel({
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
if (nextClientId) {
void persistClientChoice(nextClientId);
}
@@ -420,63 +358,83 @@ export function TimeClockPanel({
.join(" · ");
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => 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();
}}
<PullToRefresh
onRefresh={() =>
Promise.all([
runningQuery.refetch(),
clientsQuery.refetch(),
billableQuery.refetch(),
entriesQuery.refetch(),
])
}
tintColor={colors.primary}
/>
}
>
{running || !compact ? (
{running ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={[styles.hero, running && styles.heroRunning]}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabelRunning}>Timer running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</>
) : (
<Text style={styles.idleHint}>
Start the timer anytime add client, invoice, and details later.
</Text>
)}
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabelRunning}>In progress</Text>
</View>
<Text selectable style={styles.timerValue}>
{formatElapsedSeconds(elapsed)}
</Text>
<Text selectable style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text selectable style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{!running ? (
<View style={styles.idleIntro}>
<Text style={styles.idleEyebrow}>Ready to start</Text>
<Text style={styles.idleTitle}>What are you working on?</Text>
<Text style={styles.idleCopy}>
Add what you know now. You can update the rest while the timer runs.
</Text>
</View>
) : null}
{running ? (
<View style={styles.formSection}>
<>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save entry"}
variant="danger"
leftIcon="stop"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
<Pressable
accessible
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [
styles.optionsToggle,
pressed && styles.optionsTogglePressed,
]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Edit timer details</Text>
<Text style={styles.optionsToggleSummary}>
Started {formatDateTime(runningStartedAt)}
</Text>
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? <View style={styles.formSection}>
<Input
label="What are you working on?"
value={description}
@@ -544,79 +502,49 @@ export function TimeClockPanel({
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
</View> : null}
</>
) : (
<>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done"
style={[styles.titleField, { color: colors.foreground }]}
/>
<View style={styles.idleFields}>
<Input
label="Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Client kickoff…"
returnKeyType="done"
style={styles.titleField}
containerStyle={styles.timerField}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
No clients yet you can still start the timer and assign a client later.
</Text>
) : (
<>
<View style={styles.chipWrap}>
<FilterChip
label="No client"
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
</View>
<SelectField
label="Client"
placeholder="Select a client"
value={clientId}
options={[
{ label: "No client", value: "" },
...clients.map((client) => ({ label: client.name, value: client.id })),
]}
onValueChange={selectClient}
containerStyle={styles.timerField}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>
No invoice for now. Add a client and invoice later if this becomes billable.
</Text>
) : (
<View style={styles.chipWrap}>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
<FilterChip
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
<SelectField
label="Invoice (optional)"
placeholder={clientId ? "Select an invoice" : "Choose a client first"}
value={clientId ? invoiceId : "__client_required__"}
disabled={!clientId}
options={[
{ label: "Entry only — no invoice", value: "" },
...billableInvoices.map((invoice) => ({
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`,
value: invoice.id,
})),
]}
onValueChange={selectInvoice}
containerStyle={styles.timerField}
/>
</View>
)}
</View>
<View style={styles.setupSection}>
<Pressable
@@ -736,9 +664,8 @@ export function TimeClockPanel({
)}
</GlassSurface>
{todayEntries.length > 0 ? (
<Card title="Today's entries">
{todayEntries.map((entry) => {
<Card title={`Today · ${todayHours.toFixed(2)}h`}>
{todayEntries.length > 0 ? todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
@@ -783,9 +710,20 @@ export function TimeClockPanel({
</View>
</SwipeableRow>
);
})}
</Card>
) : null}
}) : (
<View style={styles.todayEmpty}>
<Text style={styles.todayEmptyTitle}>No time logged yet</Text>
<Text style={styles.todayEmptyCopy}>
Start your first timer or open history to add an entry manually.
</Text>
</View>
)}
<Button
title="View time history"
variant="secondary"
onPress={() => router.push("/(app)/more/time-entries")}
/>
</Card>
<TimeEntryEditSheet
entryId={editEntryId}
@@ -857,25 +795,45 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
},
setupCard: {
padding: spacing.lg,
gap: spacing.lg,
gap: spacing.md,
},
cardTitle: {
fontSize: 16,
idleIntro: {
gap: spacing.xs,
paddingBottom: spacing.xs,
},
idleEyebrow: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.7,
},
idleTitle: {
fontSize: 22,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
idleCopy: {
fontSize: 13,
lineHeight: 18,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
formSection: {
gap: spacing.md,
},
titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48,
paddingVertical: spacing.xs,
},
idleFields: {
gap: spacing.md,
},
timerField: {
gap: 6,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
sectionLabel: {
fontSize: 11,
@@ -892,9 +850,6 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flexWrap: "wrap",
gap: spacing.sm,
},
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
@@ -996,4 +951,23 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
color: colors.foreground,
fontSize: 14,
},
todayEmpty: {
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
},
todayEmptyTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
textAlign: "center",
},
todayEmptyCopy: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
});