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:
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'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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user