feat(water-log): redesign /water as Apple HIG mobile-first experience
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s

Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:

  PIN  ->  Headgates  ->  Log  ->  Success

Design (Apple Human Interface Guidelines):
  - System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
    instead of the admin's Fraunces/Manrope for native iOS feel
  - iOS systemGreen (#34C759) primary success, forest-900 brand accent
  - iOS systemGroupedBackground (#F2F2F7) surface, white cards,
    rgba(60,60,67,0.x) text greys
  - 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
  - env(safe-area-inset-top/bottom) respected on every screen
  - 100dvh shell so iOS Safari chrome collapse doesn't reflow

Screens:
  - PinScreen: 4 iOS-style digit boxes with blinking caret, big
    'Unlock' button (disabled until 4 digits entered), branded
    'TUXEDO WATER LOG' header, error shake on bad PIN
  - HeadgateList: grouped list of large tappable cards showing
    name + geocode (notes) + unit + last-logged relative time,
    pull-to-refresh, empty state with refresh CTA
  - LogForm: sticky headgate card + 44pt numeric input +
    SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
    + live 'Logged now' timestamp + optional 500-char notes +
    green Submit Log button with inline measurement/unit preview
  - SuccessScreen: animated ring + checkmark draw-on, recap card,
    'Log Another Entry' (green) and 'Back to Headgates' (secondary)

Reusable primitives (mobile/):
  - SegmentedControl: iOS-style sliding pill with spring timing
  - PullToRefresh: callback-based (no router.refresh) with resistance
  - icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)

Backend:
  - getWaterHeadgates extended to include notes (the 'geocode') and
    last_used_at for richer card display
  - No new routes, no DB migration - reuses verifyWaterPin,
    submitWaterEntry, logoutWater server actions unchanged

Quality gates:
  - tsc --noEmit clean
  - eslint clean on all new files
  - npm run build succeeds (/water rendered as static)
  - prefers-reduced-motion respected (existing global guard)
This commit is contained in:
Tyler
2026-07-01 19:27:10 -06:00
parent ca1cf143d3
commit ac88584b8b
14 changed files with 2292 additions and 1000 deletions
@@ -0,0 +1,240 @@
"use client";
/**
* MobileWaterApp — state machine + screen switcher for the mobile
* Tuxedo Water Log experience.
*
* Flow:
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
* ▲ │ │
* │ └───────────────(logout)─────────────────┤
* └─(logout)─────(anywhere)──────────────────────────────┘
* │
* success ── Log Another ─▶ log (cleared) ─┘
* success ── Back to Headgates ─▶ headgates
*
* 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.
*
* 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
*/
import { useCallback, useEffect, useState } from "react";
import {
verifyWaterPin,
submitWaterEntry,
getWaterHeadgates,
logoutWater,
} from "@/actions/water-log/field";
import type { FieldHeadgate, Screen, SubmittedLog } from "./types";
import { MobileWaterPinScreen } from "./PinScreen";
import { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin");
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
: "Couldn't load headgates. Pull down to try again.",
);
} finally {
setLoadingHeadgates(false);
}
}, []);
// ── 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.match(/wl_session=([^;]+)/);
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();
},
[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("No headgate selected");
}
const result = await submitWaterEntry(
selectedHeadgate.id,
input.measurement,
input.unit,
input.notes,
);
if (!result.success) {
// Session gone? Bounce back to PIN.
if (
result.error === "Session expired or invalid" ||
result.error === "Not logged in"
) {
setScreen("pin");
throw new Error("Your session expired. Please sign in again.");
}
throw new Error(result.error);
}
setSubmittedLog({
headgateName: selectedHeadgate.name,
measurement: input.measurement,
unit: input.unit,
timestamp: new Date(),
});
setScreen("success");
},
[selectedHeadgate],
);
// ── 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 () => {
try {
await logoutWater();
} catch {
// Even if the server logout fails, clear local state so the
// user isn't stuck.
}
setIrrigatorName(null);
setHeadgates([]);
setSelectedHeadgate(null);
setSubmittedLog(null);
setScreen("pin");
}, []);
// ── Switch on the current screen ──────────────────────────────
switch (screen) {
case "pin":
return <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
case "headgates":
return (
<MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined}
headgates={headgates}
loading={loadingHeadgates}
error={headgateError}
onSelect={handleHeadgateSelect}
onRefresh={loadHeadgates}
onLogout={handleLogout}
/>
);
case "log":
if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates"));
return null;
}
return (
<MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate}
onSubmit={handleLogSubmit}
onChangeHeadgate={handleBackToHeadgates}
onLogout={handleLogout}
/>
);
case "success":
if (!submittedLog) {
queueMicrotask(() => setScreen("headgates"));
return null;
}
return (
<MobileWaterSuccessScreen
headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement}
unit={submittedLog.unit}
timestamp={submittedLog.timestamp}
onLogAnother={handleLogAnother}
onBackToHeadgates={handleBackToHeadgates}
/>
);
}
}
export default MobileWaterApp;