ac88584b8b
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:
PIN -> Headgates -> Log -> Success
Design (Apple Human Interface Guidelines):
- System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
instead of the admin's Fraunces/Manrope for native iOS feel
- iOS systemGreen (#34C759) primary success, forest-900 brand accent
- iOS systemGroupedBackground (#F2F2F7) surface, white cards,
rgba(60,60,67,0.x) text greys
- 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
- env(safe-area-inset-top/bottom) respected on every screen
- 100dvh shell so iOS Safari chrome collapse doesn't reflow
Screens:
- PinScreen: 4 iOS-style digit boxes with blinking caret, big
'Unlock' button (disabled until 4 digits entered), branded
'TUXEDO WATER LOG' header, error shake on bad PIN
- HeadgateList: grouped list of large tappable cards showing
name + geocode (notes) + unit + last-logged relative time,
pull-to-refresh, empty state with refresh CTA
- LogForm: sticky headgate card + 44pt numeric input +
SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
+ live 'Logged now' timestamp + optional 500-char notes +
green Submit Log button with inline measurement/unit preview
- SuccessScreen: animated ring + checkmark draw-on, recap card,
'Log Another Entry' (green) and 'Back to Headgates' (secondary)
Reusable primitives (mobile/):
- SegmentedControl: iOS-style sliding pill with spring timing
- PullToRefresh: callback-based (no router.refresh) with resistance
- icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)
Backend:
- getWaterHeadgates extended to include notes (the 'geocode') and
last_used_at for richer card display
- No new routes, no DB migration - reuses verifyWaterPin,
submitWaterEntry, logoutWater server actions unchanged
Quality gates:
- tsc --noEmit clean
- eslint clean on all new files
- npm run build succeeds (/water rendered as static)
- prefers-reduced-motion respected (existing global guard)
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
"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; |