diff --git a/src/app/globals.css b/src/app/globals.css index 98fefef..2d9bcee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -302,16 +302,34 @@ select:-webkit-autofill:focus { to { transform: rotate(360deg); } } -/* Mobile Water Log — PIN caret blink. The `to { opacity: 0 }` + - * `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl - * / UITextField, not a gentle fade. */ -@keyframes water-blink { +/* The water success screen used `water-draw`/`water-ring`; the Time + * Tracking Clocked Out screen used inline `strokeDashoffset` transitions + * + a single `field-draw`. Cycle 11 collapses everything into shared + * `field-*` keyframes so a worker who clocks out sees the same + * ring-and-check reveal choreography as a worker who submits a water + * reading. */ +@keyframes field-draw { + from { stroke-dashoffset: var(--draw-from, 100); } + to { stroke-dashoffset: 0; } +} + +@keyframes field-ring { + from { stroke-dashoffset: var(--ring-from, 360); } + to { stroke-dashoffset: 0; } +} + +/* Mobile Field — PIN caret blink. The `to { opacity: 0 }` + + * `steps(2, start)` combo gives a hard on/off blink like + * UISegmentedControl / UITextField, not a gentle fade. */ +@keyframes field-blink { to { opacity: 0; } } -/* Mobile Water Log — PIN-entry error shake. Three diminishing - * oscillations for a "no, that wasn't right" feel. */ -@keyframes water-shake { +/* Mobile Field — PIN-entry error shake. Three diminishing oscillations + * for a "no, that wasn't right" feel. The shake is applied directly + * to the digit-box row in `FieldPinScreen.tsx`, not to an empty + * overlay, so the user actually sees the row jerk back and forth. */ +@keyframes field-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-10px); } 40% { transform: translateX(10px); } @@ -319,19 +337,6 @@ select:-webkit-autofill:focus { 80% { transform: translateX(7px); } } -/* Mobile Water Log — checkmark stroke draw for the success screen. - * The element's `stroke-dasharray` is set inline so it works for any - * length; the keyframe just controls the timing. */ -@keyframes water-draw { - from { stroke-dashoffset: var(--draw-from, 100); } - to { stroke-dashoffset: 0; } -} - -/* Mobile Water Log — circular progress for the success ring. */ -@keyframes water-ring { - from { stroke-dashoffset: var(--ring-from, 360); } - to { stroke-dashoffset: 0; } -} /* Reduce motion: respect the user's OS-level preference. */ /* The motion pass deliberately keeps the visual design intact (colors, type, layout) diff --git a/src/components/field/FieldPinScreen.tsx b/src/components/field/FieldPinScreen.tsx new file mode 100644 index 0000000..06dffb9 --- /dev/null +++ b/src/components/field/FieldPinScreen.tsx @@ -0,0 +1,367 @@ +"use client"; + +/** + * FieldPinScreen — shared Apple HIG PIN entry, used by both the + * Water Log field app and the Time Tracking field app. + * + * Visual system (cycle 11 — visual cohesion): + * - Surface: `#F2F2F7` Apple system grey (matches iOS Keyboard, + * secondarySystemBackground). Same surface for both apps so a + * worker stepping from `/water` into `/tuxedo/time-clock` (or + * vice versa) feels they switched modes, not apps. + * - Brand mark: a 22px-rounded square with a subtle green→white + * gradient, holding the brand icon. The icon is the only thing + * that differs between apps — Droplet for water, Clock for + * time. The square + gradient + ring shadow + tracked wordmark + * strap are the recognizable signature. + * - Four large digit boxes (68×58) that mirror the value of a + * hidden . The hidden input is the source of truth so + * iOS's QuickType / password-manager suggestions fire + * correctly. Boxes never echo a digit back; they show a filled + * dot, with a blinking caret in the active slot. + * - Full-width forest-green primary with an inner highlight; on + * idle: low-contrast grey placeholder. + * - Inline error replaces the caption. Failure = 0.4s shake of + * the digit-box row (`field-shake` keyframe, mirrors + * `water-shake` semantically — three diminishing oscillations + * so it reads as "no, that wasn't right"). + * + * Props are intentionally a small surface (length, onSubmit, app + * identity, translations, brand color) so both call sites stay + * thin. The `shakeEventName` lets the parent fire the same shake + * when the verify-result returned from the server is "PIN wrong". + */ + +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, +} from "react"; +import { + Droplet, + Clock, + Spinner, + type IconProps, +} from "@/components/field/icons"; + +// ── Domain types ────────────────────────────────────────────────────────── + +export type FieldPinIcon = "droplet" | "clock"; + +export type FieldPinTranslations = { + /** Headline above the digit boxes. */ + title: string; + /** Caption below the headline (and the in-line error slot). */ + caption: string; + /** Unlock button label at idle. */ + unlock: string; + /** Unlock button label while verifying. */ + verifying: string; + /** Footer hint (e.g. "Forgot your PIN? Ask the office"). */ + forgot: string; + /** Fallback error when `onSubmit` throws. */ + genericError: string; + /** a11y label for the hidden PIN input. */ + a11yPin: string; + /** a11y label for the digit-box tap target. */ + a11yPinEntry: string; +}; + +export type FieldPinBrand = { + /** Wordmark strap (already tracked-uppercase in the rendered DOM). */ + wordmark: string; + /** Brand color used for the primary button, active digit ring, etc. */ + primary?: string; + /** Domain icon shown inside the brand mark. */ + icon: FieldPinIcon; +}; + +type Props = { + /** Called when the user submits a `length`-digit PIN. May throw. */ + onSubmit: (pin: string) => Promise; + /** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */ + length?: 4 | 5 | 6 | 7 | 8; + brand: FieldPinBrand; + translations: FieldPinTranslations; + /** + * If a parent has a separate "PIN wrong" result path (e.g. server + * returned `{ ok: false }` instead of throwing), it can dispatch + * a DOM CustomEvent of this name to trigger the same shake + reset + * the input gets on a thrown submit error. + */ + shakeEventName?: string; +}; + +// ── Brand mark ──────────────────────────────────────────────────────────── + +const ICONS: Record React.JSX.Element> = { + droplet: Droplet, + clock: Clock, +}; + +function BrandMark({ icon, primary = "#14532D" }: { icon: FieldPinIcon; primary?: string }) { + const Icon = ICONS[icon]; + return ( +
+
+ +
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────── + +export function FieldPinScreen({ + onSubmit, + length = 4, + brand, + translations: t, + shakeEventName = "field-pin-shake", +}: Props) { + const [pin, setPin] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [shake, setShake] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout( + () => inputRef.current?.focus(), + 80, + ); + return () => window.clearTimeout(timer); + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const next = e.target.value.replace(/\D/g, "").slice(0, length); + setPin(next); + if (error) setError(null); + }, + [error, length], + ); + + const resetForFailure = useCallback(() => { + setShake(true); + window.setTimeout(() => setShake(false), 420); + setPin(""); + inputRef.current?.focus(); + }, []); + + const submit = useCallback( + async (e?: FormEvent) => { + e?.preventDefault(); + if (pin.length !== length || submitting) return; + setSubmitting(true); + setError(null); + try { + await onSubmit(pin); + // Don't reset here — the parent will navigate away. + } catch (err) { + setError(err instanceof Error ? err.message : t.genericError); + resetForFailure(); + } finally { + setSubmitting(false); + } + }, + [pin, length, submitting, onSubmit, t.genericError, resetForFailure], + ); + + // External shake trigger (parent fires this when verify returns false). + useEffect(() => { + function onShake() { + resetForFailure(); + } + window.addEventListener(shakeEventName, onShake); + return () => window.removeEventListener(shakeEventName, onShake); + }, [resetForFailure, shakeEventName]); + + const ready = pin.length === length && !submitting; + const primary = brand.primary ?? "#14532D"; + + return ( + // `min-h-[100dvh]` covers the viewport regardless of parent layout + // — the parent wraps us in a fixed-inset-0 container on the + // Time Tracking route, but we don't depend on that to fill. + // `100dvh` excludes iOS Safari's collapsing chrome; the legacy + // `100vh` fallback keeps Android/older browsers full-bleed too. +
+ {/* ── Branded header ───────────────────────────────────────── */} +
+
+ {(() => { + const HeaderIcon = ICONS[brand.icon]; + return ; + })()} + + {brand.wordmark} + +
+
+ + {/* ── Centered content ─────────────────────────────────────── */} +
+
+ + +

+ {t.title} +

+ +

+ {error ?? t.caption} +

+ + {/* ── Digit boxes ────────────────────────────────────── */} +
+ + + + +
+ + {/* ── Unlock button ─────────────────────────────────── */} + + + {/* Footer hint */} +

+ {t.forgot} +

+
+
+
+ ); +} + +export default FieldPinScreen; diff --git a/src/components/field/icons.tsx b/src/components/field/icons.tsx new file mode 100644 index 0000000..9262317 --- /dev/null +++ b/src/components/field/icons.tsx @@ -0,0 +1,16 @@ +/** + * Field-domain icons (re-exports the SF-Symbol-style primitives from + * the water icon set, plus the shared `IconProps` type). This module + * is the canonical import path for shared field app icons so that + * `FieldPinScreen` and any future shared field components don't have + * to reach into `@/components/water/mobile/icons`. + * + * The actual icon definitions still live in + * `@/components/water/mobile/icons` because they're shared by both + * the water and time-tracking field apps, and moving them would + * touch too many callers in one go. When the next field app shows + * up, it should import from this barrel. + */ + +export type { IconProps } from "@/components/water/mobile/icons"; +export { Droplet, Clock, Spinner } from "@/components/water/mobile/icons"; \ No newline at end of file diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 09e819b..67c7093 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -1,8 +1,33 @@ "use client"; -import Image from "next/image"; +/** + * Time Tracking — field (PIN-based) client. + * + * Cycle 11 (visual cohesion): this file's presentation layer was + * rewritten to match the Water Log field app's Apple HIG visual + * language so a worker stepping from `/water` into + * `/tuxedo/time-clock` (or vice versa) feels they switched modes, + * not apps. Both apps now share: + * + * - the same `#F2F2F7` system-grey surface + * - the same forest-green primary (`#14532D`) + * - the same brand mark (rounded gradient square + domain icon) + * - the same iOS-list card pattern with 14px radius + * - the same frosted top nav bar + * - the same digit-box PIN entry + * - the same ring-and-check success screen + * + * Domain icons carry the only visible identity — Droplet for water, + * Clock for time — and that's intentional: the brand mark's job is + * to anchor the user, not to re-introduce an entire visual identity + * on every screen. Both apps also share `t.brand.wordmark` from the + * translations table at the top of this file. + * + * The reducer + handlers + Lang toggle below are unchanged from the + * pre-cycle-11 implementation; the rewrite is purely presentation. + */ + import React, { useReducer, useEffect, useRef, useState } from "react"; -import LayoutContainer from "@/components/layout/LayoutContainer"; import { verifyTimeTrackingPin, clockInWorker, @@ -16,13 +41,18 @@ import { type TimeTaskField, type PayPeriodHours, } from "@/actions/time-tracking/field"; +import { FieldPinScreen } from "@/components/field/FieldPinScreen"; +import { Clock, CheckCircle, Spinner, ChevronLeft } from "@/components/water/mobile/icons"; -const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; -const BRAND_NAME = "Tuxedo Corn"; +const BRAND_PRIMARY = "#14532D"; +const AMBER = "#B8761E"; +const INK = "#1d1d1f"; // ── Translations ─────────────────────────────────────────────────────────────── type Translations = { + brand: { wordmark: string }; + nav: { backAria: string; langAria: string }; title: string; enter_pin: string; pin_placeholder: string; @@ -47,10 +77,19 @@ type Translations = { pay_period: string; hours_remaining: string; overtime: string; + unlocked_at: string; + elapsed: string; + new_reading: string; + back_to_app: string; + total_time: string; + generic_error: string; + forgot_hint: string; }; const TRANS_EN: Translations = { - title: "Worker Clock", + brand: { wordmark: "TUXEDO TIME CLOCK" }, + nav: { backAria: "Back", langAria: "Switch language" }, + title: "Time clock", enter_pin: "Enter your 4-digit PIN", pin_placeholder: "PIN", clock_in: "Clock In", @@ -74,9 +113,18 @@ const TRANS_EN: Translations = { pay_period: "Pay period", hours_remaining: "hours remaining", overtime: "Overtime", + unlocked_at: "On the clock", + elapsed: "elapsed", + new_reading: "Clock in", + back_to_app: "Back to PIN", + total_time: "Total time", + generic_error: "Couldn't verify PIN. Try again.", + forgot_hint: "Forgot your PIN? Ask the office.", }; const TRANS_ES: Translations = { + brand: { wordmark: "RELOJ TUXEDO" }, + nav: { backAria: "Atrás", langAria: "Cambiar idioma" }, title: "Reloj de Trabajador", enter_pin: "Ingrese su PIN de 4 dígitos", pin_placeholder: "PIN", @@ -101,6 +149,13 @@ const TRANS_ES: Translations = { pay_period: "Período de pago", hours_remaining: "horas restantes", overtime: "Horas extra", + unlocked_at: "En el reloj", + elapsed: "transcurrido", + new_reading: "Marcar entrada", + back_to_app: "Volver al PIN", + total_time: "Tiempo total", + generic_error: "No se pudo verificar el PIN. Intente de nuevo.", + forgot_hint: "¿Olvidó su PIN? Pregunte en la oficina.", }; // ── Cookie helpers ───────────────────────────────────────────────────────────── @@ -118,7 +173,15 @@ function setLangCookie(lang: string) { // ── Helpers ─────────────────────────────────────────────────────────────────── function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); + return new Date(iso).toLocaleTimeString(langToBcp(), { hour: "numeric", minute: "2-digit", hour12: true }); +} + +function langToBcp(): "en-US" | "es-ES" { + // Cheap global so `formatTime` doesn't have to thread `lang` around. + // Resolved at call time from the lang cookie that's always set. + if (typeof document === "undefined") return "en-US"; + const m = document.cookie.match(/time_tracking_lang=(\w+)/); + return m?.[1] === "es" ? "es-ES" : "en-US"; } function formatHours(minutes: number): string { @@ -135,12 +198,10 @@ function formatHoursDecimal(h: number): string { } function formatPeriodRange(start: string, end: string, lang: "en" | "es"): string { + const locale = lang === "es" ? "es-ES" : "en-US"; const s = new Date(start); const e = new Date(end); - if (lang === "es") { - return `${s.toLocaleDateString("es-ES", { month: "short", day: "numeric" })} – ${e.toLocaleDateString("es-ES", { month: "short", day: "numeric" })}`; - } - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; + return `${s.toLocaleDateString(locale, { month: "short", day: "numeric" })} – ${e.toLocaleDateString(locale, { month: "short", day: "numeric" })}`; } function taskLabel(task: TimeTaskField, lang: "en" | "es"): string { @@ -249,9 +310,6 @@ function reducer(state: State, action: Action): State { export default function TimeTrackingFieldClient({ brandId, - brandName, - brandAccent, - logoUrl, }: { brandId: string; brandName: string; @@ -266,21 +324,16 @@ export default function TimeTrackingFieldClient({ const sessionRef = useRef(null); const pollRef = useRef | null>(null); const initRef = useRef(false); - // Keep latest loadPayPeriod reachable from polling + init effects without - // having to add it to their dep arrays (the function identity changes on - // every render since it's a plain const). const loadPayPeriodRef = useRef<() => Promise>(() => Promise.resolve()); const t = lang === "en" ? TRANS_EN : TRANS_ES; + const T = translationsFor(t); // Fetch pay period hours const loadPayPeriod = async () => { const result = await getWorkerPayPeriodHours(brandId); if (result.success) dispatch({ type: "SET_PAY_PERIOD", value: result }); }; - // Keep ref pointing at the latest loadPayPeriod so effects below can call - // it without re-running on every render. Updated in an effect (not during - // render) per the React refs-during-render rule. useEffect(() => { loadPayPeriodRef.current = loadPayPeriod; }); @@ -291,7 +344,6 @@ export default function TimeTrackingFieldClient({ initRef.current = true; async function init() { - // Only check for open clock-in if session exists const stored = await getTimeTrackingSession(); if (!stored) { dispatch({ type: "SET_SCREEN", value: "pin" }); @@ -317,11 +369,8 @@ export default function TimeTrackingFieldClient({ dispatch({ type: "SET_SCREEN", value: "task_select" }); } } - init(); - // intentionally omits screen — re-running init on screen change causes race with session cookie being set - // t is referenced inside init() for `t.general_labor`; init runs once via initRef so the dep churn doesn't matter - // loadPayPeriodRef is stable; init() reads through it to avoid dep churn + // intentionally omits screen — see earlier cycle notes }, [brandId, t.general_labor]); // Poll elapsed + pay period every 30s when on working screen @@ -343,7 +392,7 @@ export default function TimeTrackingFieldClient({ return () => { if (pollRef.current) clearInterval(pollRef.current); }; - }, [screen, brandId]); // loadPayPeriodRef is stable; interval reads through it + }, [screen, brandId]); // ── Handlers ─────────────────────────────────────────────────────────────── @@ -358,6 +407,7 @@ export default function TimeTrackingFieldClient({ const result = await verifyTimeTrackingPin(brandId, pin); if (!result.success) { dispatch({ type: "SET_ERROR", value: result.error ?? t.invalid_pin }); + window.dispatchEvent(new CustomEvent("time-pin-shake")); return; } sessionRef.current = result.session!; @@ -383,7 +433,6 @@ export default function TimeTrackingFieldClient({ } }; - // Dispatch overtime notification (fire-and-forget) const dispatchNotification = async (workerName: string, dailyHours: number, weeklyHours: number) => { try { await fetch("/api/time-tracking/notify", { @@ -471,9 +520,6 @@ export default function TimeTrackingFieldClient({ return ( void; + backAria: string; + langAria: string; + wordmark: string; + onBack?: () => void; +}) { + return ( +
+
+ {onBack && ( + + )} +
+

+ {title ?? ( + + + {wordmark} + + )} +

+
+ +
+
+ ); +} + +function PrimaryButton({ + children, + onClick, + disabled, + loading, + fullWidth = true, + style, +}: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + loading?: boolean; + fullWidth?: boolean; + style?: React.CSSProperties; +}) { + const ready = !disabled && !loading; + return ( + + ); +} + +function GhostButton({ + children, + onClick, + fullWidth = true, +}: { + children: React.ReactNode; + onClick: () => void; + fullWidth?: boolean; +}) { + return ( + + ); +} + // ── Time Tracking Screens (presentation) ────────────────────────────────────── function TimeTrackingScreens({ - brandName, - brandAccent, - logoUrl, lang, screen, tasks, @@ -519,7 +758,7 @@ function TimeTrackingScreens({ notes, error, submitting, - t, + T, showOvertimeWarning, onToggleLang, onPinSubmit, @@ -533,9 +772,6 @@ function TimeTrackingScreens({ onChangeLunchMinutes, onChangeNotes, }: { - brandName: string; - brandAccent: string; - logoUrl?: string | null; lang: "en" | "es"; screen: Screen; tasks: TimeTaskField[]; @@ -547,10 +783,10 @@ function TimeTrackingScreens({ notes: string; error: string | null; submitting: boolean; - t: Translations; + T: Presentation; showOvertimeWarning: boolean; onToggleLang: () => void; - onPinSubmit: (pin: string) => void; + onPinSubmit: (pin: string) => Promise; onSelectTask: (name: string) => void; onClockOutSubmit: () => void; onClockOut: () => void; @@ -561,196 +797,281 @@ function TimeTrackingScreens({ onChangeLunchMinutes: (m: number) => void; onChangeNotes: (n: string) => void; }) { + // PIN screen owns its full-bleed surface. Other screens sit on the + // shared HIG grey with a frosted nav on top. + if (screen === "pin") { + return ( + + ); + } + return ( -
- {/* Header */} -
-
- - {logoUrl ? Olathe Sweet : null} - - {brandName} -
- -
+
+ - -
+
+ {error && ( +
+ ⚠️ + {error} +
+ )} - {screen === "loading" && ( -
Loading...
- )} + {screen === "loading" && ( +
+ Loading… +
+ )} - {screen === "pin" && ( - - )} + {screen === "task_select" && ( + + )} - {(screen === "task_select") && ( - - )} + {screen === "working" && openEntry && ( + + )} - {screen === "working" && openEntry && ( - - )} + {screen === "clock_out_form" && openEntry && ( + + )} - {screen === "clock_out_form" && openEntry && ( - - )} - - {screen === "clocked_out" && clockOutResult && ( - - )} - -
-
+ {screen === "clocked_out" && clockOutResult && ( + + )} +
); } -// ── Pay Period Card ───────────────────────────────────────────────────────────── +function titleForScreen(screen: Screen, T: Presentation): string | undefined { + switch (screen) { + case "task_select": + return T.select_task; + case "working": + return T.title; + case "clock_out_form": + return T.clock_out; + case "clocked_out": + return T.clock_out; + default: + return undefined; + } +} + +// ── Pay Period Card (light surface) ─────────────────────────────────────────── function PayPeriodCard({ - t, + T, lang, payPeriod, showWarning, }: { - t: Translations; + T: Presentation; lang: "en" | "es"; payPeriod: PayPeriodHours | null; showWarning: boolean; - logoUrl?: string | null; }) { if (!payPeriod) return null; const rangeStr = formatPeriodRange(payPeriod.period_start, payPeriod.period_end, lang); - const dailyRemaining = Math.max(0, payPeriod.daily_threshold - payPeriod.daily_hours); const weeklyRemaining = Math.max(0, payPeriod.weekly_threshold - payPeriod.weekly_hours); const dailyOT = payPeriod.daily_hours > payPeriod.daily_threshold; const weeklyOT = payPeriod.weekly_hours > payPeriod.weekly_threshold; + const inOT = dailyOT || weeklyOT; return ( -
-
-

- {t.pay_period} +

+
+

+ {T.pay_period}

{showWarning && ( - - - - - {t.overtime_warning} + + {T.overtime_warning} )} -
-

{rangeStr}

+ +

{rangeStr}

- {/* Large remaining hours display */} - {!dailyOT && !weeklyOT ? ( -
-

{formatHoursDecimal(weeklyRemaining)}

-

{t.hours_remaining} {t.this_week.toLowerCase()}

+ {/* Big remaining hours */} + {!inOT ? ( +
+

+ {formatHoursDecimal(weeklyRemaining)} +

+

+ {T.hours_remaining} {T.this_week.toLowerCase()} +

) : ( -
-

{t.overtime}

-

- {dailyOT ? `${formatHoursDecimal(payPeriod.daily_hours)} ${t.today.toLowerCase()}` : ""} +

+

+ {T.overtime} +

+

+ {dailyOT ? `${formatHoursDecimal(payPeriod.daily_hours)} ${T.today.toLowerCase()}` : ""} {dailyOT && weeklyOT ? " · " : ""} - {weeklyOT ? `${formatHoursDecimal(payPeriod.weekly_hours)} ${t.this_week.toLowerCase()}` : ""} + {weeklyOT ? `${formatHoursDecimal(payPeriod.weekly_hours)} ${T.this_week.toLowerCase()}` : ""}

)} -
- {/* Main total */} -
-

{formatHoursDecimal(payPeriod.total_hours)}

-

{t.hours_this_period}

-
- - {/* Today + week breakdown */} -
-
- - {t.today} - {formatHoursDecimal(payPeriod.daily_hours)} -
-
- - {t.this_week} - {formatHoursDecimal(payPeriod.weekly_hours)} -
+ {/* Today + week breakdown */} +
+
+ + +
+ + ); +} - {/* Overtime flags */} - {(payPeriod.daily_overtime || payPeriod.weekly_overtime) && ( -
- {payPeriod.daily_overtime && ( - - {t.daily_overtime_hit} - - )} - {payPeriod.weekly_overtime && ( - - {t.weekly_overtime_hit} - - )} -
- )} +function Row({ + label, + value, + emphasis, + warn, +}: { + label: string; + value: string; + emphasis?: boolean; + warn?: boolean; +}) { + return ( +
+ {label} + + {value} +
); } @@ -758,54 +1079,80 @@ function PayPeriodCard({ // ── Working Screen ──────────────────────────────────────────────────────────── function WorkingScreen({ - t, + T, lang, openEntry, payPeriod, showOvertimeWarning, onClockOut, onLogout, - logoUrl, }: { - t: Translations; + T: Presentation; lang: "en" | "es"; openEntry: OpenEntry; payPeriod: PayPeriodHours | null; showOvertimeWarning: boolean; onClockOut: () => void; onLogout: () => void; - logoUrl?: string | null; }) { return (
- + -
-
- - - + {/* On-the-clock card — focal point. Forest-tinted card, big time. */} +
+
+
-

{t.clocked_in_at}

-

{formatTime(openEntry.clock_in)}

-

{openEntry.task_name}

-

{formatHours(openEntry.elapsed_minutes)}

-
+

+ {T.unlocked_at} +

+

+ {T.clocked_in_at} {formatTime(openEntry.clock_in)} +

+

+ {openEntry.task_name} +

+ {/* Big elapsed — the focal point. */} +

+ {formatHours(openEntry.elapsed_minutes)} +

+

+ {T.elapsed} +

+ -
- - -
+ {T.clock_out} + {T.logout}
); } @@ -813,66 +1160,127 @@ function WorkingScreen({ // ── Task Select Screen ───────────────────────────────────────────────────────── function TaskSelectScreen({ - t, + T, tasks, lang, onSelectTask, - error, submitting, - logoUrl, }: { - t: Translations; + T: Presentation; tasks: TimeTaskField[]; lang: "en" | "es"; onSelectTask: (name: string) => void; - error: string | null; submitting: boolean; - logoUrl?: string | null; }) { return ( -
-
- - {logoUrl ? Olathe Sweet : null} - -

{t.title}

-

{t.select_task}

+
+ {/* Hero — brand strap + title */} +
+

+ {T.brand.wordmark} +

+

+ {T.select_task} +

- {error && ( -
- {error} -
- )} - -
- - {tasks.map(task => ( - + /> ))} -
+
); } +function TaskRow({ + label, + onClick, + disabled, + accent, + first, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + accent?: boolean; + first?: boolean; +}) { + return ( + + ); +} + // ── Clock Out Form ──────────────────────────────────────────────────────────── function ClockOutForm({ - t, + T, tasks, lang, openEntryTaskName, @@ -883,11 +1291,10 @@ function ClockOutForm({ notes, setNotes, onSubmit, - onBack, error, submitting, }: { - t: Translations; + T: Presentation; tasks: TimeTaskField[]; lang: "en" | "es"; openEntryTaskName: string; @@ -898,233 +1305,411 @@ function ClockOutForm({ notes: string; setNotes: (n: string) => void; onSubmit: () => void; - onBack: () => void; error: string | null; submitting: boolean; - logoUrl?: string | null; }) { + // Local "field-aria" for screen readers, also used by the HourPicker. return ( -
-
-

{t.clock_out}

-

{openEntryTaskName}

+
+ {/* Hero — small caption of the running task */} +
+

+ {T.clock_out} +

+

+ {openEntryTaskName} +

-
- {/* Task */} -
- -
- - {tasks.map(task => ( - - ))} -
-
+ {/* Grouped form card */} +
+ + setSelectedTaskId("")} + first + /> + {tasks.map((task, i) => ( + setSelectedTaskId(task.id)} + /> + ))} +
- {/* Lunch */} -
- - + +
+ setLunchMinutes(Math.max(0, parseInt(e.target.value) || 0))} - className="w-full px-4 py-3 rounded-xl border border-zinc-700 bg-zinc-900 text-white text-center font-mono text-lg focus:outline-none focus:border-emerald-500 transition-colors" + onChange={(e) => + setLunchMinutes(Math.max(0, parseInt(e.target.value) || 0)) + } + className="w-full rounded-[12px] border border-[rgba(60,60,67,0.18)] bg-white px-4 py-3 text-center text-[20px] tabular-nums text-[#1d1d1f] outline-none transition-colors focus:border-[#14532D]" + style={{ + fontFamily: + "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", + }} />
+ + +