From 5dc9a4604f562bc06b6d0026bdf2349176f1d696 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 6 Jul 2026 10:32:36 -0600 Subject: [PATCH] Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Water log mobile field experience (PIN → Headgates → Log → Success): * Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it was a stale reference (page metadata + admin Today tab caption). Constants already say Tuxedo Corn; this just aligns the stragglers. * Tab bar (Headgates | Time): pill pair → real UISegmentedControl. MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses the same ResizeObserver-measure + spring-timing pattern as the existing SegmentedControl — feels like one widget at two sizes (this one at 32pt, the unit selector at 44pt). * Liquid-glass chrome (recipe applied 5×, mirrored on the bottom submit bar): linear-gradient background + blur(24px) + saturate(180%) + inset top highlight + inset bottom hairline. TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky submit bar, FieldPinScreen + SuccessScreen brand strips. * ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the screen's signature. A live "blip" indicator under the 44pt measurement input that ties the form to its real domain (a worker reading a real gauge) instead of being a decorative gauge trophy. Track + optional colored zones (red·green·red) + thumb that slides on Apple's spring curve + color-coded zone label. Mirrors Apple's Volume Limit / Screen Time sliders. * i18n: 9 new zone labels per language (zoneBelowNormal, zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin, zoneReading, zoneTypeToLog, meterNormalRange + ES twins). Legacy normalRange/alertAbove/alertBelow signatures widened to accept pre-formatted strings so integer units render clean. * Updated water-log-i18n unit test for the wider signatures. All 27 tests pass; tsc + eslint clean. --- src/app/water/page.tsx | 2 +- .../admin/water-log/tabs/TodayTab.tsx | 2 +- src/components/field/FieldPinScreen.tsx | 13 +- src/components/water/mobile/HeadgateList.tsx | 16 +- src/components/water/mobile/LogForm.tsx | 67 ++-- .../water/mobile/MobileWaterApp.tsx | 198 +++++++++--- src/components/water/mobile/SuccessScreen.tsx | 11 +- .../water/mobile/ThresholdMeter.tsx | 298 ++++++++++++++++++ src/lib/water-log/i18n.ts | 54 +++- tests/unit/water-log-i18n.test.ts | 12 +- 10 files changed, 577 insertions(+), 96 deletions(-) create mode 100644 src/components/water/mobile/ThresholdMeter.tsx diff --git a/src/app/water/page.tsx b/src/app/water/page.tsx index fd23f1f..c153e86 100644 --- a/src/app/water/page.tsx +++ b/src/app/water/page.tsx @@ -17,7 +17,7 @@ import type { Metadata, Viewport } from "next"; import WaterFieldClient from "@/components/water/WaterFieldClient"; export const metadata: Metadata = { - title: "Water Log · Tuxedo Ditch Co.", + title: "Water Log · Tuxedo Corn", description: "Field log of headgate water readings — PIN-protected, mobile-only.", robots: { diff --git a/src/components/admin/water-log/tabs/TodayTab.tsx b/src/components/admin/water-log/tabs/TodayTab.tsx index 2a8c425..feb50be 100644 --- a/src/components/admin/water-log/tabs/TodayTab.tsx +++ b/src/components/admin/water-log/tabs/TodayTab.tsx @@ -158,7 +158,7 @@ function TodaySummary({ {/* Hero gauge */}
- Tuxedo Ditch Co. + Tuxedo Corn
- {/* ── Branded header ───────────────────────────────────────── */} + {/* ── Branded header (Apple HIG liquid glass) ───────────────── */}
diff --git a/src/components/water/mobile/HeadgateList.tsx b/src/components/water/mobile/HeadgateList.tsx index cdedea5..d54c764 100644 --- a/src/components/water/mobile/HeadgateList.tsx +++ b/src/components/water/mobile/HeadgateList.tsx @@ -63,14 +63,22 @@ export function MobileWaterHeadgateList({ paddingTop: "env(safe-area-inset-top)", }} > - {/* ── Top nav ──────────────────────────────────────────────── */} + {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
diff --git a/src/components/water/mobile/LogForm.tsx b/src/components/water/mobile/LogForm.tsx index 6474244..d45876a 100644 --- a/src/components/water/mobile/LogForm.tsx +++ b/src/components/water/mobile/LogForm.tsx @@ -26,12 +26,12 @@ */ import { useEffect, - useMemo, useRef, useState, type FormEvent, } from "react"; import { SegmentedControl } from "./SegmentedControl"; +import { ThresholdMeter } from "./ThresholdMeter"; import type { FieldHeadgate, MobileUnit } from "./types"; import { isIntegerUnit, MOBILE_UNITS } from "./types"; import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons"; @@ -101,19 +101,10 @@ export function MobileWaterLogForm({ return () => window.clearInterval(id); }, []); - // ── Threshold hint derived from the headgate. ──────────────────── - const thresholdHint = useMemo(() => { - const h = headgate.high_threshold; - const l = headgate.low_threshold; - const u = formatUnit(unit, lang); - if (h == null && l == null) return null; - if (h != null && l != null) { - return t.log.normalRange(l, h, u); - } - if (h != null) return t.log.alertAbove(h, u); - // TS narrowing: reaching this line means `h == null && l != null`. - return t.log.alertBelow(l as number, u); - }, [headgate, unit, lang, t]); + // ── Threshold hint is now rendered by below — + // the meter derives its own zone state from the live value + + // headgate thresholds, so the form no longer needs a + // useMemo for the static caption. ───────────────────── const submitted = (e?: FormEvent) => { e?.preventDefault(); @@ -165,13 +156,21 @@ export function MobileWaterLogForm({ paddingTop: "env(safe-area-inset-top)", }} > - {/* ── Top nav ────────────────────────────────────────────── */} + {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
- {thresholdHint && ( -

- {thresholdHint} -

- )} + {/* Live threshold meter — the screen's signature. Shows + * the entered value's position against the headgate's + * configured low/high thresholds, with a color-coded + * thumb and zone label. ──────────────────────────── */} +
{/* Unit segmented control — labels use the localized @@ -374,13 +378,22 @@ export function MobileWaterLogForm({ )}
- {/* ── Sticky submit bar ───────────────────────────────── */} + {/* ── Sticky submit bar (Apple HIG liquid glass) ──────────── */} + {/* Gradient runs the OPPOSITE direction from the top nav — + brighter where it meets the form (top edge), slightly + more transparent where it flows into the safe area. + The hairline lives on the top edge of the bar so it + reads as a divider, not a frame. */}
- -
-
+ +
{irrigatorName && ( {irrigatorName} @@ -415,8 +423,8 @@ function TabBar({ @@ -425,4 +433,110 @@ function TabBar({ ); } +// ── MiniSegmented (nav-bar-sized UISegmentedControl) ────────────── +// Sliding-pill variant of SegmentedControl tuned for the 32pt nav +// bar context. Reuses the same measurement + spring-timing approach +// as the larger control in `SegmentedControl.tsx` so the two feel +// like one widget at different sizes. +function MiniSegmented({ + options, + value, + onChange, + ariaLabel, +}: { + options: ReadonlyArray<{ value: V; label: ReactNode }>; + value: V; + onChange: (next: V) => void; + ariaLabel: string; +}) { + const containerRef = useRef(null); + // Sliding indicator rect. The indicator uses + // `transform: translateX(...)` with Apple's spring curve so the + // transition reads as "physical," not "fade." + const [rect, setRect] = useState<{ x: number; w: number } | null>(null); + + const measure = useCallback(() => { + const root = containerRef.current; + if (!root) return; + const btn = root.querySelector( + `[data-seg-key="${value}"]`, + ); + if (!btn) return; + const rootRect = root.getBoundingClientRect(); + const btnRect = btn.getBoundingClientRect(); + // `x` is measured from the container's content edge (after the + // 2px padding) so the indicator rides just inside the track. + setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width }); + }, [value]); + + useLayoutEffect(() => { + measure(); + }, [measure]); + + // Re-measure whenever the option set changes (e.g. labels + // translate via the Lang context and the active segment width + // shifts) so the indicator stays in place. + useLayoutEffect(() => { + const root = containerRef.current; + if (!root) return; + const ro = new ResizeObserver(measure); + ro.observe(root); + return () => ro.disconnect(); + }, [measure]); + + return ( +
+ {rect && ( +
+ )} + {options.map((opt) => { + const selected = opt.value === value; + return ( + + ); + })} +
+ ); +} + export default MobileWaterApp; \ No newline at end of file diff --git a/src/components/water/mobile/SuccessScreen.tsx b/src/components/water/mobile/SuccessScreen.tsx index 5a146eb..fe80e71 100644 --- a/src/components/water/mobile/SuccessScreen.tsx +++ b/src/components/water/mobile/SuccessScreen.tsx @@ -73,14 +73,17 @@ export function MobileWaterSuccessScreen({ paddingBottom: "env(safe-area-inset-bottom)", }} > - {/* Brand strip — same as PIN screen for visual continuity. */} + {/* Brand strip — same liquid-glass recipe as the PIN screen so + the success state feels like a sibling, not a stranger. */}
diff --git a/src/components/water/mobile/ThresholdMeter.tsx b/src/components/water/mobile/ThresholdMeter.tsx new file mode 100644 index 0000000..a59b23e --- /dev/null +++ b/src/components/water/mobile/ThresholdMeter.tsx @@ -0,0 +1,298 @@ +"use client"; + +/** + * ThresholdMeter — a live "blip" indicator under the 44pt + * measurement input that ties the form to its real domain (a + * worker reading a real gauge) instead of being a decorative + * gauge trophy. + * + * This is the screen's signature element. It mirrors Apple's + * Volume Limit / Screen Time sliders: a neutral track with + * colored zones (red → green → red) and a white thumb with a + * colored ring that slides horizontally as the worker types. + * + * Visual layering, back to front: + * 1. Neutral track (iOS systemGray, 6px tall, fully rounded). + * 2. Optional colored zones (only when thresholds are set): + * below-low · between low-high · above-high. + * 3. Thumb (18px white circle with a 3px colored ring) that + * uses `transform: translateX(...)` with Apple's standard + * spring curve, so the motion reads as physical. + * 4. Two-line status caption: left = color-coded zone label, + * right = numeric scale (monospaced). + * + * Auto-grows the visual scale to fit the entered value when no + * thresholds are configured, so the bar still has a usable + * range even on first-run headgates without admin-configured + * limits. + */ + +import { useMemo } from "react"; +import { formatMeasurement, formatUnit } from "./format"; +import { isIntegerUnit } from "./types"; +import { useLang } from "@/lib/water-log/lang-context"; + +type Zone = "none" | "low" | "normal" | "high"; + +type Props = { + /** Raw input value (string from the input). Empty → bar shows no thumb. */ + value: string; + /** Selected unit. Captions + scale thumb reposition on changes. */ + unit: string; + /** Headgate's configured low threshold, or null. */ + lowThreshold: number | null; + /** Headgate's configured high threshold, or null. */ + highThreshold: number | null; +}; + +// Apple system colors — pulled from HIG. Reusing the same palette +// as the success button (systemGreen) and the error toast +// (systemRed) so the meter's color language is already familiar +// to anyone who's used the rest of the app. +const COLOR_TRACK = "rgba(120,120,128,0.16)"; +const COLOR_NORMAL = "#34C759"; +const COLOR_ALERT = "#FF3B30"; + +export function ThresholdMeter({ + value, + unit, + lowThreshold, + highThreshold, +}: Props) { + const { t, lang } = useLang(); + + const numeric = parseFloat(value); + const hasValue = + value.trim().length > 0 && Number.isFinite(numeric) && numeric >= 0; + const integerUnit = isIntegerUnit(unit); + const unitLabel = formatUnit(unit, lang); + const hasThresholds = lowThreshold != null || highThreshold != null; + + // ── Visual scale (max value the track represents) ──────── + // Grow to whichever is largest: 1.5× the high threshold (so + // there's always a "tail" of alert zone past the alert line), + // 2× the low threshold (so the in-range area starts before + // the low edge — symmetric on either side), or 1.3× the + // entered value (so the thumb isn't pinned at the right edge + // for the typical first entry). + const maxScale = useMemo(() => { + const seed = hasValue ? numeric : 0; + if (highThreshold != null && lowThreshold != null) { + return Math.max(highThreshold * 1.5, lowThreshold * 0.6, seed * 1.2, 10); + } + if (highThreshold != null) { + return Math.max(highThreshold * 1.5, seed * 1.2, 10); + } + if (lowThreshold != null) { + return Math.max(lowThreshold * 1.8, seed * 1.3, 10); + } + return Math.max(seed * 1.3, 10); + }, [hasValue, numeric, lowThreshold, highThreshold]); + + // ── Marker position & zone fills ─────────────────────── + const markerPct = hasValue + ? Math.min(1, Math.max(0, numeric / maxScale)) + : 0; + + const lowPct = + lowThreshold != null + ? Math.min(1, Math.max(0, lowThreshold / maxScale)) + : 0; + const highPct = + highThreshold != null + ? Math.min(1, Math.max(0, highThreshold / maxScale)) + : 1; + + // ── Zone classification ───────────────────────────────── + const zone: Zone = !hasValue + ? "none" + : lowThreshold != null && numeric < lowThreshold + ? "low" + : highThreshold != null && numeric > highThreshold + ? "high" + : "normal"; + + // ── Captions ──────────────────────────────────────────── + const displayValue = hasValue + ? formatMeasurement(numeric, integerUnit) + : null; + + let leftLabel: string; + let leftColor: string; + let rightLabel: string | null; + + if (!hasValue) { + if (hasThresholds && lowThreshold != null && highThreshold != null) { + leftLabel = t.log.meterNormalRange( + formatMeasurement(lowThreshold, integerUnit), + formatMeasurement(highThreshold, integerUnit), + unitLabel, + ); + } else if (highThreshold != null) { + leftLabel = t.log.alertAbove( + formatMeasurement(highThreshold, integerUnit), + unitLabel, + ); + } else if (lowThreshold != null) { + leftLabel = t.log.alertBelow( + formatMeasurement(lowThreshold, integerUnit), + unitLabel, + ); + } else { + leftLabel = t.log.zoneTypeToLog; + } + leftColor = "rgba(60,60,67,0.5)"; + rightLabel = null; + } else if (zone === "low") { + leftLabel = t.log.zoneBelowNormal; + leftColor = COLOR_ALERT; + rightLabel = t.log.alertBelow( + formatMeasurement(lowThreshold as number, integerUnit), + unitLabel, + ); + } else if (zone === "high") { + leftLabel = t.log.zoneAboveAlert; + leftColor = COLOR_ALERT; + rightLabel = t.log.alertAbove( + formatMeasurement(highThreshold as number, integerUnit), + unitLabel, + ); + } else if (lowThreshold != null && highThreshold != null) { + leftLabel = t.log.zoneInRange; + leftColor = COLOR_NORMAL; + rightLabel = t.log.meterNormalRange( + formatMeasurement(lowThreshold, integerUnit), + formatMeasurement(highThreshold, integerUnit), + unitLabel, + ); + } else if (highThreshold != null) { + leftLabel = t.log.zoneInRange; + leftColor = COLOR_NORMAL; + rightLabel = t.log.alertAbove( + formatMeasurement(highThreshold, integerUnit), + unitLabel, + ); + } else if (lowThreshold != null) { + leftLabel = t.log.zoneAboveMin; + leftColor = COLOR_NORMAL; + rightLabel = t.log.alertBelow( + formatMeasurement(lowThreshold, integerUnit), + unitLabel, + ); + } else { + leftLabel = t.log.zoneReading; + leftColor = "rgba(60,60,67,0.85)"; + rightLabel = null; + } + + // Append "· {value} {unit}" to the left label only when the + // zone label is meaningful on its own. That keeps the empty / + // no-threshold states readable (no trailing "· undefined CFS"). + const formattedReading = hasValue + ? `${displayValue} ${unitLabel}` + : null; + + return ( +
+ {/* ── Track + zones + thumb ──────────────────────────── */} +
+ {/* 1. Neutral track */} +
+ {/* 2a. Below-low zone */} + {hasThresholds && ( +
+ )} + {/* 2b. Normal zone */} + {hasThresholds && ( +
+ )} + {/* 2c. Above-high zone */} + {hasThresholds && highThreshold != null && ( +
+ )} + {/* 3. Thumb */} + {hasValue && ( +
+ )} +
+ {/* ── Caption (zone label + scale range) ─────────────── */} +
+ + {leftLabel} + {formattedReading && ( + <> + · + {formattedReading} + + )} + + {rightLabel && zone === "normal" && ( + + {rightLabel} + + )} +
+
+ ); +} + +export default ThresholdMeter; diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts index a0ca81b..3d3f736 100644 --- a/src/lib/water-log/i18n.ts +++ b/src/lib/water-log/i18n.ts @@ -102,9 +102,29 @@ export type Labels = { submitError: string; /** Defensive guard — shouldn't ever happen. */ noHeadgateSelected: string; - normalRange: (low: number, high: number, unit: string) => string; - alertAbove: (high: number, unit: string) => string; - alertBelow: (low: number, unit: string) => string; + /** Accepts pre-formatted strings so callers can render the same + * shape (integer-cleaned, locale-aware) the meter uses. */ + normalRange: (low: string, high: string, unit: string) => string; + alertAbove: (value: string, unit: string) => string; + alertBelow: (value: string, unit: string) => string; + // ThresholdMeter zone labels. Names only — the value + unit are + // rendered beside each label so we don't have to re-localize + // the variables. Strings live here so translators see them in + // context with the rest of `log.*` instead of under a separate + // namespace that nobody updates. + zoneBelowNormal: string; + zoneAboveAlert: string; + zoneInRange: string; + /** Only a `low` threshold configured; value is below it. */ + zoneBelowAlert: string; + /** Only a `low` threshold configured; value is above it. */ + zoneAboveMin: string; + /** Value entered, but no thresholds configured. */ + zoneReading: string; + /** Empty value, no thresholds configured. */ + zoneTypeToLog: string; + /** Compact "Normal 2.5 – 8 CFS" form for the meter's right side. */ + meterNormalRange: (low: string, high: string, unit: string) => string; }; /** `/water` success screen after submit. */ success: { @@ -208,8 +228,18 @@ export const LABELS: Record = { noHeadgateSelected: "Pick a headgate first.", normalRange: (low, high, unit) => `Normal range: ${low} – ${high} ${unit}`, - alertAbove: (high, unit) => `Alert above ${high} ${unit}`, - alertBelow: (low, unit) => `Alert below ${low} ${unit}`, + alertAbove: (value, unit) => `Alert above ${value} ${unit}`, + alertBelow: (value, unit) => `Alert below ${value} ${unit}`, + // ThresholdMeter zone labels — see type def above. + zoneBelowNormal: "Below normal", + zoneAboveAlert: "Above alert", + zoneInRange: "In range", + zoneBelowAlert: "Below alert", + zoneAboveMin: "Above minimum", + zoneReading: "Reading", + zoneTypeToLog: "Type a reading above", + meterNormalRange: (low, high, unit) => + `Normal ${low} – ${high} ${unit}`, }, success: { title: "Reading logged", @@ -305,8 +335,18 @@ export const LABELS: Record = { noHeadgateSelected: "Elige una compuerta primero.", normalRange: (low, high, unit) => `Rango normal: ${low} – ${high} ${unit}`, - alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`, - alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`, + alertAbove: (value, unit) => `Alerta arriba de ${value} ${unit}`, + alertBelow: (value, unit) => `Alerta abajo de ${value} ${unit}`, + // ThresholdMeter zone labels — Spanish. + zoneBelowNormal: "Bajo lo normal", + zoneAboveAlert: "Sobre alerta", + zoneInRange: "En rango", + zoneBelowAlert: "Bajo alerta", + zoneAboveMin: "Sobre el mínimo", + zoneReading: "Lectura", + zoneTypeToLog: "Ingresa una lectura arriba", + meterNormalRange: (low, high, unit) => + `Normal ${low} – ${high} ${unit}`, }, success: { title: "Lectura registrada", diff --git a/tests/unit/water-log-i18n.test.ts b/tests/unit/water-log-i18n.test.ts index 094b03f..13885e0 100644 --- a/tests/unit/water-log-i18n.test.ts +++ b/tests/unit/water-log-i18n.test.ts @@ -252,21 +252,21 @@ describe("Mobile label helpers (interpolation)", () => { }); it("log.normalRange interpolates the bounds + unit", () => { - expect(LABELS.en.log.normalRange(2, 8, "CFS")).toBe( + expect(LABELS.en.log.normalRange("2", "8", "CFS")).toBe( "Normal range: 2 – 8 CFS", ); - expect(LABELS.es.log.normalRange(2, 8, "CFS")).toBe( + expect(LABELS.es.log.normalRange("2", "8", "CFS")).toBe( "Rango normal: 2 – 8 CFS", ); }); it("log.alertAbove / alertBelow interpolate single bound + unit", () => { - expect(LABELS.en.log.alertAbove(10, "CFS")).toBe("Alert above 10 CFS"); - expect(LABELS.es.log.alertAbove(10, "CFS")).toBe( + expect(LABELS.en.log.alertAbove("10", "CFS")).toBe("Alert above 10 CFS"); + expect(LABELS.es.log.alertAbove("10", "CFS")).toBe( "Alerta arriba de 10 CFS", ); - expect(LABELS.en.log.alertBelow(1, "CFS")).toBe("Alert below 1 CFS"); - expect(LABELS.es.log.alertBelow(1, "CFS")).toBe("Alerta abajo de 1 CFS"); + expect(LABELS.en.log.alertBelow("1", "CFS")).toBe("Alert below 1 CFS"); + expect(LABELS.es.log.alertBelow("1", "CFS")).toBe("Alerta abajo de 1 CFS"); }); it("success.loggedBy interpolates the irrigator name", () => {