Files
route-commerce/src/components/water/mobile/MobileWaterApp.tsx
T
Tyler 5dc9a4604f
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Water log mobile field experience (PIN → Headgates → Log → Success):

* Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it
  was a stale reference (page metadata + admin Today tab caption).
  Constants already say Tuxedo Corn; this just aligns the stragglers.

* Tab bar (Headgates | Time): pill pair → real UISegmentedControl.
  MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses
  the same ResizeObserver-measure + spring-timing pattern as the
  existing SegmentedControl — feels like one widget at two sizes
  (this one at 32pt, the unit selector at 44pt).

* Liquid-glass chrome (recipe applied 5×, mirrored on the bottom
  submit bar): linear-gradient background + blur(24px) +
  saturate(180%) + inset top highlight + inset bottom hairline.
  TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky
  submit bar, FieldPinScreen + SuccessScreen brand strips.

* ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the
  screen's signature. A live "blip" indicator under the 44pt
  measurement input that ties the form to its real domain (a
  worker reading a real gauge) instead of being a decorative
  gauge trophy. Track + optional colored zones (red·green·red)
  + thumb that slides on Apple's spring curve + color-coded
  zone label. Mirrors Apple's Volume Limit / Screen Time sliders.

* i18n: 9 new zone labels per language (zoneBelowNormal,
  zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin,
  zoneReading, zoneTypeToLog, meterNormalRange + ES twins).
  Legacy normalRange/alertAbove/alertBelow signatures widened
  to accept pre-formatted strings so integer units render clean.

* Updated water-log-i18n unit test for the wider signatures.
  All 27 tests pass; tsc + eslint clean.
2026-07-06 10:32:36 -06:00

542 lines
20 KiB
TypeScript

"use client";
/**
* MobileWaterApp — state machine + screen switcher for the mobile
* Tuxedo Water Log experience.
*
* Flow:
* pin ─verify─▶ tabbed shell
* │
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
* │ ▲ │
* │ │ │
* │ └───────── (logout) ────────────┤
* │
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
*
* Hydration:
* - On mount we peek at `document.cookie` for the `wl_session`
* cookie (set by the server action `verifyWaterPin`). If it's
* present we skip the PIN screen and load headgates directly.
* - This mirrors the existing field-client behavior — irrigators
* who already signed in once in their shift shouldn't have to
* 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:
* - `verifyWaterPin` → signs the irrigator in, sets cookie
* - `getWaterHeadgates` → loads the list of active gates
* - `submitWaterEntry` → posts a new reading
* - `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,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
verifyWaterPin,
submitWaterEntry,
getWaterHeadgates,
logoutWater,
} from "@/actions/water-log/field";
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 { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
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";
// Server-side auth errors that should bounce the user to the PIN
// screen rather than surface as a read error. Matched against the
// exact strings returned by `requireFieldSession` in
// src/actions/water-log/auth.ts.
const SESSION_ERRORS = [
"Session expired",
"Session not found",
"User is inactive",
"Not logged in",
];
export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin");
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
// so it's always up-to-date when the user toggles via the toggle
// in the headgate list header.
const { t } = useLang();
const [irrigatorName, setIrrigatorName] = useState<string | null>(null);
const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
const [loadingHeadgates, setLoadingHeadgates] = useState(false);
const [headgateError, setHeadgateError] = useState<string | null>(null);
const [selectedHeadgate, setSelectedHeadgate] =
useState<FieldHeadgate | null>(null);
const [submittedLog, setSubmittedLog] = useState<SubmittedLog | null>(null);
// Monotonic counter used to remount the log form on "Log Another"
// (so the input + notes clear deterministically without an
// impure Date.now() call in render).
const [logFormKey, setLogFormKey] = useState(0);
// ── Headgate fetch (shared by initial load + pull-to-refresh) ──
const loadHeadgates = useCallback(async () => {
setLoadingHeadgates(true);
setHeadgateError(null);
try {
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
setHeadgates(hgs.filter((h) => h.active));
} catch (err) {
setHeadgateError(
err instanceof Error ? err.message : t.headgates.loadError,
);
} finally {
setLoadingHeadgates(false);
}
}, [t.headgates.loadError]);
// ── Initial bootstrap: if `wl_session` exists, jump straight to
// the headgate list. We defer the cookie read into an effect
// so SSR markup matches (avoids hydration warnings). ────────
useEffect(() => {
if (typeof document === "undefined") return;
const hasSession = document.cookie
.split(";")
.some((c) => c.trim().startsWith(`${WL_SESSION_COOKIE}=`));
if (!hasSession) return;
// Wrap setScreen in a microtask so the call doesn't fire
// synchronously inside the effect body.
queueMicrotask(() => {
setScreen("headgates");
void loadHeadgates();
});
}, [loadHeadgates]);
// ── PIN submit ────────────────────────────────────────────────
const handlePinSubmit = useCallback(
async (pin: string) => {
const result = await verifyWaterPin(TUXEDO_BRAND_ID, pin);
if (!result.success) {
// Trigger the shake animation on the PIN screen.
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("water-pin-shake"));
}
throw new Error(result.error);
}
// If the user turns out to be a water_admin, the legacy flow
// bounces them to /water/admin. Preserve that for parity.
if (result.role === "water_admin") {
window.location.href = "/water/admin";
return;
}
setIrrigatorName(result.name ?? null);
setScreen("headgates");
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],
);
// ── Headgate select ───────────────────────────────────────────
const handleHeadgateSelect = useCallback((hg: FieldHeadgate) => {
setSelectedHeadgate(hg);
setScreen("log");
}, []);
// ── Submit entry ──────────────────────────────────────────────
const handleLogSubmit = useCallback(
async (input: { measurement: number; unit: string; notes: string }) => {
if (!selectedHeadgate) {
throw new Error(t.log.noHeadgateSelected);
}
const result = await submitWaterEntry(
selectedHeadgate.id,
input.measurement,
input.unit,
input.notes,
);
if (!result.success) {
if (SESSION_ERRORS.includes(result.error)) {
setScreen("pin");
throw new Error(t.log.sessionExpired);
}
throw new Error(t.log.submitError);
}
setSubmittedLog({
headgateName: selectedHeadgate.name,
measurement: input.measurement,
unit: input.unit,
timestamp: new Date(),
});
setScreen("success");
},
[selectedHeadgate, t.log.noHeadgateSelected, t.log.sessionExpired, t.log.submitError],
);
// ── Success actions ───────────────────────────────────────────
const handleLogAnother = useCallback(() => {
// Stay on the same headgate but remount the form so the input
// + notes clear. Bumping the state key is deterministic —
// never call Date.now() (or any impure fn) during render.
setLogFormKey((k) => k + 1);
setSubmittedLog(null);
setScreen("log");
}, []);
const handleBackToHeadgates = useCallback(() => {
setSubmittedLog(null);
setSelectedHeadgate(null);
setScreen("headgates");
void loadHeadgates();
}, [loadHeadgates]);
// ── Logout (from anywhere) ────────────────────────────────────
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 {
await logoutWater();
} catch {
// Even if the server logout fails, clear local state so the
// user isn't stuck.
}
try {
await logoutTimeTracking();
} catch {
// Same — defensive cleanup.
}
setIrrigatorName(null);
setHeadgates([]);
setSelectedHeadgate(null);
setSubmittedLog(null);
setActiveTab("headgates");
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 ──────────────────────────────
// Compute the screen element first, then wrap it once in
// <LangProvider>. Wrapping per-case would remount the provider on
// every screen change, wiping lang state.
let screenElement: React.ReactNode = null;
switch (screen) {
case "pin":
screenElement = <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
break;
case "headgates":
screenElement = (
<MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined}
headgates={headgates}
loading={loadingHeadgates}
error={headgateError}
onSelect={handleHeadgateSelect}
onRefresh={loadHeadgates}
onLogout={handleLogout}
/>
);
break;
case "log":
if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates"));
screenElement = null;
break;
}
screenElement = (
<MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate}
irrigatorName={irrigatorName ?? undefined}
onSubmit={handleLogSubmit}
onChangeHeadgate={handleBackToHeadgates}
onLogout={handleLogout}
/>
);
break;
case "success":
if (!submittedLog) {
queueMicrotask(() => setScreen("headgates"));
screenElement = null;
break;
}
screenElement = (
<MobileWaterSuccessScreen
headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement}
unit={submittedLog.unit}
timestamp={submittedLog.timestamp}
irrigatorName={irrigatorName ?? undefined}
onLogAnother={handleLogAnother}
onBackToHeadgates={handleBackToHeadgates}
/>
);
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;
}
// ── Post-PIN shell ────────────────────────────────────────────
// The pre-PIN screen is bare (no nav). After PIN we wrap the
// 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 → Apple HIG pass) ──────────────────────────
// Sticky top nav for the Tuxedo worker experience. Stays visible
// across Headgates → Log → Success AND on the Time tab.
//
// Two HIG principles in play:
// 1. Use UISegmentedControl for the mode switch (not two pills).
// The control is a true iOS segmented control: gray track,
// sliding white pill, spring timing. The same widget appears
// as the unit selector inside the log form, just bigger.
// 2. Use a "liquid glass" chrome on the bar itself: gradient
// background, strong saturate(180%), inner top highlight, and
// a hairline at the bottom edge — not a flat colored band.
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 px-3"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
// "Liquid glass": top catches more light than the bottom,
// so the gradient runs brighter at the top and slightly
// more transparent below. Saturation boost makes any
// background read with a hint of brand warmth as the
// worker scrolls headgates past the bar.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
// Inner highlight + hairline (one box-shadow with two
// insets reads cheaper than separate pseudo-elements).
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
>
<MiniSegmented
ariaLabel="Worker navigation"
value={activeTab}
onChange={onChange}
options={[
{ value: "headgates", label: t.tab.headgates },
{ value: "time", label: t.tab.time },
]}
/>
<div className="flex items-center gap-1.5 pr-0.5">
{irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName}
</span>
)}
<button
type="button"
onClick={onLogout}
aria-label={t.tab.logout}
className="rounded-full px-2.5 py-1 text-[13px] font-semibold text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
{t.tab.logout}
</button>
</div>
</div>
);
}
// ── MiniSegmented (nav-bar-sized UISegmentedControl) ──────────────
// Sliding-pill variant of SegmentedControl tuned for the 32pt nav
// bar context. Reuses the same measurement + spring-timing approach
// as the larger control in `SegmentedControl.tsx` so the two feel
// like one widget at different sizes.
function MiniSegmented<V extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: ReadonlyArray<{ value: V; label: ReactNode }>;
value: V;
onChange: (next: V) => void;
ariaLabel: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// Sliding indicator rect. The indicator uses
// `transform: translateX(...)` with Apple's spring curve so the
// transition reads as "physical," not "fade."
const [rect, setRect] = useState<{ x: number; w: number } | null>(null);
const measure = useCallback(() => {
const root = containerRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(
`[data-seg-key="${value}"]`,
);
if (!btn) return;
const rootRect = root.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
// `x` is measured from the container's content edge (after the
// 2px padding) so the indicator rides just inside the track.
setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width });
}, [value]);
useLayoutEffect(() => {
measure();
}, [measure]);
// Re-measure whenever the option set changes (e.g. labels
// translate via the Lang context and the active segment width
// shifts) so the indicator stays in place.
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const ro = new ResizeObserver(measure);
ro.observe(root);
return () => ro.disconnect();
}, [measure]);
return (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className="relative inline-flex h-8 items-stretch rounded-[9px] p-[2px]"
style={{ background: "rgba(120,120,128,0.20)" }}
>
{rect && (
<div
aria-hidden
className="absolute rounded-[7px] bg-white"
style={{
top: 2,
bottom: 2,
left: 2,
width: rect.w,
transform: `translateX(${rect.x}px)`,
boxShadow:
"0 3px 8px rgba(0,0,0,0.06), 0 3px 1px rgba(0,0,0,0.04), inset 0 -0.5px 0 0 rgba(0,0,0,0.04), inset 0 0.5px 0 0 rgba(255,255,255,1)",
transition:
"transform 320ms cubic-bezier(0.32, 0.72, 0, 1), width 320ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
/>
)}
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={selected}
data-seg-key={opt.value}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 select-none px-3 text-[13px] font-semibold",
"flex items-center justify-center",
"min-w-[78px] active:opacity-80",
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.65)]",
].join(" ")}
style={{
height: 28,
transition:
"color 220ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
);
}
export default MobileWaterApp;