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:
@@ -52,6 +52,16 @@ type FieldHeadgate = {
|
|||||||
high_threshold: number | null;
|
high_threshold: number | null;
|
||||||
low_threshold: number | null;
|
low_threshold: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Free-text headgate description. Often a field location / section
|
||||||
|
* label (e.g. "Section 4 — North Field") or a coordinate string
|
||||||
|
* ("GPS 36.1234, -119.5678"). The mobile UI shows this as the
|
||||||
|
* card's secondary line — think of it as the "geocode" until a
|
||||||
|
* proper location column ships.
|
||||||
|
*/
|
||||||
|
notes: string | null;
|
||||||
|
/** ISO timestamp of the most recent successful log. Display only. */
|
||||||
|
last_used_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VerifyPinResult =
|
type VerifyPinResult =
|
||||||
@@ -95,6 +105,8 @@ export async function getWaterHeadgates(
|
|||||||
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
||||||
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
||||||
active: h.active,
|
active: h.active,
|
||||||
|
notes: h.notes,
|
||||||
|
last_used_at: h.lastUsedAt ? h.lastUsedAt.toISOString() : null,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,37 @@ select:-webkit-autofill:focus {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Water Log — PIN caret blink. The `to { opacity: 0 }` +
|
||||||
|
* `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl
|
||||||
|
* / UITextField, not a gentle fade. */
|
||||||
|
@keyframes water-blink {
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Water Log — PIN-entry error shake. Three diminishing
|
||||||
|
* oscillations for a "no, that wasn't right" feel. */
|
||||||
|
@keyframes water-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-10px); }
|
||||||
|
40% { transform: translateX(10px); }
|
||||||
|
60% { transform: translateX(-7px); }
|
||||||
|
80% { transform: translateX(7px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Water Log — checkmark stroke draw for the success screen.
|
||||||
|
* The element's `stroke-dasharray` is set inline so it works for any
|
||||||
|
* length; the keyframe just controls the timing. */
|
||||||
|
@keyframes water-draw {
|
||||||
|
from { stroke-dashoffset: var(--draw-from, 100); }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Water Log — circular progress for the success ring. */
|
||||||
|
@keyframes water-ring {
|
||||||
|
from { stroke-dashoffset: var(--ring-from, 360); }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduce motion: respect the user's OS-level preference. */
|
/* Reduce motion: respect the user's OS-level preference. */
|
||||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||||
* but flattens everything that fights vestibular comfort. This block wipes:
|
* but flattens everything that fights vestibular comfort. This block wipes:
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Public `/water` route — entrypoint for the mobile-first Tuxedo
|
||||||
|
* Water Log PIN-gated experience.
|
||||||
|
*
|
||||||
|
* As of 2026-07, the route renders `WaterFieldClient`, which now
|
||||||
|
* delegates to the Apple-HIG styled `MobileWaterApp` state machine
|
||||||
|
* (PIN → Headgates → Log → Success). All screens are designed
|
||||||
|
* for phones and tablets in the field — there is no desktop
|
||||||
|
* consideration.
|
||||||
|
*
|
||||||
|
* `display: "standalone"` + `viewportFit: "cover"` in the root
|
||||||
|
* layout, combined with the mobile-specific `<meta>` tags below,
|
||||||
|
* give this route a near-native feel when added to an iOS home
|
||||||
|
* screen as a PWA.
|
||||||
|
*/
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
import WaterFieldClient from "@/components/water/WaterFieldClient";
|
import WaterFieldClient from "@/components/water/WaterFieldClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Water Log · Tuxedo Ditch Co.",
|
||||||
|
description:
|
||||||
|
"Field log of headgate water readings — PIN-protected, mobile-only.",
|
||||||
|
robots: {
|
||||||
|
index: false, // PIN-gated field tool — no public indexing
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Water Log",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The water route is mobile-only — never scale above 1.0 (we don't
|
||||||
|
// want desktop users pinching-and-zooming a tool that's tuned for
|
||||||
|
// thumb-reach). The viewport-fit: cover setting in the root layout
|
||||||
|
// enables safe-area insets on iOS.
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
themeColor: "#14532d",
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function WaterPage() {
|
export default async function WaterPage() {
|
||||||
return <WaterFieldClient />;
|
return <WaterFieldClient />;
|
||||||
}
|
}
|
||||||
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