5dc9a4604f
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
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.
542 lines
20 KiB
TypeScript
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; |