From 9929d7321d88ad7927a611fb0ef6f705cba1c599 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 17 Aug 2026 00:18:55 -0400 Subject: [PATCH] Polish mobile and web experience --- apps/mobile/app/(app)/entities/index.tsx | 8 +- apps/mobile/app/(app)/index.tsx | 36 +- apps/mobile/app/(app)/invoices/index.tsx | 5 +- apps/mobile/app/(app)/invoices/new.tsx | 29 +- apps/mobile/app/(app)/more/expenses/index.tsx | 7 +- apps/mobile/app/(app)/more/recurring.tsx | 8 +- apps/mobile/app/(app)/more/reports.tsx | 18 +- apps/mobile/app/(app)/more/time-entries.tsx | 8 +- apps/mobile/app/(auth)/reset-password.tsx | 19 +- apps/mobile/components/FilterChip.tsx | 5 +- apps/mobile/components/PullToRefresh.tsx | 34 + apps/mobile/components/SwipeableRow.tsx | 76 +- .../components/businesses/BusinessForm.tsx | 9 +- apps/mobile/components/clients/ClientForm.tsx | 16 +- .../components/invoices/InvoiceSetupForm.tsx | 18 + .../components/time-clock/TimeClockPanel.tsx | 360 +++++----- apps/mobile/components/ui/DateTimeField.tsx | 5 + apps/mobile/components/ui/Input.tsx | 6 +- apps/mobile/components/ui/SelectField.tsx | 24 +- apps/mobile/lib/action-contrast.ts | 56 ++ apps/mobile/lib/auth-cookie.ts | 72 +- apps/mobile/lib/invoice-number.ts | 11 +- apps/mobile/lib/tab-bar-insets.ts | 5 +- apps/mobile/tests/cross-client-parity.test.ts | 9 + apps/mobile/tests/ui-regressions.test.ts | 19 + .../web/src/components/forms/invoice-form.tsx | 3 +- .../time-clock/time-clock-panel.tsx | 650 ++++++++---------- apps/web/src/lib/draft-invoice.ts | 7 +- 28 files changed, 835 insertions(+), 688 deletions(-) create mode 100644 apps/mobile/components/PullToRefresh.tsx create mode 100644 apps/mobile/lib/action-contrast.ts create mode 100644 apps/mobile/tests/ui-regressions.test.ts diff --git a/apps/mobile/app/(app)/entities/index.tsx b/apps/mobile/app/(app)/entities/index.tsx index 75e0fc3..7f356e6 100644 --- a/apps/mobile/app/(app)/entities/index.tsx +++ b/apps/mobile/app/(app)/entities/index.tsx @@ -2,7 +2,6 @@ import { router } from "expo-router"; import { useState } from "react"; import { Alert, - RefreshControl, ScrollView, StyleSheet, Text, @@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton"; import { GlassSurface } from "@/components/GlassSurface"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { SwipeableRow } from "@/components/SwipeableRow"; import { TabPage } from "@/components/TabPage"; import { TabScrollView } from "@/components/TabScrollView"; @@ -71,8 +71,7 @@ export default function EntitiesScreen() { const businesses = businessesQuery.data ?? []; function refresh() { - if (tab === "clients") void clientsQuery.refetch(); - else void businessesQuery.refetch(); + return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch(); } function confirmDelete(id: string, name: string) { @@ -100,8 +99,7 @@ export default function EntitiesScreen() { /> } refreshControl={ - diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 80a5234..7968a09 100644 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,11 +1,12 @@ import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; -import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; import { AppBackground } from "@/components/AppBackground"; import { GlassSurface } from "@/components/GlassSurface"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { Screen } from "@/components/Screen"; import { StatCard } from "@/components/StatCard"; import { StatusBadge } from "@/components/StatusBadge"; @@ -152,12 +153,8 @@ export default function DashboardScreen() { } refreshControl={ - { - void statsQuery.refetch(); - void runningQuery.refetch(); - }} + Promise.all([statsQuery.refetch(), runningQuery.refetch()])} tintColor={colors.primary} /> } @@ -221,7 +218,10 @@ export default function DashboardScreen() { {running ? ( - router.push("/(app)/timer")}> + router.push("/(app)/timer")} + > @@ -299,6 +299,11 @@ export default function DashboardScreen() { {stats.currentDraft ? ( router.push(`/(app)/invoices/${stats.currentDraft!.id}`)} > @@ -325,7 +330,12 @@ export default function DashboardScreen() { - router.push("/(app)/entities")}> + router.push("/(app)/entities")} + > @@ -354,6 +364,14 @@ export default function DashboardScreen() { const status = getInvoiceStatus(invoice); return ( [styles.recentRow, pressed && styles.pressed]} onPress={() => router.push(`/(app)/invoices/${invoice.id}`)} diff --git a/apps/mobile/app/(app)/invoices/index.tsx b/apps/mobile/app/(app)/invoices/index.tsx index cc6ff6e..1d28c78 100644 --- a/apps/mobile/app/(app)/invoices/index.tsx +++ b/apps/mobile/app/(app)/invoices/index.tsx @@ -2,7 +2,6 @@ import { router } from "expo-router"; import { useState } from "react"; import { Alert, - RefreshControl, ScrollView, StyleSheet, Text, @@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton"; import { GlassSurface } from "@/components/GlassSurface"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { SwipeableRow } from "@/components/SwipeableRow"; import { StatusBadge } from "@/components/StatusBadge"; import { TabPage } from "@/components/TabPage"; @@ -121,8 +121,7 @@ export default function InvoicesScreen() { } refreshControl={ - invoicesQuery.refetch()} tintColor={colors.primary} /> diff --git a/apps/mobile/app/(app)/invoices/new.tsx b/apps/mobile/app/(app)/invoices/new.tsx index a21c2d1..81aa3f3 100644 --- a/apps/mobile/app/(app)/invoices/new.tsx +++ b/apps/mobile/app/(app)/invoices/new.tsx @@ -29,6 +29,7 @@ import { formatCurrency } from "@/lib/format"; import { isRequiredString, isValidTaxRate, + useFieldVisibility, validateLineItems, } from "@/lib/form-validation"; import { resolveInvoiceBusinessId } from "@/lib/invoice-business"; @@ -70,6 +71,7 @@ export default function NewInvoiceScreen() { ); const [section, setSection] = useState("setup"); const [error, setError] = useState(null); + const { touch, visible, markSubmitted } = useFieldVisibility(); useEffect(() => { if (businessId || !businessesQuery.data?.length) return; @@ -184,10 +186,12 @@ export default function NewInvoiceScreen() { } function updateItem(index: number, patch: Partial) { + touch("lineItems"); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); } function addItem() { + touch("lineItems"); setItems((prev) => [ ...prev, { @@ -200,10 +204,12 @@ export default function NewInvoiceScreen() { } function removeItem(index: number) { + touch("lineItems"); setItems((prev) => prev.filter((_, i) => i !== index)); } function duplicateItem(index: number) { + touch("lineItems"); setItems((prev) => { const source = prev[index]; if (!source) return prev; @@ -213,6 +219,7 @@ export default function NewInvoiceScreen() { } function handleCreate() { + markSubmitted(); if (!canCreate) return; setError(null); @@ -296,27 +303,31 @@ export default function NewInvoiceScreen() { businessId={businessId} onBusinessIdChange={setBusinessId} businessOptions={businessOptions} - businessError={businessError} + businessError={visible("business") ? businessError : undefined} + onBusinessBlur={() => touch("business")} clientId={clientId} onClientIdChange={setClientId} clientOptions={clientOptions} - clientError={clientError} + clientError={visible("client") ? clientError : undefined} + onClientBlur={() => touch("client")} invoiceNumber={invoiceNumber} onInvoiceNumberChange={setInvoiceNumber} + invoiceNumberError={ + visible("invoiceNumber") ? invoiceNumberError : undefined + } + onInvoiceNumberBlur={() => touch("invoiceNumber")} issueDate={issueDate} onIssueDateChange={setIssueDate} dueDate={dueDate} onDueDateChange={setDueDate} taxRate={taxRate} onTaxRateChange={setTaxRate} + taxRateError={visible("taxRate") ? taxError : undefined} + onTaxRateBlur={() => touch("taxRate")} notes={notes} onNotesChange={setNotes} /> )} - {taxError ? {taxError} : null} - {invoiceNumberError ? ( - {invoiceNumberError} - ) : null} ) : ( <> @@ -354,11 +365,13 @@ export default function NewInvoiceScreen() { /> - {lineItemsError ? {lineItemsError} : null} + {visible("lineItems") && lineItemsError ? ( + {lineItemsError} + ) : null} )} - {error ? {error} : null} + {error ? {error} : null} } refreshControl={ - void expensesQuery.refetch()} + expensesQuery.refetch()} tintColor={colors.primary} /> } diff --git a/apps/mobile/app/(app)/more/recurring.tsx b/apps/mobile/app/(app)/more/recurring.tsx index f89a5d0..b87eb2e 100644 --- a/apps/mobile/app/(app)/more/recurring.tsx +++ b/apps/mobile/app/(app)/more/recurring.tsx @@ -1,8 +1,9 @@ -import { RefreshControl, StyleSheet, Text, View } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; import { AppBackground } from "@/components/AppBackground"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { SwipeableRow } from "@/components/SwipeableRow"; import { TabPage } from "@/components/TabPage"; import { TabScrollView } from "@/components/TabScrollView"; @@ -46,9 +47,8 @@ export default function RecurringScreen() { /> } refreshControl={ - void query.refetch()} + query.refetch()} tintColor={colors.primary} /> } diff --git a/apps/mobile/app/(app)/more/reports.tsx b/apps/mobile/app/(app)/more/reports.tsx index d66f43e..431225d 100644 --- a/apps/mobile/app/(app)/more/reports.tsx +++ b/apps/mobile/app/(app)/more/reports.tsx @@ -1,8 +1,9 @@ -import { RefreshControl, StyleSheet, Text, View } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; import { AppBackground } from "@/components/AppBackground"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { StatCard } from "@/components/StatCard"; import { TabPage } from "@/components/TabPage"; import { TabScrollView } from "@/components/TabScrollView"; @@ -48,13 +49,14 @@ export default function ReportsScreen() { } refreshControl={ - { - void statsQuery.refetch(); - void expensesQuery.refetch(); - void summaryQuery.refetch(); - }} + + Promise.all([ + statsQuery.refetch(), + expensesQuery.refetch(), + summaryQuery.refetch(), + ]) + } tintColor={colors.primary} /> } diff --git a/apps/mobile/app/(app)/more/time-entries.tsx b/apps/mobile/app/(app)/more/time-entries.tsx index 932af81..04fadfd 100644 --- a/apps/mobile/app/(app)/more/time-entries.tsx +++ b/apps/mobile/app/(app)/more/time-entries.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from "react"; -import { RefreshControl, StyleSheet, Text, View } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; import { AppBackground } from "@/components/AppBackground"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PageHeader } from "@/components/PageHeader"; +import { PullToRefresh } from "@/components/PullToRefresh"; import { SwipeableRow } from "@/components/SwipeableRow"; import { TabPage } from "@/components/TabPage"; import { TabScrollView } from "@/components/TabScrollView"; @@ -74,9 +75,8 @@ export default function TimeEntriesScreen() { } refreshControl={ - void entriesQuery.refetch()} + entriesQuery.refetch()} tintColor={colors.primary} /> } diff --git a/apps/mobile/app/(auth)/reset-password.tsx b/apps/mobile/app/(auth)/reset-password.tsx index 10acc7c..bff8154 100644 --- a/apps/mobile/app/(auth)/reset-password.tsx +++ b/apps/mobile/app/(auth)/reset-password.tsx @@ -21,7 +21,11 @@ import { useAppTheme } from "@/contexts/ThemeContext"; import { resetPassword } from "@/lib/auth-api"; import type { ThemeColors } from "@/lib/theme-palette"; import { useThemedStyles } from "@/lib/use-themed-styles"; -import { isRequiredString, isValidPassword } from "@/lib/form-validation"; +import { + isRequiredString, + isValidPassword, + useFieldVisibility, +} from "@/lib/form-validation"; export default function ResetPasswordScreen() { const styles = useThemedStyles(createResetPasswordStyles); @@ -33,6 +37,7 @@ export default function ResetPasswordScreen() { const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const [serverReady, setServerReady] = useState(true); + const { touch, visible, markSubmitted } = useFieldVisibility(); useEffect(() => { if (typeof tokenParam === "string" && tokenParam.length > 0) { @@ -56,6 +61,7 @@ export default function ResetPasswordScreen() { confirmPassword.length > 0; async function handleSubmit() { + markSubmitted(); if (!canSubmit) return; setError(null); @@ -111,30 +117,33 @@ export default function ResetPasswordScreen() { autoCapitalize="none" value={token} onChangeText={setToken} + onBlur={() => touch("token")} placeholder="Paste token from email" required - error={tokenError} + error={visible("token") ? tokenError : undefined} /> touch("password")} placeholder="At least 8 characters" required - error={passwordError} + error={visible("password") ? passwordError : undefined} /> touch("confirmPassword")} placeholder="Repeat password" required - error={confirmError} + error={visible("confirmPassword") ? confirmError : undefined} /> - {error ? {error} : null} + {error ? {error} : null} - ); -} - export function TimeClockPanel({ defaultClientId = "", defaultInvoiceId = "", @@ -161,7 +146,6 @@ export function TimeClockPanel({ const [stopNote, setStopNote] = useState(""); const [rate, setRate] = useState(0); const [elapsed, setElapsed] = useState(0); - const [showAllClients, setShowAllClients] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false); const [startMode, setStartMode] = useState("now"); const [pickedStart, setPickedStart] = useState(""); @@ -180,40 +164,6 @@ export function TimeClockPanel({ [clients, clientId], ); - const featuredClientIds = useMemo(() => { - const ids: string[] = []; - const last = getLastTimeClockClientId(); - if (last) ids.push(last); - - if (running?.clientId && !ids.includes(running.clientId)) { - ids.unshift(running.clientId); - } - - for (const entry of todayEntries ?? []) { - if (entry.clientId && !ids.includes(entry.clientId)) { - ids.push(entry.clientId); - } - } - - for (const client of clients ?? []) { - if (!ids.includes(client.id)) ids.push(client.id); - if (ids.length >= FEATURED_CLIENT_COUNT) break; - } - - return ids; - }, [clients, todayEntries, running]); - - const visibleClients = useMemo(() => { - if (!clients?.length) return []; - if (showAllClients) return clients; - const featured = featuredClientIds - .map((id) => clients.find((c) => c.id === id)) - .filter((c): c is NonNullable => Boolean(c)); - return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT); - }, [clients, featuredClientIds, showAllClients]); - - const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length); - useEffect(() => { if (intervalRef.current) clearInterval(intervalRef.current); if (!running) return; @@ -373,292 +323,63 @@ export function TimeClockPanel({ const runningTitle = formatRunningTimerLabel(running?.description); const activeClientId = running ? (running.clientId ?? "") : clientId; const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId; + const completedToday = todayEntries?.filter((entry) => entry.endedAt) ?? []; + const todayHours = completedToday.reduce( + (total, entry) => total + Number(entry.hours ?? 0), + 0, + ); return ( -
- {running ? ( -
-
- - - - - Timer running +
+ + +
+
+

+ {running ? "In progress" : "Ready to start"} +

+ + {running ? runningTitle : "What are you working on?"} + + + {running + ? [ + running.client?.name ?? "No client", + running.invoice ? invoiceLabel(running.invoice) : null, + displayRate ? `$${displayRate}/hr` : null, + ] + .filter(Boolean) + .join(" · ") + : "Add the details you know now. You can update the rest while the timer runs."} + +
+ {running ? ( +

+ {formatElapsedSeconds(elapsed)} +

+ ) : null}
-

- {formatElapsedSeconds(elapsed)} -

-

{runningTitle}

-

- {running.client?.name ?? "No client"} - {running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""} - {displayRate ? ` · $${displayRate}/hr` : ""} -

-
- ) : null} - - - - - {!running ? : null} - {running ? "Update & stop" : "Clock in"} - - + {!running ? ( <> -
- +
+ setTitle(e.target.value)} - placeholder="What are you working on?" - className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0" - /> -
- -
- -
- {visibleClients.map((client) => ( - handleClientChange(client.id)} - /> - ))} - {!showAllClients && hiddenClientCount > 0 ? ( - - ) : null} -
- {(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && ( - - )} -
- -
- - -
- - - - - - -
- - - {clientId && rate === 0 && selectedClient?.defaultHourlyRate ? ( -

- Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero). -

- ) : null} -
- -
- -
- {( - [ - ["now", "Now"], - ["pick", "Pick time"], - ["ago", "Time ago"], - ] as const - ).map(([mode, label]) => ( - - ))} -
- {startMode === "pick" ? ( - setPickedStart(e.target.value)} - className="mt-2" - /> - ) : null} - {startMode === "ago" ? ( -
- setMinutesAgo(e.target.value)} - className="w-24" - /> - minutes ago -
- ) : null} -
-
-
- - ) : ( - <> - - -
- -
- {visibleClients.map((client) => ( - handleClientChange(client.id)} - /> - ))} - {!showAllClients && hiddenClientCount > 0 ? ( - - ) : null} -
- {(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && ( - - )} -
- -
- - -
- -
- - setStopNote(e.target.value)} - placeholder={ - running?.description?.trim() - ? running.description - : "Update description when you stop" - } + placeholder="e.g. Client kickoff…" + className="h-11" />
- )} + ) : null} {running ? ( - ) : ( + ) : null} + +
+
+ + +
+ +
+ + +

+ {activeClientId + ? "Linking an invoice adds the completed time as a billable line item." + : "Choose a client to see billable invoices."} +

+
+
+ + + + + + +
+ {running ? ( + + ) : ( + <> +
+ + + {clientId && rate === 0 && selectedClient?.defaultHourlyRate ? ( +

+ {`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`} +

+ ) : null} +
+
+ +
+ {( + [ + ["now", "Now"], + ["pick", "Pick time"], + ["ago", "Time ago"], + ] as const + ).map(([mode, label]) => ( + + ))} +
+ {startMode === "pick" ? ( + setPickedStart(event.target.value)} + /> + ) : null} + {startMode === "ago" ? ( +
+ setMinutesAgo(event.target.value)} + className="w-24" + /> + minutes ago +
+ ) : null} +
+ + )} +
+ + {running ? ( +
+ + setStopNote(event.target.value)} + placeholder="Add a final note…" + /> +
+ ) : null} +
+
+ + + {!running ? ( + - )} - + + ) : null} {!compact ? ( - - - Today's entries - + + +
+
+ Today + + {completedToday.length === 1 + ? "1 completed entry" + : `${completedToday.length} completed entries`} + +
+

+ {todayHours.toFixed(2)}h +

+
- {todayEntries?.some((e) => e.endedAt) ? ( - setEditEntryId(entry.id)} - /> + {completedToday.length > 0 ? ( + setEditEntryId(entry.id)} /> ) : ( -

- No entries today.{" "} - - View history - -

+
+

No time logged yet

+

+ Start your first timer or open history to add an entry manually. +

+
)}
+ + +
) : null} diff --git a/apps/web/src/lib/draft-invoice.ts b/apps/web/src/lib/draft-invoice.ts index db83980..e720734 100644 --- a/apps/web/src/lib/draft-invoice.ts +++ b/apps/web/src/lib/draft-invoice.ts @@ -1,6 +1,11 @@ /** Default invoice number format (matches web/mobile create forms). */ export function generateInvoiceNumber(now = new Date()): string { - const date = now.toISOString().slice(0, 10).replace(/-/g, ""); + const date = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + ].join(""); + return `INV-${date}-${String(now.getTime()).slice(-6)}`; }