Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s

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.
This commit is contained in:
Tyler
2026-07-06 10:32:36 -06:00
parent 5188960bd2
commit 5dc9a4604f
10 changed files with 577 additions and 96 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import type { Metadata, Viewport } from "next";
import WaterFieldClient from "@/components/water/WaterFieldClient"; import WaterFieldClient from "@/components/water/WaterFieldClient";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Water Log · Tuxedo Ditch Co.", title: "Water Log · Tuxedo Corn",
description: description:
"Field log of headgate water readings — PIN-protected, mobile-only.", "Field log of headgate water readings — PIN-protected, mobile-only.",
robots: { robots: {
@@ -158,7 +158,7 @@ function TodaySummary({
{/* Hero gauge */} {/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70"> <div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Ditch Co. Tuxedo Corn
</div> </div>
<WaterGauge <WaterGauge
size={120} size={120}
+9 -4
View File
@@ -200,14 +200,19 @@ export function FieldPinScreen({
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy // `100dvh` excludes iOS Safari's collapsing chrome; the legacy
// `100vh` fallback keeps Android/older browsers full-bleed too. // `100vh` fallback keeps Android/older browsers full-bleed too.
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]"> <div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
{/* ── Branded header ───────────────────────────────────────── */} {/* ── Branded header (Apple HIG liquid glass) ───────────────── */}
<header <header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]" className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{ style={{
// Fades the chrome into the form below so the brand strap
// feels suspended in the air rather than locked to the
// top — same trick Apple uses on the iOS lock screen.
background: background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)", "linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
}} }}
> >
<div className="mx-auto flex max-w-md items-center justify-center gap-2"> <div className="mx-auto flex max-w-md items-center justify-center gap-2">
+12 -4
View File
@@ -63,14 +63,22 @@ export function MobileWaterHeadgateList({
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
}} }}
> >
{/* ── Top nav ──────────────────────────────────────────────── */} {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header <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" className="sticky top-0 z-30 flex h-11 items-center justify-between px-2"
style={{ style={{
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))", height: "calc(44px + env(safe-area-inset-top))",
backdropFilter: "blur(20px) saturate(180%)", // Gradient + saturate(180%) reads as "liquid glass" — the
WebkitBackdropFilter: "blur(20px) saturate(180%)", // gold standard iOS Settings / Notes / Mail chrome uses
// the same recipe. Single shadow with two insets handles
// the top highlight + bottom hairline in one rule.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}} }}
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
+40 -27
View File
@@ -26,12 +26,12 @@
*/ */
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { SegmentedControl } from "./SegmentedControl"; import { SegmentedControl } from "./SegmentedControl";
import { ThresholdMeter } from "./ThresholdMeter";
import type { FieldHeadgate, MobileUnit } from "./types"; import type { FieldHeadgate, MobileUnit } from "./types";
import { isIntegerUnit, MOBILE_UNITS } from "./types"; import { isIntegerUnit, MOBILE_UNITS } from "./types";
import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons"; import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons";
@@ -101,19 +101,10 @@ export function MobileWaterLogForm({
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
// ── Threshold hint derived from the headgate. ──────────────────── // ── Threshold hint is now rendered by <ThresholdMeter /> below —
const thresholdHint = useMemo(() => { // the meter derives its own zone state from the live value +
const h = headgate.high_threshold; // headgate thresholds, so the form no longer needs a
const l = headgate.low_threshold; // useMemo for the static caption. ─────────────────────
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]);
const submitted = (e?: FormEvent) => { const submitted = (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -165,13 +156,21 @@ export function MobileWaterLogForm({
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
}} }}
> >
{/* ── Top nav ────────────────────────────────────────────── */} {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header <header
className="sticky top-0 z-30 flex items-center gap-2 border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2 py-2" className="sticky top-0 z-30 flex items-center gap-2 px-2 py-2"
style={{ style={{
paddingTop: "calc(env(safe-area-inset-top) + 4px)", paddingTop: "calc(env(safe-area-inset-top) + 4px)",
backdropFilter: "blur(20px) saturate(180%)", // Mirror the headgate-list chrome so the worker feels the
WebkitBackdropFilter: "blur(20px) saturate(180%)", // same shell as they navigate. Gradient + saturate(180%)
// gives the modern iOS look; insets deliver the highlight
// and hairline in one rule.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}} }}
> >
<button <button
@@ -290,11 +289,16 @@ export function MobileWaterLogForm({
{unitLabel} {unitLabel}
</span> </span>
</div> </div>
{thresholdHint && ( {/* Live threshold meter — the screen's signature. Shows
<p className="mt-3 border-t border-[rgba(60,60,67,0.10)] pt-3 text-[13px] text-[rgba(60,60,67,0.6)]"> * the entered value's position against the headgate's
{thresholdHint} * configured low/high thresholds, with a color-coded
</p> * thumb and zone label. ──────────────────────────── */}
)} <ThresholdMeter
value={measurement}
unit={unit}
lowThreshold={headgate.low_threshold}
highThreshold={headgate.high_threshold}
/>
</div> </div>
{/* Unit segmented control — labels use the localized {/* Unit segmented control — labels use the localized
@@ -374,13 +378,22 @@ export function MobileWaterLogForm({
)} )}
</div> </div>
{/* ── 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. */}
<div <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" className="sticky bottom-0 z-20 px-4 py-3"
style={{ style={{
paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)", paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)",
backdropFilter: "blur(20px) saturate(180%)", background:
WebkitBackdropFilter: "blur(20px) saturate(180%)", "linear-gradient(0deg, rgba(255,255,255,0.65) 0%, rgba(255,255,255,0.88) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(0,0,0,0.06), inset 0 -0.5px 0 0 rgba(255,255,255,1)",
}} }}
> >
<button <button
+156 -42
View File
@@ -36,7 +36,14 @@
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4) * - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
* - `logoutTimeTracking` → clears the time-tracking cookie on logout * - `logoutTimeTracking` → clears the time-tracking cookie on logout
*/ */
import { useCallback, useEffect, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { import {
verifyWaterPin, verifyWaterPin,
submitWaterEntry, submitWaterEntry,
@@ -353,11 +360,18 @@ export function MobileWaterApp() {
); );
} }
// ── TabBar (Cycle 4) ─────────────────────────────────────────── // ── TabBar (Cycle 4 → Apple HIG pass) ──────────────────────────
// Sticky two-tab top nav for the Tuxedo worker experience. // Sticky top nav for the Tuxedo worker experience. Stays visible
// Stays visible across Headgates → Log → Success AND on the Time // across Headgates → Log → Success AND on the Time tab.
// tab. Extracted as a presentational component (no server actions, //
// no effects) so it's pure and easy to iterate on visually. // Two HIG principles in play:
// 1. Use UISegmentedControl for the mode switch (not two pills).
// The control is a true iOS segmented control: gray track,
// sliding white pill, spring timing. The same widget appears
// as the unit selector inside the log form, just bigger.
// 2. Use a "liquid glass" chrome on the bar itself: gradient
// background, strong saturate(180%), inner top highlight, and
// a hairline at the bottom edge — not a flat colored band.
function TabBar({ function TabBar({
activeTab, activeTab,
onChange, onChange,
@@ -372,41 +386,35 @@ function TabBar({
const t = useLang().t; const t = useLang().t;
return ( return (
<div <div
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur" className="sticky top-0 z-30 flex items-center justify-between px-3"
role="tablist" style={{
aria-label="Worker navigation" paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
// "Liquid glass": top catches more light than the bottom,
// so the gradient runs brighter at the top and slightly
// more transparent below. Saturation boost makes any
// background read with a hint of brand warmth as the
// worker scrolls headgates past the bar.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
// Inner highlight + hairline (one box-shadow with two
// insets reads cheaper than separate pseudo-elements).
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
> >
<div className="flex items-center gap-2"> <MiniSegmented
<button ariaLabel="Worker navigation"
type="button" value={activeTab}
role="tab" onChange={onChange}
aria-selected={activeTab === "headgates"} options={[
onClick={() => onChange("headgates")} { value: "headgates", label: t.tab.headgates },
className={ { value: "time", label: t.tab.time },
"rounded-full px-3 py-1 text-[13px] font-semibold transition " + ]}
(activeTab === "headgates" />
? "bg-[#1a4d2e] text-white shadow-sm" <div className="flex items-center gap-1.5 pr-0.5">
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.headgates}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "time"}
onClick={() => onChange("time")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "time"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.time}
</button>
</div>
<div className="flex items-center gap-2">
{irrigatorName && ( {irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline"> <span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName} {irrigatorName}
@@ -415,8 +423,8 @@ function TabBar({
<button <button
type="button" type="button"
onClick={onLogout} onClick={onLogout}
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]" aria-label={t.tab.logout}
aria-label="Log out" className="rounded-full px-2.5 py-1 text-[13px] font-semibold text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
> >
{t.tab.logout} {t.tab.logout}
</button> </button>
@@ -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<V extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: ReadonlyArray<{ value: V; label: ReactNode }>;
value: V;
onChange: (next: V) => void;
ariaLabel: string;
}) {
const containerRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(
`[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 (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className="relative inline-flex h-8 items-stretch rounded-[9px] p-[2px]"
style={{ background: "rgba(120,120,128,0.20)" }}
>
{rect && (
<div
aria-hidden
className="absolute rounded-[7px] bg-white"
style={{
top: 2,
bottom: 2,
left: 2,
width: rect.w,
transform: `translateX(${rect.x}px)`,
boxShadow:
"0 3px 8px rgba(0,0,0,0.06), 0 3px 1px rgba(0,0,0,0.04), inset 0 -0.5px 0 0 rgba(0,0,0,0.04), inset 0 0.5px 0 0 rgba(255,255,255,1)",
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}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 select-none px-3 text-[13px] font-semibold",
"flex items-center justify-center",
"min-w-[78px] active:opacity-80",
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.65)]",
].join(" ")}
style={{
height: 28,
transition:
"color 220ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
);
}
export default MobileWaterApp; export default MobileWaterApp;
@@ -73,14 +73,17 @@ export function MobileWaterSuccessScreen({
paddingBottom: "env(safe-area-inset-bottom)", 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. */}
<header <header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]" className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{ style={{
background: background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)", "linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
}} }}
> >
<div className="mx-auto flex max-w-md items-center justify-center gap-2"> <div className="mx-auto flex max-w-md items-center justify-center gap-2">
@@ -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 (
<div className="mt-4 border-t border-[rgba(60,60,67,0.08)] pt-3">
{/* ── Track + zones + thumb ──────────────────────────── */}
<div className="relative mx-1.5 h-[6px]">
{/* 1. Neutral track */}
<div
aria-hidden
className="absolute inset-0 rounded-full"
style={{ background: COLOR_TRACK }}
/>
{/* 2a. Below-low zone */}
{hasThresholds && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: 0,
width: `${lowPct * 100}%`,
background: COLOR_ALERT,
opacity: 0.85,
}}
/>
)}
{/* 2b. Normal zone */}
{hasThresholds && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: `${lowPct * 100}%`,
width: `${Math.max(0, (highPct - lowPct) * 100)}%`,
background: COLOR_NORMAL,
opacity: 0.85,
}}
/>
)}
{/* 2c. Above-high zone */}
{hasThresholds && highThreshold != null && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: `${highPct * 100}%`,
width: `${Math.max(0, (1 - highPct) * 100)}%`,
background: COLOR_ALERT,
opacity: 0.85,
}}
/>
)}
{/* 3. Thumb */}
{hasValue && (
<div
aria-hidden
className="absolute"
style={{
top: "50%",
left: `${markerPct * 100}%`,
transform: "translate(-50%, -50%)",
width: 18,
height: 18,
borderRadius: 9999,
background: "white",
border:
zone === "low" || zone === "high"
? `3px solid ${COLOR_ALERT}`
: zone === "normal"
? `3px solid ${COLOR_NORMAL}`
: "3px solid rgba(60,60,67,0.6)",
boxShadow:
"0 2px 5px rgba(0,0,0,0.12), 0 0 0 0.5px rgba(0,0,0,0.06)",
transition:
"left 240ms cubic-bezier(0.32, 0.72, 0, 1), border-color 200ms ease-out",
}}
/>
)}
</div>
{/* ── Caption (zone label + scale range) ─────────────── */}
<div className="mt-2.5 flex items-baseline justify-between gap-3">
<span
className="truncate text-[12px] font-semibold tabular-nums"
style={{
color: leftColor,
transition: "color 220ms ease-out",
}}
>
{leftLabel}
{formattedReading && (
<>
<span className="mx-1.5 font-normal opacity-50">·</span>
<span className="tabular-nums">{formattedReading}</span>
</>
)}
</span>
{rightLabel && zone === "normal" && (
<span className="shrink-0 text-[11px] tabular-nums text-[rgba(60,60,67,0.5)]">
{rightLabel}
</span>
)}
</div>
</div>
);
}
export default ThresholdMeter;
+47 -7
View File
@@ -102,9 +102,29 @@ export type Labels = {
submitError: string; submitError: string;
/** Defensive guard — shouldn't ever happen. */ /** Defensive guard — shouldn't ever happen. */
noHeadgateSelected: string; noHeadgateSelected: string;
normalRange: (low: number, high: number, unit: string) => string; /** Accepts pre-formatted strings so callers can render the same
alertAbove: (high: number, unit: string) => string; * shape (integer-cleaned, locale-aware) the meter uses. */
alertBelow: (low: number, unit: string) => string; 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. */ /** `/water` success screen after submit. */
success: { success: {
@@ -208,8 +228,18 @@ export const LABELS: Record<Lang, Labels> = {
noHeadgateSelected: "Pick a headgate first.", noHeadgateSelected: "Pick a headgate first.",
normalRange: (low, high, unit) => normalRange: (low, high, unit) =>
`Normal range: ${low} ${high} ${unit}`, `Normal range: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alert above ${high} ${unit}`, alertAbove: (value, unit) => `Alert above ${value} ${unit}`,
alertBelow: (low, unit) => `Alert below ${low} ${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: { success: {
title: "Reading logged", title: "Reading logged",
@@ -305,8 +335,18 @@ export const LABELS: Record<Lang, Labels> = {
noHeadgateSelected: "Elige una compuerta primero.", noHeadgateSelected: "Elige una compuerta primero.",
normalRange: (low, high, unit) => normalRange: (low, high, unit) =>
`Rango normal: ${low} ${high} ${unit}`, `Rango normal: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`, alertAbove: (value, unit) => `Alerta arriba de ${value} ${unit}`,
alertBelow: (low, unit) => `Alerta abajo de ${low} ${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: { success: {
title: "Lectura registrada", title: "Lectura registrada",
+6 -6
View File
@@ -252,21 +252,21 @@ describe("Mobile label helpers (interpolation)", () => {
}); });
it("log.normalRange interpolates the bounds + unit", () => { 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", "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", "Rango normal: 2 8 CFS",
); );
}); });
it("log.alertAbove / alertBelow interpolate single bound + unit", () => { it("log.alertAbove / alertBelow interpolate single bound + unit", () => {
expect(LABELS.en.log.alertAbove(10, "CFS")).toBe("Alert above 10 CFS"); expect(LABELS.en.log.alertAbove("10", "CFS")).toBe("Alert above 10 CFS");
expect(LABELS.es.log.alertAbove(10, "CFS")).toBe( expect(LABELS.es.log.alertAbove("10", "CFS")).toBe(
"Alerta arriba de 10 CFS", "Alerta arriba de 10 CFS",
); );
expect(LABELS.en.log.alertBelow(1, "CFS")).toBe("Alert below 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"); expect(LABELS.es.log.alertBelow("1", "CFS")).toBe("Alerta abajo de 1 CFS");
}); });
it("success.loggedBy interpolates the irrigator name", () => { it("success.loggedBy interpolates the irrigator name", () => {