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
@@ -0,0 +1,250 @@
"use client";
/**
* MobileWaterSuccessScreen — confirmation after a successful submit.
*
* Visual structure:
* - Centered success illustration: a green ring that draws on,
* with a checkmark that draws on ~280ms later (the same staggered
* reveal UIKit's success feedback uses).
* - Headline: "Reading logged."
* - Recap card: headgate name + measurement + unit + timestamp.
* - Two large primary actions, stacked:
* - "Log Another Entry" (green primary, refresh icon)
* - "Back to Headgates" (white secondary with thin border)
* - Subtle thank-you micro-copy at the bottom.
*
* The animations use the global `water-ring` + `water-draw` keyframes
* from `src/app/globals.css`.
*/
import { useEffect, useState } from "react";
import { Grid, Refresh } from "./icons";
import { formatClockTime, formatLongTimestamp, formatMeasurement } from "./format";
type Props = {
headgateName: string;
measurement: number;
unit: string;
timestamp: Date;
onLogAnother: () => void;
onBackToHeadgates: () => void;
};
export function MobileWaterSuccessScreen({
headgateName,
measurement,
unit,
timestamp,
onLogAnother,
onBackToHeadgates,
}: Props) {
// Reveal animation — staggered for ring → checkmark → content.
const [ringDone, setRingDone] = useState(false);
const [contentShown, setContentShown] = useState(false);
useEffect(() => {
const ringTimer = window.setTimeout(() => setRingDone(true), 380);
const contentTimer = window.setTimeout(() => setContentShown(true), 480);
return () => {
window.clearTimeout(ringTimer);
window.clearTimeout(contentTimer);
};
}, []);
return (
<div
className="flex h-full flex-col bg-[#F2F2F7]"
style={{
paddingTop: "env(safe-area-inset-top)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
{/* Brand strip — same as PIN screen for visual continuity. */}
<header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
<span
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
TUXEDO WATER LOG
</span>
</div>
</header>
<main className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm text-center">
{/* Success ring + checkmark */}
<div className="relative mx-auto mb-8 h-24 w-24">
<svg
width="96"
height="96"
viewBox="0 0 96 96"
fill="none"
aria-hidden
focusable={false}
>
{/* Ring — circumference ~264px for r=42 */}
<circle
cx="48"
cy="48"
r="42"
stroke="#34C759"
strokeWidth="6"
strokeLinecap="round"
fill="none"
strokeDasharray="264"
strokeDashoffset="264"
style={{
animation:
"water-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
["--ring-from" as string]: "264",
}}
/>
{/* Checkmark — ~62px long */}
<path
d="M30 50 L43 63 L66 38"
stroke="#34C759"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
strokeDasharray="62"
strokeDashoffset="62"
style={{
animation:
"water-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
["--draw-from" as string]: "62",
}}
/>
</svg>
{/* Soft glow behind the ring — sells the celebration. */}
<div
aria-hidden
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(52,199,89,0.18) 0%, rgba(52,199,89,0) 70%)",
opacity: ringDone ? 1 : 0,
transition: "opacity 280ms ease-out",
}}
/>
</div>
{/* Headline */}
<h1
className={[
"mb-2 text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]",
"transition-[opacity,transform] duration-300",
contentShown
? "translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
].join(" ")}
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Reading logged
</h1>
{/* Recap card */}
<div
className={[
"mx-auto mt-3 mb-8 max-w-xs rounded-[14px] bg-white px-5 py-4 text-left",
"shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]",
"transition-[opacity,transform] duration-300",
contentShown
? "translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
].join(" ")}
style={{
transitionDelay: contentShown ? "80ms" : "0ms",
}}
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[15px] font-medium text-[rgba(60,60,67,0.55)]">
{headgateName}
</span>
<span className="shrink-0 text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{formatClockTime(timestamp)}
</span>
</div>
<div className="mt-1 flex items-baseline gap-1.5">
<span
className="text-[36px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{formatMeasurement(measurement)}
</span>
<span className="pb-1 text-[16px] font-semibold text-[rgba(60,60,67,0.55)]">
{unit}
</span>
</div>
<div className="mt-2 border-t border-[rgba(60,60,67,0.10)] pt-2 text-[12px] text-[rgba(60,60,67,0.45)]">
{formatLongTimestamp(timestamp)}
</div>
</div>
{/* Action buttons */}
<div
className={[
"flex flex-col gap-3",
"transition-[opacity,transform] duration-300",
contentShown
? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0",
].join(" ")}
style={{
transitionDelay: contentShown ? "160ms" : "0ms",
}}
>
<button
type="button"
onClick={onLogAnother}
className="flex h-[56px] w-full items-center justify-center gap-2 rounded-[14px] bg-[#34C759] text-[17px] font-semibold text-white active:scale-[0.985] active:opacity-90"
style={{
boxShadow:
"0 6px 18px rgba(52,199,89,0.32), inset 0 1px 0 rgba(255,255,255,0.18)",
}}
>
<Refresh size={20} />
Log Another Entry
</button>
<button
type="button"
onClick={onBackToHeadgates}
className="flex h-[56px] w-full items-center justify-center gap-2 rounded-[14px] border border-[rgba(60,60,67,0.18)] bg-white text-[17px] font-semibold text-[#14532d] active:scale-[0.985] active:bg-[rgba(120,120,128,0.08)]"
style={{
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
}}
>
<Grid size={20} />
Back to Headgates
</button>
</div>
<p className="mt-7 text-[13px] text-[rgba(60,60,67,0.45)]">
Thanks for keeping the records flowing.
</p>
</div>
</main>
</div>
);
}
export default MobileWaterSuccessScreen;