Files
route-commerce/src/components/water/mobile/LogForm.tsx
T
Tyler ac88584b8b
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
feat(water-log): redesign /water as Apple HIG mobile-first experience
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)
2026-07-01 19:27:10 -06:00

380 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;