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
+380
View File
@@ -0,0 +1,380 @@
"use client";
/**
* MobileWaterLogForm — measurement entry screen.
*
* Visual structure (Apple HIG form):
* - Sticky top nav: back chevron + "Log Reading" title.
* - Sticky headgate card under the nav — picks a tap target
* bigger than the iOS minimum (44pt) and lets the user confirm
* which headgate they're logging against at a glance.
* - Section: MEASUREMENT
* - HUGE numeric input (44pt font) — the visual focus of the
* screen. `inputMode="decimal"` brings up the iOS numeric
* keyboard with the decimal key.
* - Segmented control for units (CFS / GPM / gal / ac-in /
* ac-ft). The default selection is the headgate's configured
* unit.
* - Live "thresholds" hint below the input (e.g. "Normal range:
* 28 CFS").
* - Section: TIMESTAMP (read-only, auto-updating every second so
* the user can verify they're logging against "now").
* - Section: NOTES (optional, 500-char multiline).
* - Sticky submit bar at the bottom — green primary button per
* HIG success semantics. Slides up out of the keyboard's way
* because the bar is `position: sticky; bottom: 0`.
*/
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import { SegmentedControl } from "./SegmentedControl";
import type { FieldHeadgate, MobileUnit } from "./types";
import { MOBILE_UNITS } from "./types";
import { ChevronLeft, Clock, Gauge, Pencil, Pin, Spinner } from "./icons";
import {
formatLongTimestamp,
formatMeasurement,
headgateSubtitle,
} from "./format";
type Props = {
headgate: FieldHeadgate;
onSubmit: (input: { measurement: number; unit: string; notes: string }) => Promise<void>;
onChangeHeadgate: () => void;
onLogout: () => void;
/** Optional preset units — defaults to MOBILE_UNITS. */
units?: ReadonlyArray<MobileUnit | string>;
};
const NOTES_MAX = 500;
export function MobileWaterLogForm({
headgate,
onSubmit,
onChangeHeadgate,
onLogout,
units = MOBILE_UNITS,
}: Props) {
// ── State ────────────────────────────────────────────────────────
const [measurement, setMeasurement] = useState("");
const [unit, setUnit] = useState<string>(
units.includes(headgate.unit) ? headgate.unit : units[0],
);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState<Date>(() => new Date());
const measurementRef = useRef<HTMLInputElement>(null);
// ── Auto-focus the measurement input on mount so the keyboard
// rises immediately.
useEffect(() => {
const t = window.setTimeout(() => measurementRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, []);
// ── Live clock — re-renders every second. The setInterval is
// cheap (one DOM update per second on a hidden element) and
// gives the user confidence the timestamp reflects "now".
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
// ── Threshold hint derived from the headgate. ────────────────────
const thresholdHint = useMemo(() => {
const h = headgate.high_threshold;
const l = headgate.low_threshold;
if (h == null && l == null) return null;
if (h != null && l != null) {
return `Normal range: ${l} ${h} ${unit}`;
}
if (h != null) return `Alert above ${h} ${unit}`;
return `Alert below ${l} ${unit}`;
}, [headgate, unit]);
const submitted = (e?: FormEvent) => {
e?.preventDefault();
if (submitting) return;
const value = parseFloat(measurement);
if (!Number.isFinite(value) || value < 0) {
setError("Enter a measurement greater than or equal to 0");
return;
}
if (value > 1_000_000) {
setError("That value is implausibly large");
return;
}
setSubmitting(true);
setError(null);
onSubmit({ measurement: value, unit, notes })
.catch((err) => {
setError(
err instanceof Error ? err.message : "Couldn't submit. Try again.",
);
setSubmitting(false);
});
// On success the parent swaps to the success screen.
};
const subtitle = headgateSubtitle(headgate);
const canSubmit =
measurement.trim().length > 0 &&
Number.isFinite(parseFloat(measurement)) &&
parseFloat(measurement) >= 0 &&
!submitting;
return (
<div
className="flex h-full flex-col bg-[#F2F2F7]"
style={{
paddingTop: "env(safe-area-inset-top)",
}}
>
{/* ── Top nav ────────────────────────────────────────────── */}
<header
className="sticky top-0 z-30 flex h-11 items-center border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<button
type="button"
onClick={onChangeHeadgate}
aria-label="Back to headgates"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
<ChevronLeft size={22} />
</button>
<h1
className="absolute left-1/2 -translate-x-1/2 text-[17px] font-semibold text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Log Reading
</h1>
<span className="w-9" />
</header>
{/* ── Scrollable form body ───────────────────────────────── */}
<form
onSubmit={submitted}
className="flex flex-1 flex-col"
// Allow the form to grow so the sticky submit bar can sit at
// the bottom on tall viewports.
>
<div className="flex-1 overflow-y-auto px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
{/* ── Headgate summary card (sticky) ──────────────── */}
<button
type="button"
onClick={onChangeHeadgate}
aria-label="Change headgate"
className="mb-5 flex w-full items-center gap-3 rounded-[14px] bg-white p-3 text-left active:bg-[rgba(120,120,128,0.10)]"
style={{
boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 1px 0 rgba(0,0,0,0.02)",
}}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] bg-[rgba(20,83,45,0.10)] text-[#14532d]">
<Gauge size={22} />
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-[17px] font-semibold leading-[1.2] text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{headgate.name}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[13px] text-[rgba(60,60,67,0.6)]">
<Pin size={12} className="shrink-0 opacity-60" />
<span className="truncate">{subtitle}</span>
</div>
</div>
<span className="rounded-full bg-[rgba(20,83,45,0.10)] px-2.5 py-1 text-[12px] font-semibold text-[#14532d]">
{headgate.unit}
</span>
</button>
{/* ── Measurement section ─────────────────────────── */}
<SectionHeader>Measurement</SectionHeader>
<div className="mb-2 overflow-hidden rounded-[14px] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="flex items-baseline gap-2">
<input
ref={measurementRef}
type="number"
inputMode="decimal"
step="any"
min={0}
value={measurement}
onChange={(e) => {
setMeasurement(e.target.value);
if (error) setError(null);
}}
placeholder="0"
aria-label="Measurement value"
className="min-w-0 flex-1 appearance-none bg-transparent text-[44px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.25)]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
// Hide the native spinner buttons — they crowd a
// small mobile screen and feel non-native.
MozAppearance: "textfield",
}}
/>
<span className="shrink-0 pb-2 text-[20px] font-semibold text-[rgba(60,60,67,0.5)]">
{unit}
</span>
</div>
{thresholdHint && (
<p className="mt-3 border-t border-[rgba(60,60,67,0.10)] pt-3 text-[13px] text-[rgba(60,60,67,0.6)]">
{thresholdHint}
</p>
)}
</div>
{/* Unit segmented control */}
<div className="mb-5">
<SegmentedControl
ariaLabel="Unit"
value={unit as MobileUnit}
onChange={(v: string) => setUnit(v)}
options={units.map((u) => ({ value: u as string, label: u }))}
/>
</div>
{/* ── Timestamp section ───────────────────────────── */}
<SectionHeader>Timestamp</SectionHeader>
<div className="mb-5 flex items-center gap-3 rounded-[14px] bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)] text-[#14532d]">
<Clock size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-[#1d1d1f]">
Logged now
</div>
<div className="mt-0.5 truncate text-[13px] text-[rgba(60,60,67,0.6)]">
{formatLongTimestamp(now)}
</div>
</div>
</div>
{/* ── Notes section ────────────────────────────────── */}
<SectionHeader>
Notes <span className="font-normal text-[rgba(60,60,67,0.45)]"> optional</span>
</SectionHeader>
<div className="mb-5 rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<label className="flex items-start gap-3 p-4">
<Pencil
size={18}
className="mt-0.5 shrink-0 text-[rgba(60,60,67,0.45)]"
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
rows={3}
maxLength={NOTES_MAX}
placeholder="Anything the supervisor should know about this reading?"
className="min-h-[68px] w-full resize-none bg-transparent text-[16px] leading-[1.4] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
/>
</label>
<div className="flex items-center justify-between border-t border-[rgba(60,60,67,0.10)] px-4 py-2 text-[12px] text-[rgba(60,60,67,0.45)]">
<span>Visible to admins only</span>
<span>
{notes.length}/{NOTES_MAX}
</span>
</div>
</div>
{/* Inline error */}
{error && (
<div
role="alert"
className="mb-3 rounded-[12px] bg-[rgba(255,59,48,0.10)] px-4 py-3 text-[15px] text-[#7f1d1d]"
>
{error}
</div>
)}
</div>
{/* ── Sticky submit bar ───────────────────────────────── */}
<div
className="sticky bottom-0 z-20 border-t border-[rgba(60,60,67,0.10)] bg-[rgba(255,255,255,0.92)] px-4 py-3"
style={{
paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<button
type="submit"
disabled={!canSubmit}
className={[
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
"transition-[transform,background-color,opacity] duration-200",
"active:scale-[0.985]",
canSubmit
? "bg-[#34C759] text-white shadow-[0_6px_18px_rgba(52,199,89,0.32),inset_0_1px_0_rgba(255,255,255,0.18)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
Submitting
</>
) : (
<>
Submit Log
{measurement && Number.isFinite(parseFloat(measurement)) && (
<span className="ml-1 opacity-80">
· {formatMeasurement(parseFloat(measurement))} {unit}
</span>
)}
</>
)}
</button>
<div className="mt-2 flex items-center justify-center">
<button
type="button"
onClick={onLogout}
className="text-[12px] font-medium text-[rgba(60,60,67,0.45)] active:text-[#14532d]"
>
Sign out
</button>
</div>
</div>
</form>
</div>
);
}
// ── Section header (HIG inset-grouped style) ───────────────────────────
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h2 className="mb-1.5 px-3 text-[13px] font-semibold uppercase tracking-[0.06em] text-[rgba(60,60,67,0.55)]">
{children}
</h2>
);
}
export default MobileWaterLogForm;