feat(water-log): cycle 4 — Tuxedo mobile worker Time tab + unified PIN

The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.

- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
  after PIN, screen='time' case embeds <TimeTrackingFieldClient>
  with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
  the water PIN succeeds, so the Time tab never re-prompts a worker
  who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
  cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
  back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
  Tuxedo brand display constants into @/lib/water-log/brand so the
  standalone /tuxedo/time-clock page and the embedded Time tab share
  one source of truth.
This commit is contained in:
Nora
2026-07-03 18:26:32 -06:00
parent 0599bdc331
commit dfd6b63888
5 changed files with 218 additions and 23 deletions
+9 -8
View File
@@ -1,12 +1,13 @@
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient"; import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import { getBrandSettingsPublic } from "@/actions/brand-settings"; import { getBrandSettingsPublic } from "@/actions/brand-settings";
import {
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; TUXEDO_BRAND_ID,
const BRAND_NAME = "Tuxedo Corn"; TUXEDO_BRAND_NAME,
const BRAND_ACCENT = "green"; TUXEDO_BRAND_ACCENT,
} from "@/lib/water-log/brand";
export const metadata = { export const metadata = {
title: "Worker Clock — Tuxedo Corn", title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
}; };
export default async function TuxedoTimeClockPage() { export default async function TuxedoTimeClockPage() {
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
return ( return (
<TimeTrackingFieldClient <TimeTrackingFieldClient
brandId={BRAND_ID} brandId={TUXEDO_BRAND_ID}
brandName={BRAND_NAME} brandName={TUXEDO_BRAND_NAME}
brandAccent={BRAND_ACCENT} brandAccent={TUXEDO_BRAND_ACCENT}
logoUrl={logoUrl} logoUrl={logoUrl}
/> />
); );
+172 -12
View File
@@ -5,13 +5,14 @@
* Tuxedo Water Log experience. * Tuxedo Water Log experience.
* *
* Flow: * Flow:
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success * pin ─verify─▶ tabbed shell
* ▲ │ * │
* └───────────────(logout)─────────────────┤ * ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
* └─(logout)─────(anywhere)──────────────────────────────┘ * │ ▲ │
* │ * │ │
* success ── Log Another ─▶ log (cleared) ─┘ * │ └───────── (logout) ────────────┤
* success ── Back to Headgates ─▶ headgates *
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
* *
* Hydration: * Hydration:
* - On mount we peek at `document.cookie` for the `wl_session` * - On mount we peek at `document.cookie` for the `wl_session`
@@ -21,11 +22,19 @@
* who already signed in once in their shift shouldn't have to * who already signed in once in their shift shouldn't have to
* re-enter their PIN every time they reopen the page. * re-enter their PIN every time they reopen the page.
* *
* Cycle 4 — the existing PIN is unified with the time-tracking PIN:
* after `verifyWaterPin` succeeds we also best-effort call
* `verifyTimeTrackingPin` with the same digits. If the worker exists
* in `time_tracking_workers`, the time-tracking session cookie is set
* too, so they never re-enter their PIN on the Time tab.
*
* Server actions used: * Server actions used:
* - `verifyWaterPin` → signs the irrigator in, sets cookie * - `verifyWaterPin` → signs the irrigator in, sets cookie
* - `getWaterHeadgates` → loads the list of active gates * - `getWaterHeadgates` → loads the list of active gates
* - `submitWaterEntry` → posts a new reading * - `submitWaterEntry` → posts a new reading
* - `logoutWater` → clears the cookie + session row * - `logoutWater` → clears the cookie + session row
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
@@ -34,13 +43,28 @@ import {
getWaterHeadgates, getWaterHeadgates,
logoutWater, logoutWater,
} from "@/actions/water-log/field"; } from "@/actions/water-log/field";
import type { FieldHeadgate, Screen, SubmittedLog } from "./types"; import {
verifyTimeTrackingPin,
logoutTimeTracking,
} from "@/actions/time-tracking/field";
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import type {
FieldHeadgate,
Screen,
SubmittedLog,
ActiveTab,
} from "./types";
import { MobileWaterPinScreen } from "./PinScreen"; import { MobileWaterPinScreen } from "./PinScreen";
import { MobileWaterHeadgateList } from "./HeadgateList"; import { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm"; import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen"; import { MobileWaterSuccessScreen } from "./SuccessScreen";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session"; import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand"; import {
TUXEDO_BRAND_ID,
TUXEDO_BRAND_NAME,
TUXEDO_BRAND_ACCENT,
TUXEDO_BRAND_LOGO_URL,
} from "@/lib/water-log/brand";
import { useLang } from "@/lib/water-log/lang-context"; import { useLang } from "@/lib/water-log/lang-context";
// Server-side auth errors that should bounce the user to the PIN // Server-side auth errors that should bounce the user to the PIN
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
export function MobileWaterApp() { export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin"); const [screen, setScreen] = useState<Screen>("pin");
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
// `useLang()` reads from the <LangProvider> in WaterFieldClient, // `useLang()` reads from the <LangProvider> in WaterFieldClient,
// so it's always up-to-date when the user toggles via the toggle // so it's always up-to-date when the user toggles via the toggle
// in the headgate list header. // in the headgate list header.
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
setIrrigatorName(result.name ?? null); setIrrigatorName(result.name ?? null);
setScreen("headgates"); setScreen("headgates");
void loadHeadgates(); void loadHeadgates();
// Cycle 4 — unify the PIN with Time Tracking. Best-effort: a
// worker who isn't also set up as a time-tracking worker just
// sees the regular PIN screen on the Time tab. Never blocks
// the water flow, never surfaces a 500 to the user.
void verifyTimeTrackingPin(TUXEDO_BRAND_ID, pin).catch(() => {});
}, },
[loadHeadgates], [loadHeadgates],
); );
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
// ── Logout (from anywhere) ──────────────────────────────────── // ── Logout (from anywhere) ────────────────────────────────────
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
// Cycle 4 — clear BOTH cookies. The water flow only knew about
// `wl_session`; the Time tab introduced `time_tracking_session`.
try { try {
await logoutWater(); await logoutWater();
} catch { } catch {
// Even if the server logout fails, clear local state so the // Even if the server logout fails, clear local state so the
// user isn't stuck. // user isn't stuck.
} }
try {
await logoutTimeTracking();
} catch {
// Same — defensive cleanup.
}
setIrrigatorName(null); setIrrigatorName(null);
setHeadgates([]); setHeadgates([]);
setSelectedHeadgate(null); setSelectedHeadgate(null);
setSubmittedLog(null); setSubmittedLog(null);
setActiveTab("headgates");
setScreen("pin"); setScreen("pin");
}, []); }, []);
// ── Tab change (Cycle 4) ──────────────────────────────────────
const handleTabChange = useCallback((next: ActiveTab) => {
setActiveTab(next);
if (next === "time") {
// Switching to Time drops the in-flight water flow (selected
// headgate + submitted log) so it doesn't linger when the
// worker comes back to Headgates. The headgate list itself
// is cached and will reload on demand.
setSubmittedLog(null);
setSelectedHeadgate(null);
setScreen("time");
} else {
// Switching back to Headgates — restore the flow at the
// headgate list (we always have that state cached; the
// loadHeadgates effect picks up fresh data on mount).
if (headgates.length === 0) void loadHeadgates();
setScreen("headgates");
}
}, [headgates.length, loadHeadgates]);
// ── Switch on the current screen ────────────────────────────── // ── Switch on the current screen ──────────────────────────────
// Compute the screen element first, then wrap it once in // Compute the screen element first, then wrap it once in
// <LangProvider>. Wrapping per-case would remount the provider on // <LangProvider>. Wrapping per-case would remount the provider on
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
/> />
); );
break; break;
case "time":
// Cycle 4 — Tuxedo-only Time tab. Same brand id / name /
// accent as the standalone /tuxedo/time-clock route so the
// embedded surface matches the standalone one pixel-for-pixel.
screenElement = (
<TimeTrackingFieldClient
brandId={TUXEDO_BRAND_ID}
brandName={TUXEDO_BRAND_NAME}
brandAccent={TUXEDO_BRAND_ACCENT}
logoUrl={TUXEDO_BRAND_LOGO_URL}
/>
);
break;
} }
// `initialLang` is computed by the parent <LangProvider> in // ── Post-PIN shell ────────────────────────────────────────────
// WaterFieldClient — we just return the screen element here. // The pre-PIN screen is bare (no nav). After PIN we wrap the
return screenElement; // screen element in a sticky tab bar so the worker can flip
// between Headgates and Time without re-entering credentials.
const isPostPin = screen !== "pin";
return (
<div className="flex h-full flex-col">
{isPostPin && (
<TabBar
activeTab={activeTab}
onChange={handleTabChange}
onLogout={handleLogout}
irrigatorName={irrigatorName ?? undefined}
/>
)}
<div className="min-h-0 flex-1">{screenElement}</div>
</div>
);
}
// ── TabBar (Cycle 4) ───────────────────────────────────────────
// Sticky two-tab top nav for the Tuxedo worker experience.
// Stays visible across Headgates → Log → Success AND on the Time
// tab. Extracted as a presentational component (no server actions,
// no effects) so it's pure and easy to iterate on visually.
function TabBar({
activeTab,
onChange,
onLogout,
irrigatorName,
}: {
activeTab: ActiveTab;
onChange: (next: ActiveTab) => void;
onLogout: () => void;
irrigatorName?: string;
}) {
const t = useLang().t;
return (
<div
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
role="tablist"
aria-label="Worker navigation"
>
<div className="flex items-center gap-2">
<button
type="button"
role="tab"
aria-selected={activeTab === "headgates"}
onClick={() => onChange("headgates")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "headgates"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.headgates}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "time"}
onClick={() => onChange("time")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "time"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.time}
</button>
</div>
<div className="flex items-center gap-2">
{irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName}
</span>
)}
<button
type="button"
onClick={onLogout}
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
aria-label="Log out"
>
{t.tab.logout}
</button>
</div>
</div>
);
} }
export default MobileWaterApp; export default MobileWaterApp;
+10 -2
View File
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
return unit === "holes"; return unit === "holes";
} }
/** Top-level screen identifiers — drives the state machine. */ /** Top-level screen identifiers — drives the state machine.
export type Screen = "pin" | "headgates" | "log" | "success"; *
* Cycle 4 adds "time" for the Tuxedo-only Time tab, which renders
* `TimeTrackingFieldClient` instead of the existing water-entry
* screens. The "pin" → "headgates"/"log"/"success" flow is unchanged.
*/
export type Screen = "pin" | "headgates" | "log" | "success" | "time";
/** Tuxedo-only Time-tab branding (Cycle 4). */
export type ActiveTab = "headgates" | "time";
/** A submitted log entry (only kept locally for the success screen). */ /** A submitted log entry (only kept locally for the success screen). */
export type SubmittedLog = { export type SubmittedLog = {
+10 -1
View File
@@ -11,4 +11,13 @@
* `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` — * `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` —
* which is the standard pattern used throughout the rest of the app. * which is the standard pattern used throughout the rest of the app.
*/ */
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
/** Display surfaces (Cycle 4) — single source of truth for the
* Tuxedo worker experience so the standalone `/tuxedo/time-clock`
* page and the embedded Time tab inside `/water` stay in sync.
* Bump these together when the brand refreshes its accent/logo. */
export const TUXEDO_BRAND_NAME = "Tuxedo Corn";
export const TUXEDO_BRAND_ACCENT = "green";
/** `null` until the Tuxedo brand gets a logo asset uploaded. */
export const TUXEDO_BRAND_LOGO_URL: string | null = null;
+17
View File
@@ -138,6 +138,13 @@ export type Labels = {
minutesAgo: (n: number) => string; minutesAgo: (n: number) => string;
hoursAgo: (n: number) => string; hoursAgo: (n: number) => string;
}; };
/** Tuxedo worker post-PIN shell (Cycle 4) — names for the two
* top tabs and the inline logout affordance. */
tab: {
headgates: string;
time: string;
logout: string;
};
}; };
export const LABELS: Record<Lang, Labels> = { export const LABELS: Record<Lang, Labels> = {
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `${n}m ago`, minutesAgo: (n) => `${n}m ago`,
hoursAgo: (n) => `${n}h ago`, hoursAgo: (n) => `${n}h ago`,
}, },
tab: {
headgates: "Headgates",
time: "Time",
logout: "Log out",
},
}, },
es: { es: {
common: { common: {
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `hace ${n}m`, minutesAgo: (n) => `hace ${n}m`,
hoursAgo: (n) => `hace ${n}h`, hoursAgo: (n) => `hace ${n}h`,
}, },
tab: {
headgates: "Compuertas",
time: "Tiempo",
logout: "Salir",
},
}, },
}; };