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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,340 @@
"use client";
/**
* MobileWaterHeadgateList — headgate selection screen.
*
* Visual structure (Apple HIG grouped list):
* - Sticky top nav: small back chevron + "Headgates" title +
* logout icon on the right.
* - Section header ("ACTIVE HEADGATES") with a count chip.
* - One tappable card per headgate. Each card has:
* - Leading gauge icon (status-tinted)
* - Bold headgate name
* - Subtitle = "geocode" (notes) OR a derived status line
* - Meta row = unit + last-logged relative time
* - Trailing chevron
* - Subtle threshold badge if high/low thresholds are set
* - Pull-to-refresh at the top.
* - "No active headgates" empty state with an icon and CTA back to
* refresh when the operator has just added a gate.
*/
import { useCallback } from "react";
import type { FieldHeadgate } from "./types";
import {
ChevronRight,
Droplet,
Gauge,
Logout,
Pin,
Spinner,
} from "./icons";
import { MobilePullToRefresh } from "./PullToRefresh";
import { formatRelativeTime, headgateSubtitle } from "./format";
type Props = {
/** Optional display name of the logged-in irrigator (for the header). */
irrigatorName?: string;
headgates: FieldHeadgate[];
loading: boolean;
error: string | null;
onSelect: (headgate: FieldHeadgate) => void;
onRefresh: () => Promise<void> | void;
onLogout: () => void;
};
export function MobileWaterHeadgateList({
irrigatorName,
headgates,
loading,
error,
onSelect,
onRefresh,
onLogout,
}: Props) {
const active = headgates.filter((h) => h.active);
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 justify-between 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%)",
}}
>
<div className="flex items-center gap-1">
{/* Placeholder to keep the title centered — the back
* chevron would lead to the PIN screen, which the user
* got here from. Leaving it as just the title to keep
* the nav minimal per HIG. */}
<span className="w-9" />
</div>
<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",
}}
>
Headgates
</h1>
<button
type="button"
onClick={onLogout}
aria-label="Sign out"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
<Logout size={20} />
</button>
</header>
{/* ── Scrollable list ─────────────────────────────────────── */}
<MobilePullToRefresh onRefresh={onRefresh} disabled={loading}>
<div className="px-4 pb-[max(env(safe-area-inset-bottom),1.25rem)] pt-3">
{irrigatorName && (
<p className="mb-4 px-1 text-[13px] font-medium uppercase tracking-[0.08em] text-[rgba(60,60,67,0.5)]">
Signed in as {irrigatorName}
</p>
)}
{/* Error banner */}
{error && (
<div
role="alert"
className="mb-3 flex items-start gap-2 rounded-[12px] bg-[rgba(255,59,48,0.10)] px-4 py-3 text-[15px] text-[#7f1d1d]"
>
<span className="mt-0.5"></span>
<span>{error}</span>
</div>
)}
{/* Section header */}
<div className="mb-2 flex items-end justify-between px-3 pt-1">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.06em] text-[rgba(60,60,67,0.55)]">
Active headgates
</h2>
<span className="text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{active.length}
</span>
</div>
{loading && active.length === 0 ? (
<ListSkeleton />
) : active.length === 0 ? (
<EmptyState onRefresh={onRefresh} refreshing={loading} />
) : (
<ul className="overflow-hidden rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
{active.map((hg, i) => (
<li key={hg.id}>
<HeadgateCard
headgate={hg}
isLast={i === active.length - 1}
onClick={() => onSelect(hg)}
/>
</li>
))}
</ul>
)}
{/* Footer note */}
{active.length > 0 && (
<p className="mt-4 px-3 text-center text-[12px] text-[rgba(60,60,67,0.4)]">
Pull down to refresh the list
</p>
)}
</div>
</MobilePullToRefresh>
</div>
);
}
// ── Headgate card ────────────────────────────────────────────────────────
function HeadgateCard({
headgate,
isLast,
onClick,
}: {
headgate: FieldHeadgate;
isLast: boolean;
onClick: () => void;
}) {
const subtitle = headgateSubtitle(headgate);
const lastUsed = formatRelativeTime(headgate.last_used_at);
const isClosed = headgate.status === "closed";
const isMaintenance = headgate.status === "maintenance";
return (
<button
type="button"
onClick={onClick}
disabled={isClosed || isMaintenance}
className={[
"group flex w-full items-center gap-3 px-4 py-4 text-left transition-colors",
"active:bg-[rgba(120,120,128,0.12)]",
isClosed || isMaintenance ? "opacity-55" : "",
].join(" ")}
style={{
borderBottom: isLast ? "none" : "1px solid rgba(60,60,67,0.10)",
minHeight: 76,
}}
>
{/* Leading icon — colored by status */}
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
style={{
background: isMaintenance
? "rgba(255,149,0,0.14)"
: isClosed
? "rgba(120,120,128,0.14)"
: "rgba(20,83,45,0.10)",
color: isMaintenance
? "#a16207"
: isClosed
? "#86868b"
: "#14532d",
}}
>
<Gauge size={22} />
</div>
{/* Body — name + subtitle + meta */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
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}
</span>
{(headgate.high_threshold != null ||
headgate.low_threshold != null) && (
<ThresholdDot
high={headgate.high_threshold}
low={headgate.low_threshold}
/>
)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[13px] text-[rgba(60,60,67,0.6)]">
<Pin size={13} className="shrink-0 opacity-60" />
<span className="truncate">{subtitle}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[12px] text-[rgba(60,60,67,0.45)]">
<span className="rounded-full bg-[rgba(120,120,128,0.12)] px-2 py-0.5 font-medium">
{headgate.unit}
</span>
<span>·</span>
<span>{lastUsed}</span>
</div>
</div>
{/* Trailing chevron */}
<ChevronRight size={18} className="shrink-0 text-[rgba(60,60,67,0.3)]" />
</button>
);
}
// ── Threshold dot (small indicator on the card row) ────────────────────
function ThresholdDot({
high,
}: {
high: number | null;
low: number | null;
}) {
return (
<span
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide"
style={{
background: high != null ? "rgba(255,59,48,0.12)" : "rgba(0,122,255,0.12)",
color: high != null ? "#7f1d1d" : "#1e3a8a",
}}
>
{high != null ? "Hi" : "Lo"}
</span>
);
}
// ── Skeleton ────────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<ul className="overflow-hidden rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex animate-pulse items-center gap-3 px-4 py-4"
style={{
borderBottom: i === 3 ? "none" : "1px solid rgba(60,60,67,0.10)",
}}
>
<div className="h-11 w-11 rounded-[10px] bg-[rgba(120,120,128,0.16)]" />
<div className="flex-1 space-y-2">
<div className="h-4 w-2/3 rounded bg-[rgba(120,120,128,0.16)]" />
<div className="h-3 w-1/2 rounded bg-[rgba(120,120,128,0.12)]" />
</div>
</li>
))}
</ul>
);
}
// ── Empty state ─────────────────────────────────────────────────────────
function EmptyState({
onRefresh,
refreshing,
}: {
onRefresh: () => Promise<void> | void;
refreshing: boolean;
}) {
const handleRefresh = useCallback(async () => {
if (!refreshing) await onRefresh();
}, [onRefresh, refreshing]);
return (
<div className="rounded-[14px] bg-white px-6 py-12 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)]">
<Droplet size={28} className="text-[#14532d]" />
</div>
<h3 className="mb-1 text-[17px] font-semibold text-[#1d1d1f]">
No active headgates
</h3>
<p className="mx-auto mb-5 max-w-xs text-[15px] leading-[1.4] text-[rgba(60,60,67,0.6)]">
Once your supervisor activates a headgate, it&apos;ll show up here.
Pull down to check for updates.
</p>
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="inline-flex h-11 items-center justify-center gap-2 rounded-[10px] bg-[#14532d] px-5 text-[15px] font-semibold text-white active:opacity-80 disabled:opacity-60"
style={{
boxShadow: "0 4px 12px rgba(20,83,45,0.25)",
}}
>
{refreshing ? (
<>
<Spinner size={16} className="text-white" style={{ animation: "spin 0.8s linear infinite" }} />
Checking
</>
) : (
"Refresh"
)}
</button>
</div>
);
}
export default MobileWaterHeadgateList;
+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;
@@ -0,0 +1,240 @@
"use client";
/**
* MobileWaterApp — state machine + screen switcher for the mobile
* Tuxedo Water Log experience.
*
* Flow:
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
* ▲ │ │
* │ └───────────────(logout)─────────────────┤
* └─(logout)─────(anywhere)──────────────────────────────┘
* │
* success ── Log Another ─▶ log (cleared) ─┘
* success ── Back to Headgates ─▶ headgates
*
* Hydration:
* - On mount we peek at `document.cookie` for the `wl_session`
* cookie (set by the server action `verifyWaterPin`). If it's
* present we skip the PIN screen and load headgates directly.
* - This mirrors the existing field-client behavior — irrigators
* who already signed in once in their shift shouldn't have to
* re-enter their PIN every time they reopen the page.
*
* Server actions used:
* - `verifyWaterPin` → signs the irrigator in, sets cookie
* - `getWaterHeadgates` → loads the list of active gates
* - `submitWaterEntry` → posts a new reading
* - `logoutWater` → clears the cookie + session row
*/
import { useCallback, useEffect, useState } from "react";
import {
verifyWaterPin,
submitWaterEntry,
getWaterHeadgates,
logoutWater,
} from "@/actions/water-log/field";
import type { FieldHeadgate, Screen, SubmittedLog } from "./types";
import { MobileWaterPinScreen } from "./PinScreen";
import { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin");
const [irrigatorName, setIrrigatorName] = useState<string | null>(null);
const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
const [loadingHeadgates, setLoadingHeadgates] = useState(false);
const [headgateError, setHeadgateError] = useState<string | null>(null);
const [selectedHeadgate, setSelectedHeadgate] =
useState<FieldHeadgate | null>(null);
const [submittedLog, setSubmittedLog] = useState<SubmittedLog | null>(null);
// Monotonic counter used to remount the log form on "Log Another"
// (so the input + notes clear deterministically without an
// impure Date.now() call in render).
const [logFormKey, setLogFormKey] = useState(0);
// ── Headgate fetch (shared by initial load + pull-to-refresh) ──
const loadHeadgates = useCallback(async () => {
setLoadingHeadgates(true);
setHeadgateError(null);
try {
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
setHeadgates(hgs.filter((h) => h.active));
} catch (err) {
setHeadgateError(
err instanceof Error
? err.message
: "Couldn't load headgates. Pull down to try again.",
);
} finally {
setLoadingHeadgates(false);
}
}, []);
// ── Initial bootstrap: if `wl_session` exists, jump straight to
// the headgate list. We defer the cookie read into an effect
// so SSR markup matches (avoids hydration warnings). ────────
useEffect(() => {
if (typeof document === "undefined") return;
const hasSession = document.cookie.match(/wl_session=([^;]+)/);
if (!hasSession) return;
// Wrap setScreen in a microtask so the call doesn't fire
// synchronously inside the effect body.
queueMicrotask(() => {
setScreen("headgates");
void loadHeadgates();
});
}, [loadHeadgates]);
// ── PIN submit ────────────────────────────────────────────────
const handlePinSubmit = useCallback(
async (pin: string) => {
const result = await verifyWaterPin(TUXEDO_BRAND_ID, pin);
if (!result.success) {
// Trigger the shake animation on the PIN screen.
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("water-pin-shake"));
}
throw new Error(result.error);
}
// If the user turns out to be a water_admin, the legacy flow
// bounces them to /water/admin. Preserve that for parity.
if (result.role === "water_admin") {
window.location.href = "/water/admin";
return;
}
setIrrigatorName(result.name ?? null);
setScreen("headgates");
void loadHeadgates();
},
[loadHeadgates],
);
// ── Headgate select ───────────────────────────────────────────
const handleHeadgateSelect = useCallback((hg: FieldHeadgate) => {
setSelectedHeadgate(hg);
setScreen("log");
}, []);
// ── Submit entry ──────────────────────────────────────────────
const handleLogSubmit = useCallback(
async (input: { measurement: number; unit: string; notes: string }) => {
if (!selectedHeadgate) {
throw new Error("No headgate selected");
}
const result = await submitWaterEntry(
selectedHeadgate.id,
input.measurement,
input.unit,
input.notes,
);
if (!result.success) {
// Session gone? Bounce back to PIN.
if (
result.error === "Session expired or invalid" ||
result.error === "Not logged in"
) {
setScreen("pin");
throw new Error("Your session expired. Please sign in again.");
}
throw new Error(result.error);
}
setSubmittedLog({
headgateName: selectedHeadgate.name,
measurement: input.measurement,
unit: input.unit,
timestamp: new Date(),
});
setScreen("success");
},
[selectedHeadgate],
);
// ── Success actions ───────────────────────────────────────────
const handleLogAnother = useCallback(() => {
// Stay on the same headgate but remount the form so the input
// + notes clear. Bumping the state key is deterministic —
// never call Date.now() (or any impure fn) during render.
setLogFormKey((k) => k + 1);
setSubmittedLog(null);
setScreen("log");
}, []);
const handleBackToHeadgates = useCallback(() => {
setSubmittedLog(null);
setSelectedHeadgate(null);
setScreen("headgates");
void loadHeadgates();
}, [loadHeadgates]);
// ── Logout (from anywhere) ────────────────────────────────────
const handleLogout = useCallback(async () => {
try {
await logoutWater();
} catch {
// Even if the server logout fails, clear local state so the
// user isn't stuck.
}
setIrrigatorName(null);
setHeadgates([]);
setSelectedHeadgate(null);
setSubmittedLog(null);
setScreen("pin");
}, []);
// ── Switch on the current screen ──────────────────────────────
switch (screen) {
case "pin":
return <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
case "headgates":
return (
<MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined}
headgates={headgates}
loading={loadingHeadgates}
error={headgateError}
onSelect={handleHeadgateSelect}
onRefresh={loadHeadgates}
onLogout={handleLogout}
/>
);
case "log":
if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates"));
return null;
}
return (
<MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate}
onSubmit={handleLogSubmit}
onChangeHeadgate={handleBackToHeadgates}
onLogout={handleLogout}
/>
);
case "success":
if (!submittedLog) {
queueMicrotask(() => setScreen("headgates"));
return null;
}
return (
<MobileWaterSuccessScreen
headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement}
unit={submittedLog.unit}
timestamp={submittedLog.timestamp}
onLogAnother={handleLogAnother}
onBackToHeadgates={handleBackToHeadgates}
/>
);
}
}
export default MobileWaterApp;
+296
View File
@@ -0,0 +1,296 @@
"use client";
/**
* MobileWaterPinScreen — PIN entry screen.
*
* Visual structure (Apple HIG):
* - Minimal nav bar with "TUXEDO WATER LOG" branding (system
* small-caps via tracking).
* - Centered droplet + lock icon pair as a brand anchor.
* - Headline ("Enter PIN") + supporting caption.
* - Four large digit boxes (64pt square) that mirror the value of
* a hidden <input>. Tapping the row focuses the hidden input —
* the native numeric keyboard rises from the bottom.
* - Full-width primary "Unlock" button (HIG success blue).
* - Inline error pill replaces the caption on failure, with a
* gentle shake.
*
* The hidden input is the source of truth so iOS's QuickType /
* password-manager suggestions work correctly — we never rebuild
* the digit boxes from a synthetic onChange.
*/
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { Droplet, Lock, Spinner } from "./icons";
type Props = {
/** Called when the user submits a 4-8 digit PIN. */
onSubmit: (pin: string) => Promise<void>;
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
length?: 4 | 5 | 6 | 7 | 8;
};
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [shake, setShake] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus the hidden input when the screen mounts so the
// keyboard rises immediately. iOS sometimes needs a tiny delay
// for the focus to take after the page transition.
useEffect(() => {
const t = window.setTimeout(() => inputRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
// Strip non-digits, clamp to `length`.
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setPin(next);
if (error) setError(null);
},
[error, length],
);
const submit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
if (pin.length !== length || submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(pin);
// Don't reset here — the parent will navigate away.
} catch (err) {
// Network / unexpected error
setError(
err instanceof Error ? err.message : "Something went wrong. Try again.",
);
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
} finally {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit],
);
const onFailureShake = useCallback(() => {
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
inputRef.current?.focus();
}, []);
// Expose the shake trigger via a custom event so the parent can
// fire it after a failed verify (parent owns the verify call).
useEffect(() => {
function onShake() {
onFailureShake();
}
window.addEventListener("water-pin-shake", onShake);
return () => window.removeEventListener("water-pin-shake", onShake);
}, [onFailureShake]);
return (
<div className="flex h-full flex-col bg-[#F2F2F7]">
{/* ── Branded header ───────────────────────────────────────── */}
<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">
<Droplet size={18} className="text-[#14532d]" />
<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>
{/* ── Centered content ─────────────────────────────────────── */}
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
<div className="w-full max-w-sm">
{/* Brand mark */}
<div className="mb-7 flex justify-center">
<div
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
style={{
background:
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Lock size={32} className="text-[#14532d]" />
</div>
</div>
<h1
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Enter your PIN
</h1>
{/* Caption OR inline error */}
<p
className={[
"mb-9 min-h-[20px] text-center text-[15px]",
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? "Sign in to log a water reading"}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
{/* The hidden input is the source of truth and gives us
* native iOS numeric keyboard + autofill. The visible
* boxes mirror its value. */}
<form onSubmit={submit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={length}
value={pin}
onChange={handleChange}
// Keep the hidden input in the layout flow so its
// keyboard rises correctly. Visually hidden via the
// standard "sr-only" technique.
className="sr-only"
aria-label="PIN"
disabled={submitting}
/>
<button
type="button"
onClick={() => inputRef.current?.focus()}
aria-label="PIN entry — tap to focus"
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
>
{Array.from({ length }).map((_, i) => {
const filled = i < pin.length;
const active = i === pin.length;
return (
<div
key={i}
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
style={{
boxShadow: active
? "0 0 0 2px #14532d, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)"
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
}}
>
{filled ? (
<span className="relative">
{/* Solid dot — never reveals the digit back */}
<span
aria-hidden
className="block h-[14px] w-[14px] rounded-full bg-[#1d1d1f]"
/>
</span>
) : null}
{/* Caret blink on the active box (iOS-style). */}
{active && !filled && (
<span
aria-hidden
className="block h-[28px] w-[2px] rounded-full bg-[#14532d]"
style={{
animation: "water-blink 1.05s steps(2, start) infinite",
}}
/>
)}
</div>
);
})}
</button>
{/* Hidden submit so Enter from the keyboard triggers Unlock */}
<button type="submit" className="sr-only" tabIndex={-1}>
Unlock
</button>
</form>
{/* ── Unlock button ─────────────────────────────────── */}
<button
type="button"
onClick={() => submit()}
disabled={pin.length !== length || submitting}
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]",
pin.length === length && !submitting
? "bg-[#14532d] text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
style={{
// Pressable feedback — springy scale.
transform: shake ? "translateX(0)" : undefined,
}}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
Verifying
</>
) : (
<>Unlock</>
)}
</button>
{/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
Forgot your PIN? Ask the ditch supervisor.
</p>
</div>
</main>
{/* Apply the shake via inline animation when `shake` is true.
* The `water-shake` keyframe lives in `src/app/globals.css`
* alongside the rest of the Water Log animation set. */}
{shake && (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-1/2 z-50 mx-auto max-w-sm"
style={{
animation: "water-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
}}
/>
)}
</div>
);
}
export default MobileWaterPinScreen;
@@ -0,0 +1,173 @@
"use client";
/**
* MobilePullToRefresh — iOS-style pull-to-refresh for the headgate list.
*
* Why a custom implementation (vs reusing the admin one):
* - The admin PullToRefresh calls `router.refresh()` to re-render
* the server component. The mobile flow is fully client-side
* (the headgate list comes from a server action), so we need a
* callback-based variant that re-fetches via `onRefresh`.
* - The mobile version also needs to feel snappier on a phone
* (smaller resistance curve, indicator pinned to the top of the
* scrollable region).
*
* The indicator:
* - Sits 16px below the top edge of the scroll container.
* - Scales + fades in based on pull progress (0 → 1 across the
* trigger threshold), with the spinner kicking in only once
* refresh actually starts.
* - Respects `prefers-reduced-motion` (the rest of the mobile
* bundle does too — see `usePrefersReducedMotion` in
* `MobileWaterApp`).
*/
import {
useCallback,
useRef,
useState,
type ReactNode,
} from "react";
import { Spinner } from "./icons";
type Props = {
onRefresh: () => Promise<void> | void;
children: ReactNode;
/** Pull distance (px) at which refresh triggers. Default 72. */
threshold?: number;
/** Maximum stretch distance (px) under heavy pull. Default 120. */
maxPull?: number;
/** Disable pull-to-refresh (e.g. while loading). */
disabled?: boolean;
};
export function MobilePullToRefresh({
onRefresh,
children,
threshold = 72,
maxPull = 120,
disabled = false,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const startY = useRef<number | null>(null);
const [offset, setOffset] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [isPulling, setIsPulling] = useState(false);
// The pull-to-refresh should only fire when the list is already at
// the very top — otherwise the user is mid-scroll and we should
// hand the gesture back to the native scroll.
const atTop = useCallback(() => {
const el = scrollRef.current;
if (!el) return true;
return el.scrollTop <= 0;
}, []);
const onTouchStart = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
if (disabled || refreshing) return;
if (!atTop()) return;
startY.current = e.touches[0].clientY;
setIsPulling(true);
},
[disabled, refreshing, atTop],
);
const onTouchMove = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
if (!isPulling || startY.current == null) return;
const dy = e.touches[0].clientY - startY.current;
if (dy <= 0) {
setOffset(0);
return;
}
// Resistance curve — feels heavier as you pull further so a
// casual swipe doesn't accidentally trigger refresh.
const resistance = 1 - Math.min(1, dy / 600);
setOffset(Math.min(maxPull, dy * resistance));
},
[isPulling, maxPull],
);
const onTouchEnd = useCallback(async () => {
if (!isPulling) return;
setIsPulling(false);
startY.current = null;
if (offset >= threshold) {
setRefreshing(true);
setOffset(threshold); // hold the indicator in place while refreshing
try {
await Promise.resolve(onRefresh());
} finally {
// A small delay so the spinner is visible even on a fast
// refresh — feels less jarring.
await new Promise((r) => setTimeout(r, 350));
setRefreshing(false);
setOffset(0);
}
} else {
setOffset(0);
}
}, [isPulling, offset, threshold, onRefresh]);
const progress = Math.min(1, offset / threshold);
return (
<div
ref={scrollRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
className="relative h-full overflow-y-auto overscroll-contain"
style={{
WebkitOverflowScrolling: "touch",
}}
>
{/* Indicator — absolutely positioned just below the top edge.
* Translates with the pull so it follows the finger. */}
<div
aria-hidden
className="pointer-events-none sticky top-0 z-20 flex justify-center"
style={{
height: 56,
transform: `translateY(${Math.max(0, offset - 56)}px)`,
transition: isPulling
? "none"
: "transform 280ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div
className="mt-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/85 backdrop-blur-md"
style={{
opacity: refreshing ? 1 : 0.4 + progress * 0.6,
transform: `scale(${0.55 + progress * 0.45}) rotate(${progress * 180}deg)`,
transition: isPulling
? "none"
: "transform 280ms cubic-bezier(0.32, 0.72, 0, 1), opacity 200ms ease",
boxShadow: "0 1px 2px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.06)",
}}
>
{refreshing ? (
<Spinner
size={20}
className="text-[#1a4d2e]"
style={{ animation: "spin 0.8s linear infinite" }}
/>
) : (
<span
className="text-[15px] font-bold leading-none text-[#1a4d2e]"
style={{
transform: `rotate(-${progress * 180}deg)`,
}}
>
</span>
)}
</div>
</div>
{children}
</div>
);
}
export default MobilePullToRefresh;
@@ -0,0 +1,164 @@
"use client";
/**
* MobileWaterSegmented — an iOS-style segmented control.
*
* Mimics UISegmentedControl:
* - Rounded 9px outer container in surface-2 (iOS systemGray5).
* - White sliding pill behind the selected option.
* - iOS spring timing (cubic-bezier(0.32, 0.72, 0, 1)) for the
* pill transition.
* - Hugs the largest option's natural width when the parent gives
* no fixed width — looks balanced for 3-5 short labels.
* - 44pt minimum touch target per HIG.
*
* Uses a useLayoutEffect ref-measurement pass so the indicator
* slides smoothly to the correct option when the value changes
* (including the initial mount and across option-list changes).
*/
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
export type SegmentedOption<V extends string> = {
value: V;
label: ReactNode;
/** Optional badge or icon, rendered inside the segment. */
badge?: ReactNode;
};
type Props<V extends string> = {
options: ReadonlyArray<SegmentedOption<V>>;
value: V;
onChange: (value: V) => void;
/** ARIA label for the group. */
ariaLabel?: string;
/** Optional fixed height (default 44 — the HIG minimum). */
height?: number;
/** Disable the whole control. */
disabled?: boolean;
};
export function SegmentedControl<V extends string>({
options,
value,
onChange,
ariaLabel,
height = 44,
disabled = false,
}: Props<V>) {
const containerRef = useRef<HTMLDivElement>(null);
// Cache {x, width} per option key so the sliding indicator animates
// even when options aren't measured synchronously (e.g. SSR).
const [rects, setRects] = useState<Record<string, { x: number; w: number }>>({});
// Measure on mount and whenever the option set changes.
useLayoutEffect(() => {
if (!containerRef.current) return;
const root = containerRef.current;
const next: Record<string, { x: number; w: number }> = {};
options.forEach((opt) => {
const el = root.querySelector<HTMLButtonElement>(
`[data-seg-key="${opt.value}"]`,
);
if (el) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
next[opt.value] = { x: elRect.left - rootRect.left, w: elRect.width };
}
});
setRects(next);
}, [options]);
// Re-measure on resize so the indicator stays aligned.
useEffect(() => {
if (!containerRef.current) return;
const ro = new ResizeObserver(() => {
const root = containerRef.current!;
const next: Record<string, { x: number; w: number }> = {};
options.forEach((opt) => {
const el = root.querySelector<HTMLButtonElement>(
`[data-seg-key="${opt.value}"]`,
);
if (el) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
next[opt.value] = { x: elRect.left - rootRect.left, w: elRect.width };
}
});
setRects(next);
});
ro.observe(containerRef.current);
return () => ro.disconnect();
}, [options]);
const active = rects[value];
return (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className="relative inline-flex w-full items-stretch rounded-[10px] bg-[rgba(120,120,128,0.12)] p-[2px]"
style={{
height,
opacity: disabled ? 0.5 : 1,
}}
>
{/* Sliding indicator. 4px of inset (2px container padding + 2px
* visual breathing room) gives it the iOS look without
* feeling cramped. */}
{active && (
<div
aria-hidden
className="absolute top-[2px] bottom-[2px] rounded-[8px] bg-white"
style={{
transform: `translateX(${active.x - 2}px)`,
width: active.w,
boxShadow:
"0 1px 1px rgba(0,0,0,0.04), 0 2px 6px rgba(0,0,0,0.06), inset 0 0 0 0.5px rgba(0,0,0,0.04)",
transition:
"transform 320ms cubic-bezier(0.32, 0.72, 0, 1), width 320ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
/>
)}
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={selected}
data-seg-key={opt.value}
disabled={disabled}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 flex-1 select-none px-2 text-[15px] font-semibold leading-none",
"flex items-center justify-center gap-1.5",
"rounded-[8px] active:opacity-80",
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
style={{
minHeight: 44,
transition: "color 200ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<span className="truncate">{opt.label}</span>
{opt.badge != null && (
<span className="text-[11px] font-bold leading-none rounded-full bg-[rgba(120,120,128,0.16)] text-[#3c3c43] px-1.5 py-0.5">
{opt.badge}
</span>
)}
</button>
);
})}
</div>
);
}
export default SegmentedControl;
@@ -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;
+92
View File
@@ -0,0 +1,92 @@
/**
* Mobile Water Log — formatting helpers.
*
* Pure functions, no React. Used by the PIN, headgate list, and log
* screens for consistent display of timestamps, units, and "geocode"
* secondary text.
*/
/**
* Format an ISO timestamp as a short relative phrase.
* "just now" < 45s
* "5m ago" < 60m
* "3h ago" < 24h
* "Mar 14" same calendar year
* "Mar 14 '25" previous calendar year
*/
export function formatRelativeTime(iso: string | null): string {
if (!iso) return "Never logged";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "Never logged";
const diffSec = Math.round((Date.now() - then) / 1000);
if (diffSec < 45) return "Just now";
if (diffSec < 60 * 60) return `${Math.round(diffSec / 60)}m ago`;
if (diffSec < 60 * 60 * 24) return `${Math.round(diffSec / 3600)}h ago`;
const d = new Date(iso);
const sameYear = d.getFullYear() === new Date().getFullYear();
const date = d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return sameYear ? date : `${date} '${String(d.getFullYear()).slice(-2)}`;
}
/**
* Format a Date as a 12-hour time suitable for the sticky log header
* (e.g. "2:37 PM").
*/
export function formatClockTime(d: Date): string {
return d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
/**
* Format a Date as a long human label for the submit timestamp preview
* (e.g. "Wed, Mar 14 · 2:37 PM").
*/
export function formatLongTimestamp(d: Date): string {
const date = d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
return `${date} · ${formatClockTime(d)}`;
}
/**
* Format a measurement for the success-screen recap. Strips trailing
* zeros and clamps to 4 significant fraction digits so "12.5000"
* becomes "12.5".
*/
export function formatMeasurement(value: number): string {
if (!Number.isFinite(value)) return "0";
const fixed = value.toFixed(4);
return fixed.replace(/\.?0+$/, "") || "0";
}
/**
* Decide what to show under a headgate name. The schema doesn't have
* a dedicated `geocode` column, so we fall back through the available
* context fields in priority order:
* 1. headgate.notes — admin's free-text label
* 2. status label — "Open", "Closed", "Maintenance"
* 3. default "Headgate"
*/
export function headgateSubtitle(h: FieldHeadgateLike): string {
if (h.notes && h.notes.trim()) return h.notes.trim();
const status = (h.status ?? "open").toLowerCase();
if (status === "open") return "Open · Ready to log";
if (status === "closed") return "Closed";
if (status === "maintenance") return "Under maintenance";
return status;
}
/** Type alias used by `headgateSubtitle` — accepts any object with the
* relevant subset, so callers don't need the full FieldHeadgate type. */
type FieldHeadgateLike = {
notes?: string | null;
status?: string | null;
};
+189
View File
@@ -0,0 +1,189 @@
/**
* Mobile Water Log — inline SVG icon set.
*
* SF Symbol-style icons drawn at a 24×24 grid with `currentColor`
* strokes so they tint to whatever text color the parent passes.
* Avoids the icon-font dependency that would otherwise bloat the
* mobile bundle. All icons use 1.75px strokes (close to SF's
* "medium" weight at this size) and rounded line caps for the soft,
* pen-friendly feel HIG calls for.
*/
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
function base({ size = 22, strokeWidth = 1.75, ...rest }: IconProps) {
return {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
focusable: false,
...rest,
};
}
/** iOS-style chevron pointing right — used in list rows. */
export function ChevronRight(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M9 5l7 7-7 7" />
</svg>
);
}
/** iOS-style chevron pointing left — used in nav bars. */
export function ChevronLeft(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M15 19l-7-7 7-7" />
</svg>
);
}
/** A water droplet — used for the app icon and brand mark. */
export function Droplet(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M12 3.5c0 0 -6.5 7 -6.5 11a6.5 6.5 0 0 0 13 0c0 -4 -6.5 -11 -6.5 -11z" />
<path d="M9 14a3 3 0 0 0 3 3" opacity="0.6" />
</svg>
);
}
/** A pin/marker — used for the geocode subtitle. */
export function Pin(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M12 21s7 -6.2 7 -11a7 7 0 1 0 -14 0c0 4.8 7 11 7 11z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
);
}
/** A circular arrow — used for "Log Another Entry". */
export function Refresh(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M3.5 12a8.5 8.5 0 0 1 14.5 -6" />
<path d="M18 3v4.5h-4.5" />
<path d="M20.5 12a8.5 8.5 0 0 1 -14.5 6" />
<path d="M6 21v-4.5h4.5" />
</svg>
);
}
/** A back-arrow grid — used for "Back to Headgates". */
export function Grid(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="4" y="4" width="7" height="7" rx="1.5" />
<rect x="13" y="4" width="7" height="7" rx="1.5" />
<rect x="4" y="13" width="7" height="7" rx="1.5" />
<rect x="13" y="13" width="7" height="7" rx="1.5" />
</svg>
);
}
/** A gauge — used for the headgate card icon. */
export function Gauge(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M4 14a8 8 0 1 1 16 0" />
<path d="M12 14l4 -4" />
<circle cx="12" cy="14" r="1.2" fill="currentColor" stroke="none" />
<path d="M4 18h16" opacity="0.4" />
</svg>
);
}
/** Big checkmark for the success screen. */
export function CheckCircle(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="12" cy="12" r="9.5" />
<path d="M8 12.5l2.8 2.8L16 9.5" strokeWidth={2.25} />
</svg>
);
}
/** A lock — used on the PIN screen to anchor the brand. */
export function Lock(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="5" y="11" width="14" height="9.5" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
<circle cx="12" cy="15.5" r="1" fill="currentColor" stroke="none" />
</svg>
);
}
/** A pencil — used next to notes input. */
export function Pencil(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M4 20h4l10 -10 -4 -4L4 16v4z" />
<path d="M14 6l4 4" />
</svg>
);
}
/** Clock — used for the auto-timestamp display. */
export function Clock(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
}
/** Spinner — used for async states. */
export function Spinner({ size = 22, ...rest }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden
focusable={false}
{...rest}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2.5" />
<path
d="M21 12a9 9 0 0 0 -9 -9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
);
}
/** Alert triangle — used for error states. */
export function AlertTriangle(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M12 3.5L21 19H3L12 3.5z" />
<path d="M12 10v4" />
<circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none" />
</svg>
);
}
/** Logout — used to exit the PIN session. */
export function Logout(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M15 3.5h4a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1 -1.5 1.5h-4" />
<path d="M10 17l-5 -5 5 -5" />
<path d="M15 12H5" />
</svg>
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Mobile Water Log — shared types.
*
* Mirror of the server-returned headgate shape from
* `src/actions/water-log/field.ts`. Keep these in sync if the server
* shape changes.
*/
export type FieldHeadgate = {
id: string;
name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
/** Free-text location description / coordinate string ("geocode"). */
notes: string | null;
/** ISO timestamp of last successful log. */
last_used_at: string | null;
};
/** The five units the mobile app offers in its segmented control. */
export const MOBILE_UNITS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"] as const;
export type MobileUnit = (typeof MOBILE_UNITS)[number];
/** Top-level screen identifiers — drives the state machine. */
export type Screen = "pin" | "headgates" | "log" | "success";
/** A submitted log entry (only kept locally for the success screen). */
export type SubmittedLog = {
headgateName: string;
measurement: number;
unit: MobileUnit | string;
timestamp: Date;
};