Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
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:
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user