feat(water-log): redesign /water as Apple HIG mobile-first experience
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
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:
@@ -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;
|
||||
Reference in New Issue
Block a user