ac88584b8b
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)
380 lines
15 KiB
TypeScript
380 lines
15 KiB
TypeScript
"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; |