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,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:
|
||||
* 2–8 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;
|
||||
Reference in New Issue
Block a user