From 7795263944a58d1507ca5cb08ba8ecebee7c7f6e Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 2 Jul 2026 11:18:23 -0600 Subject: [PATCH] feat(water-log): add 'holes' unit + EN/ES language toggle for mobile - 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft with integer-only validation: submit button greys out and the keyboard raises the numeric keypad (no decimal) when the unit is selected. - Language toggle in the headgate list header (next to logout) flips every screen between English and Spanish. Choice is persisted to the existing 'wl_lang' cookie for 1y, so the user's last selection sticks across logout/relogin. - i18n strings lifted into shared src/lib/water-log/i18n.ts (single source of truth, en/es parity enforced by tests). All mobile screens (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error in the orchestrator consume the dictionary. Brand strip, aria-labels, segmented control labels, success recap, and submit-button labels all go through getT()/formatUnit()/displayUnit(). - 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es dict parity, holes catalog + hoyos translation, all the detectInitialLang() branches (SSR/cookie/navigator), setLangCookie read/write, label interpolation helpers, and the unit catalog. Refs /tmp/refactor-water-log.md (Commit B plan) --- src/components/water/mobile/HeadgateList.tsx | 63 ++-- src/components/water/mobile/LogForm.tsx | 100 ++++-- .../water/mobile/MobileWaterApp.tsx | 75 ++++- src/components/water/mobile/PinScreen.tsx | 24 +- src/components/water/mobile/SuccessScreen.tsx | 30 +- src/components/water/mobile/format.ts | 71 +++-- src/components/water/mobile/types.ts | 22 +- src/lib/water-log/i18n.ts | 43 +++ tests/unit/water-log-i18n.test.ts | 296 ++++++++++++++++++ 9 files changed, 613 insertions(+), 111 deletions(-) create mode 100644 tests/unit/water-log-i18n.test.ts diff --git a/src/components/water/mobile/HeadgateList.tsx b/src/components/water/mobile/HeadgateList.tsx index d6be925..cdedea5 100644 --- a/src/components/water/mobile/HeadgateList.tsx +++ b/src/components/water/mobile/HeadgateList.tsx @@ -29,7 +29,9 @@ import { Spinner, } from "./icons"; import { MobilePullToRefresh } from "./PullToRefresh"; -import { formatRelativeTime, headgateSubtitle } from "./format"; +import { formatRelativeTime, headgateSubtitle, formatUnit } from "./format"; +import { useLang } from "@/lib/water-log/lang-context"; +import { LanguageToggle } from "@/components/water/LanguageToggle"; type Props = { /** Optional display name of the logged-in irrigator (for the header). */ @@ -51,6 +53,7 @@ export function MobileWaterHeadgateList({ onRefresh, onLogout, }: Props) { + const { t, lang } = useLang(); const active = headgates.filter((h) => h.active); return ( @@ -84,16 +87,19 @@ export function MobileWaterHeadgateList({ "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - Headgates + {t.headgates.title} - +
+ + +
{/* ── Scrollable list ─────────────────────────────────────── */} @@ -101,7 +107,7 @@ export function MobileWaterHeadgateList({
{irrigatorName && (

- Signed in as {irrigatorName} + {t.headgates.signedInAs(irrigatorName)}

)} @@ -119,7 +125,7 @@ export function MobileWaterHeadgateList({ {/* Section header */}

- Active headgates + {t.headgates.activeSection}

{active.length} @@ -138,6 +144,7 @@ export function MobileWaterHeadgateList({ headgate={hg} isLast={i === active.length - 1} onClick={() => onSelect(hg)} + lang={lang} /> ))} @@ -147,7 +154,7 @@ export function MobileWaterHeadgateList({ {/* Footer note */} {active.length > 0 && (

- Pull down to refresh the list + {t.headgates.pullToRefresh}

)}
@@ -162,13 +169,17 @@ function HeadgateCard({ headgate, isLast, onClick, + lang, }: { headgate: FieldHeadgate; isLast: boolean; onClick: () => void; + lang: "en" | "es"; }) { - const subtitle = headgateSubtitle(headgate); - const lastUsed = formatRelativeTime(headgate.last_used_at); + const { t } = useLang(); + const subtitle = headgateSubtitle(headgate, lang); + const lastUsed = formatRelativeTime(headgate.last_used_at, lang); + const unitLabel = formatUnit(headgate.unit, lang); const isClosed = headgate.status === "closed"; const isMaintenance = headgate.status === "maintenance"; @@ -222,7 +233,8 @@ function HeadgateCard({ headgate.low_threshold != null) && ( )}
@@ -232,7 +244,7 @@ function HeadgateCard({
- {headgate.unit} + {unitLabel} · {lastUsed} @@ -249,9 +261,12 @@ function HeadgateCard({ function ThresholdDot({ high, + labelHigh, + labelLow, }: { high: number | null; - low: number | null; + labelHigh: string; + labelLow: string; }) { return ( - {high != null ? "Hi" : "Lo"} + {high != null ? labelHigh : labelLow} ); } @@ -299,6 +314,7 @@ function EmptyState({ onRefresh: () => Promise | void; refreshing: boolean; }) { + const { t } = useLang(); const handleRefresh = useCallback(async () => { if (!refreshing) await onRefresh(); }, [onRefresh, refreshing]); @@ -309,11 +325,10 @@ function EmptyState({

- No active headgates + {t.headgates.emptyTitle}

- Once your supervisor activates a headgate, it'll show up here. - Pull down to check for updates. + {t.headgates.emptyBody}

diff --git a/src/components/water/mobile/LogForm.tsx b/src/components/water/mobile/LogForm.tsx index fc07917..c571029 100644 --- a/src/components/water/mobile/LogForm.tsx +++ b/src/components/water/mobile/LogForm.tsx @@ -33,13 +33,15 @@ import { } from "react"; import { SegmentedControl } from "./SegmentedControl"; import type { FieldHeadgate, MobileUnit } from "./types"; -import { MOBILE_UNITS } from "./types"; +import { isIntegerUnit, MOBILE_UNITS } from "./types"; import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons"; import { formatLongTimestamp, formatMeasurement, + formatUnit, headgateSubtitle, } from "./format"; +import { useLang } from "@/lib/water-log/lang-context"; type Props = { headgate: FieldHeadgate; @@ -61,6 +63,7 @@ export function MobileWaterLogForm({ onLogout, units = MOBILE_UNITS, }: Props) { + const { t, lang } = useLang(); // ── State ──────────────────────────────────────────────────────── const [measurement, setMeasurement] = useState(""); const [unit, setUnit] = useState( @@ -72,6 +75,11 @@ export function MobileWaterLogForm({ const [now, setNow] = useState(() => new Date()); const measurementRef = useRef(null); + // True when the current unit is an integer count (e.g. "holes"). + // Drives the input's keyboard + step constraint + the recap + // formatting on the success screen. + const integerUnit = isIntegerUnit(unit); + // ── Auto-focus the measurement input on mount so the keyboard // rises immediately. useEffect(() => { @@ -91,24 +99,32 @@ export function MobileWaterLogForm({ 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 `Normal range: ${l} – ${h} ${unit}`; + return t.log.normalRange(l, h, u); } - if (h != null) return `Alert above ${h} ${unit}`; - return `Alert below ${l} ${unit}`; - }, [headgate, unit]); + 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) => { e?.preventDefault(); if (submitting) return; const value = parseFloat(measurement); if (!Number.isFinite(value) || value < 0) { - setError("Enter a measurement greater than or equal to 0"); + setError(t.log.measurementError); + return; + } + // Integer-count units (holes) reject fractional values — you + // can't have 2.5 holes. + if (integerUnit && !Number.isInteger(value)) { + setError(t.log.measurementError); return; } if (value > 1_000_000) { - setError("That value is implausibly large"); + setError(t.log.implausiblyLarge); return; } setSubmitting(true); @@ -116,18 +132,24 @@ export function MobileWaterLogForm({ onSubmit({ measurement: value, unit, notes }) .catch((err) => { setError( - err instanceof Error ? err.message : "Couldn't submit. Try again.", + err instanceof Error ? err.message : t.log.submitting, ); setSubmitting(false); }); // On success the parent swaps to the success screen. }; - const subtitle = headgateSubtitle(headgate); + const subtitle = headgateSubtitle(headgate, lang); + const unitLabel = formatUnit(unit, lang); + // Disable submit when the value wouldn't survive `submitted()`'s + // validation — including "holes" rejecting fractional values so + // the button stays grey until the user types a valid integer. + const parsed = parseFloat(measurement); const canSubmit = measurement.trim().length > 0 && - Number.isFinite(parseFloat(measurement)) && - parseFloat(measurement) >= 0 && + Number.isFinite(parsed) && + parsed >= 0 && + (!integerUnit || Number.isInteger(parsed)) && !submitting; return ( @@ -149,7 +171,7 @@ export function MobileWaterLogForm({ {/* ── Measurement section ─────────────────────────── */} - Measurement + {t.log.measurement}
{ @@ -245,7 +270,7 @@ export function MobileWaterLogForm({ if (error) setError(null); }} placeholder="0" - aria-label="Measurement value" + aria-label={t.log.measurement} className="min-w-0 flex-1 appearance-none bg-transparent text-[44px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.25)]" style={{ fontFamily: @@ -256,7 +281,7 @@ export function MobileWaterLogForm({ }} /> - {unit} + {unitLabel}
{thresholdHint && ( @@ -266,35 +291,43 @@ export function MobileWaterLogForm({ )}
- {/* Unit segmented control */} + {/* Unit segmented control — labels use the localized + * display string so "holes" becomes "hoyos" in Spanish, + * while the value (the canonical DB code) stays English. */}
setUnit(v)} - options={units.map((u) => ({ value: u as string, label: u }))} + options={units.map((u) => ({ + value: u as string, + label: formatUnit(u as string, lang), + }))} />
{/* ── Timestamp section ───────────────────────────── */} - Timestamp + {t.log.timestamp}
- Logged now + {t.log.loggedNow}
- {formatLongTimestamp(now)} + {formatLongTimestamp(now, lang)}
{/* ── Notes section ────────────────────────────────── */} - Notes — optional + {t.log.notes}{" "} + + — {t.log.notesOptional} +
- Visible to admins only + {t.log.notesVisibility} {notes.length}/{NOTES_MAX} @@ -362,14 +395,15 @@ export function MobileWaterLogForm({ className="text-white" style={{ animation: "spin 0.8s linear infinite" }} /> - Submitting… + {t.log.submitting} ) : ( <> - Submit Log + {t.log.submit} {measurement && Number.isFinite(parseFloat(measurement)) && ( - · {formatMeasurement(parseFloat(measurement))} {unit} + · {formatMeasurement(parseFloat(measurement), integerUnit)}{" "} + {unitLabel} )} diff --git a/src/components/water/mobile/MobileWaterApp.tsx b/src/components/water/mobile/MobileWaterApp.tsx index 2efe425..cf1829c 100644 --- a/src/components/water/mobile/MobileWaterApp.tsx +++ b/src/components/water/mobile/MobileWaterApp.tsx @@ -27,7 +27,7 @@ * - `submitWaterEntry` → posts a new reading * - `logoutWater` → clears the cookie + session row */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { verifyWaterPin, submitWaterEntry, @@ -41,9 +41,25 @@ import { MobileWaterLogForm } from "./LogForm"; import { MobileWaterSuccessScreen } from "./SuccessScreen"; import { WL_SESSION_COOKIE } from "@/lib/water-log/session"; import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand"; +import { LangProvider } from "@/lib/water-log/lang-context"; +import { + detectInitialLang, + getT, + type Lang, +} from "@/lib/water-log/i18n"; export function MobileWaterApp() { const [screen, setScreen] = useState("pin"); + // Language comes from `document.cookie` (the wl_lang cookie) which + // doesn't exist on the server. We use `useSyncExternalStore` so + // the server snapshot ("es") and the client snapshot (cookie or + // navigator) are read from stable getSnapshot functions — no + // hydration mismatch, no cascading setState in an effect. + const lang = useSyncExternalStore( + subscribeNoop, + getClientLangSnapshot, + getServerLangSnapshot, + ); const [irrigatorName, setIrrigatorName] = useState(null); const [headgates, setHeadgates] = useState([]); const [loadingHeadgates, setLoadingHeadgates] = useState(false); @@ -57,6 +73,10 @@ export function MobileWaterApp() { const [logFormKey, setLogFormKey] = useState(0); // ── Headgate fetch (shared by initial load + pull-to-refresh) ── + // `lang` is read here (instead of via `useLang`) because this + // orchestrator lives outside the ; the provider + // wraps each child screen. We just want the fallback error string + // in the user's chosen language. const loadHeadgates = useCallback(async () => { setLoadingHeadgates(true); setHeadgateError(null); @@ -67,12 +87,12 @@ export function MobileWaterApp() { setHeadgateError( err instanceof Error ? err.message - : "Couldn't load headgates. Pull down to try again.", + : getT(lang).headgates.loadError, ); } finally { setLoadingHeadgates(false); } - }, []); + }, [lang]); // ── Initial bootstrap: if `wl_session` exists, jump straight to // the headgate list. We defer the cookie read into an effect @@ -188,12 +208,17 @@ export function MobileWaterApp() { }, []); // ── Switch on the current screen ────────────────────────────── + // Compute the screen element first, then wrap it once in + // . Wrapping per-case would remount the provider on + // every screen change, wiping lang state. + let screenElement: React.ReactNode = null; switch (screen) { case "pin": - return ; + screenElement = ; + break; case "headgates": - return ( + screenElement = ( ); + break; case "log": if (!selectedHeadgate) { // Defensive: shouldn't happen, but bounce back to list. queueMicrotask(() => setScreen("headgates")); - return null; + screenElement = null; + break; } - return ( + screenElement = ( ); + break; case "success": if (!submittedLog) { queueMicrotask(() => setScreen("headgates")); - return null; + screenElement = null; + break; } - return ( + screenElement = ( ); + break; } + + // `initialLang` is computed at module-load time — the value will + // match the first client render (SSR returns "es" because + // `document` is undefined; the client effect then syncs if the + // cookie disagrees). This avoids the hydration mismatch that a + // `useState(detectInitialLang)` initializer would cause. + return ( + {screenElement} + ); } +/** + * Subscribers don't make sense for the cookie source — it's read + * once on mount and updated when the user toggles via the + * `LanguageToggle` component (which writes to the same cookie + + * dispatches a manual re-render via setState in the provider). + */ +const subscribeNoop = () => () => {}; + +const getServerLangSnapshot = (): Lang => "es"; + +const getClientLangSnapshot = (): Lang => { + // Cache the value so we don't re-read the cookie on every render. + // The cookie is only mutated by the LanguageToggle's setLang → + // setLangCookie path, which causes a re-render through the + // provider's setState. + return detectInitialLang(); +}; + export default MobileWaterApp; \ No newline at end of file diff --git a/src/components/water/mobile/PinScreen.tsx b/src/components/water/mobile/PinScreen.tsx index 53e8d18..75bca42 100644 --- a/src/components/water/mobile/PinScreen.tsx +++ b/src/components/water/mobile/PinScreen.tsx @@ -27,6 +27,7 @@ import { type FormEvent, } from "react"; import { Droplet, Lock, Spinner } from "./icons"; +import { useLang } from "@/lib/water-log/lang-context"; type Props = { /** Called when the user submits a 4-8 digit PIN. */ @@ -36,6 +37,7 @@ type Props = { }; export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { + const { t } = useLang(); const [pin, setPin] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -72,7 +74,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { } catch (err) { // Network / unexpected error setError( - err instanceof Error ? err.message : "Something went wrong. Try again.", + err instanceof Error ? err.message : t.pin.genericError, ); setShake(true); window.setTimeout(() => setShake(false), 420); @@ -81,7 +83,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { setSubmitting(false); } }, - [pin, length, submitting, onSubmit], + [pin, length, submitting, onSubmit, t.pin.genericError], ); const onFailureShake = useCallback(() => { @@ -122,7 +124,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - TUXEDO WATER LOG + {t.brand.waterLog}
@@ -152,7 +154,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - Enter your PIN + {t.pin.title} {/* Caption OR inline error */} @@ -164,7 +166,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { role={error ? "alert" : undefined} aria-live={error ? "assertive" : undefined} > - {error ?? "Sign in to log a water reading"} + {error ?? t.pin.caption}

{/* ── Digit boxes ────────────────────────────────────── */} @@ -185,13 +187,13 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { // keyboard rises correctly. Visually hidden via the // standard "sr-only" technique. className="sr-only" - aria-label="PIN" + aria-label={t.pin.a11yPin} disabled={submitting} /> @@ -263,16 +265,16 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { className="text-white" style={{ animation: "spin 0.8s linear infinite" }} /> - Verifying… + {t.pin.verifying} ) : ( - <>Unlock + <>{t.pin.unlock} )} {/* Footer hint */}

- Forgot your PIN? Ask the ditch supervisor. + {t.pin.forgot}

diff --git a/src/components/water/mobile/SuccessScreen.tsx b/src/components/water/mobile/SuccessScreen.tsx index ee10e3d..17700e5 100644 --- a/src/components/water/mobile/SuccessScreen.tsx +++ b/src/components/water/mobile/SuccessScreen.tsx @@ -19,7 +19,9 @@ */ import { useEffect, useState } from "react"; import { Grid, Refresh } from "./icons"; -import { formatClockTime, formatLongTimestamp, formatMeasurement } from "./format"; +import { formatClockTime, formatLongTimestamp, formatMeasurement, formatUnit } from "./format"; +import { isIntegerUnit } from "./types"; +import { useLang } from "@/lib/water-log/lang-context"; type Props = { headgateName: string; @@ -42,10 +44,16 @@ export function MobileWaterSuccessScreen({ onLogAnother, onBackToHeadgates, }: Props) { + const { t, lang } = useLang(); // Reveal animation — staggered for ring → checkmark → content. const [ringDone, setRingDone] = useState(false); const [contentShown, setContentShown] = useState(false); + // Integer-count units (e.g. "holes") render without decimals on + // the recap. Reuse the shared catalog predicate so adding a new + // integer unit in `types.ts` automatically handles the recap too. + const integerUnit = isIntegerUnit(unit); + useEffect(() => { const ringTimer = window.setTimeout(() => setRingDone(true), 380); const contentTimer = window.setTimeout(() => setContentShown(true), 480); @@ -81,7 +89,7 @@ export function MobileWaterSuccessScreen({ "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - TUXEDO WATER LOG + {t.brand.waterLog} @@ -159,7 +167,7 @@ export function MobileWaterSuccessScreen({ "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - Reading logged + {t.success.title} {/* Recap card */} @@ -181,7 +189,7 @@ export function MobileWaterSuccessScreen({ {headgateName} - {formatClockTime(timestamp)} + {formatClockTime(timestamp, lang)}
@@ -192,14 +200,14 @@ export function MobileWaterSuccessScreen({ "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - {formatMeasurement(measurement)} + {formatMeasurement(measurement, integerUnit)} - {unit} + {formatUnit(unit, lang)}
- {formatLongTimestamp(timestamp)} + {formatLongTimestamp(timestamp, lang)}
{irrigatorName ? (
@@ -209,7 +217,7 @@ export function MobileWaterSuccessScreen({ > {initials(irrigatorName)} - Logged by {irrigatorName} + {t.success.loggedBy(irrigatorName)}
) : null} @@ -237,7 +245,7 @@ export function MobileWaterSuccessScreen({ }} > - Log Another Entry + {t.success.logAnother}

- Thanks for keeping the records flowing. + {t.success.thanks}

diff --git a/src/components/water/mobile/format.ts b/src/components/water/mobile/format.ts index 89c303f..e603af5 100644 --- a/src/components/water/mobile/format.ts +++ b/src/components/water/mobile/format.ts @@ -4,8 +4,14 @@ * Pure functions, no React. Used by the PIN, headgate list, and log * screens for consistent display of timestamps, units, and "geocode" * secondary text. + * + * Functions that emit user-facing strings accept a `Lang` so they + * localize correctly (en / es). Defaults to `"en"` for back-compat + * with any code path that hasn't been threaded through `useLang()`. */ +import { displayUnit, getT, type Lang } from "@/lib/water-log/i18n"; + /** * Format an ISO timestamp as a short relative phrase. * "just now" < 45s @@ -14,17 +20,24 @@ * "Mar 14" same calendar year * "Mar 14 '25" previous calendar year */ -export function formatRelativeTime(iso: string | null): string { - if (!iso) return "Never logged"; +export function formatRelativeTime( + iso: string | null, + lang: Lang = "en", +): string { + const t = getT(lang).format; + if (!iso) return t.neverLogged; const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return "Never logged"; + if (Number.isNaN(then)) return t.neverLogged; const diffSec = Math.round((Date.now() - then) / 1000); - if (diffSec < 45) return "Just now"; - if (diffSec < 60 * 60) return `${Math.round(diffSec / 60)}m ago`; - if (diffSec < 60 * 60 * 24) return `${Math.round(diffSec / 3600)}h ago`; + if (diffSec < 45) return t.justNow; + if (diffSec < 60 * 60) return t.minutesAgo(Math.round(diffSec / 60)); + if (diffSec < 60 * 60 * 24) return t.hoursAgo(Math.round(diffSec / 3600)); const d = new Date(iso); const sameYear = d.getFullYear() === new Date().getFullYear(); - const date = d.toLocaleDateString("en-US", { + // Use Intl.DateTimeFormat with the appropriate locale. "es" covers + // es-ES, es-MX, etc. for date format strings. + const locale = lang === "es" ? "es" : "en-US"; + const date = d.toLocaleDateString(locale, { month: "short", day: "numeric", }); @@ -35,8 +48,9 @@ export function formatRelativeTime(iso: string | null): string { * Format a Date as a 12-hour time suitable for the sticky log header * (e.g. "2:37 PM"). */ -export function formatClockTime(d: Date): string { - return d.toLocaleTimeString("en-US", { +export function formatClockTime(d: Date, lang: Lang = "en"): string { + const locale = lang === "es" ? "es" : "en-US"; + return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit", hour12: true, @@ -45,24 +59,27 @@ export function formatClockTime(d: Date): string { /** * Format a Date as a long human label for the submit timestamp preview - * (e.g. "Wed, Mar 14 · 2:37 PM"). + * (e.g. "Wed, Mar 14 · 2:37 PM" / "mié, mar 14 · 2:37 PM"). */ -export function formatLongTimestamp(d: Date): string { - const date = d.toLocaleDateString("en-US", { +export function formatLongTimestamp(d: Date, lang: Lang = "en"): string { + const locale = lang === "es" ? "es" : "en-US"; + const date = d.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric", }); - return `${date} · ${formatClockTime(d)}`; + return `${date} · ${formatClockTime(d, lang)}`; } /** * Format a measurement for the success-screen recap. Strips trailing * zeros and clamps to 4 significant fraction digits so "12.5000" - * becomes "12.5". + * becomes "12.5". For integer-count units ("holes") we render the + * whole-number form ("12" not "12.0"). */ -export function formatMeasurement(value: number): string { +export function formatMeasurement(value: number, integer = false): string { if (!Number.isFinite(value)) return "0"; + if (integer) return String(Math.round(value)); const fixed = value.toFixed(4); return fixed.replace(/\.?0+$/, "") || "0"; } @@ -72,18 +89,30 @@ export function formatMeasurement(value: number): string { * a dedicated `geocode` column, so we fall back through the available * context fields in priority order: * 1. headgate.notes — admin's free-text label - * 2. status label — "Open", "Closed", "Maintenance" - * 3. default "Headgate" + * 2. status label — localized "Open · Ready to log" / etc. + * 3. raw status string */ -export function headgateSubtitle(h: FieldHeadgateLike): string { +export function headgateSubtitle( + h: FieldHeadgateLike, + lang: Lang = "en", +): string { if (h.notes && h.notes.trim()) return h.notes.trim(); + const t = getT(lang).status; const status = (h.status ?? "open").toLowerCase(); - if (status === "open") return "Open · Ready to log"; - if (status === "closed") return "Closed"; - if (status === "maintenance") return "Under maintenance"; + if (status === "open") return t.openReady; + if (status === "closed") return t.closed; + if (status === "maintenance") return t.maintenance; return status; } +/** + * Display label for a headgate's stored unit (e.g. "CFS" / "hoyos"), + * localized. Falls back to the raw string for unknown units. + */ +export function formatUnit(unit: string, lang: Lang): string { + return displayUnit(unit, lang); +} + /** Type alias used by `headgateSubtitle` — accepts any object with the * relevant subset, so callers don't need the full FieldHeadgate type. */ type FieldHeadgateLike = { diff --git a/src/components/water/mobile/types.ts b/src/components/water/mobile/types.ts index 489263d..981e5fb 100644 --- a/src/components/water/mobile/types.ts +++ b/src/components/water/mobile/types.ts @@ -20,10 +20,28 @@ export type FieldHeadgate = { last_used_at: string | null; }; -/** The five units the mobile app offers in its segmented control. */ -export const MOBILE_UNITS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"] as const; +/** The units the mobile app offers in its segmented control. + * + * The order is significant — CFS is the default for Tuxedo headgates + * (it's the most common measurement). "holes" is the count of open + * holes on a headgate plate, used by some Tuxedo operations where + * the field is metered by orifice count rather than CFS. + */ +export const MOBILE_UNITS = [ + "CFS", + "GPM", + "gal", + "ac-in", + "ac-ft", + "holes", +] as const; export type MobileUnit = (typeof MOBILE_UNITS)[number]; +/** True when the unit is an integer count (no decimal allowed). */ +export function isIntegerUnit(unit: string): boolean { + return unit === "holes"; +} + /** Top-level screen identifiers — drives the state machine. */ export type Screen = "pin" | "headgates" | "log" | "success"; diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts index e7778ba..66970ac 100644 --- a/src/lib/water-log/i18n.ts +++ b/src/lib/water-log/i18n.ts @@ -45,6 +45,14 @@ export type Labels = { save: string; saving: string; back: string; + signOut: string; + a11yBackToHeadgates: string; + a11yChangeHeadgate: string; + a11ySignOut: string; + }; + /** The brand strip shown on the PIN and Success screens. */ + brand: { + waterLog: string; }; /** `/water` PIN entry screen. */ pin: { @@ -54,6 +62,8 @@ export type Labels = { unlock: string; forgot: string; genericError: string; + a11yPin: string; + a11yPinEntry: string; }; /** `/water` headgate list screen. */ headgates: { @@ -65,14 +75,19 @@ export type Labels = { pullToRefresh: string; thresholdHigh: string; thresholdLow: string; + refresh: string; + refreshing: string; + loadError: string; }; /** `/water` measurement entry screen. */ log: { title: string; measurement: string; + unit: string; timestamp: string; loggedNow: string; notes: string; + notesOptional: string; notesPlaceholder: string; notesVisibility: string; measurementError: string; @@ -124,6 +139,13 @@ export const LABELS: Record = { save: "Save", saving: "Saving…", back: "Back", + signOut: "Sign out", + a11yBackToHeadgates: "Back to headgates", + a11yChangeHeadgate: "Change headgate", + a11ySignOut: "Sign out", + }, + brand: { + waterLog: "TUXEDO WATER LOG", }, pin: { title: "Enter your PIN", @@ -132,6 +154,8 @@ export const LABELS: Record = { unlock: "Unlock", forgot: "Forgot your PIN? Ask the ditch supervisor.", genericError: "Something went wrong. Try again.", + a11yPin: "PIN", + a11yPinEntry: "PIN entry — tap to focus", }, headgates: { title: "Headgates", @@ -143,13 +167,18 @@ export const LABELS: Record = { pullToRefresh: "Pull down to refresh the list", thresholdHigh: "Hi", thresholdLow: "Lo", + refresh: "Refresh", + refreshing: "Checking…", + loadError: "Couldn't load headgates. Pull down to try again.", }, log: { title: "Log Reading", measurement: "Measurement", + unit: "Unit", timestamp: "Timestamp", loggedNow: "Logged now", notes: "Notes", + notesOptional: "optional", notesPlaceholder: "Anything the supervisor should know about this reading?", notesVisibility: "Visible to admins only", @@ -197,6 +226,13 @@ export const LABELS: Record = { save: "Guardar", saving: "Guardando…", back: "Atrás", + signOut: "Salir", + a11yBackToHeadgates: "Volver a compuertas", + a11yChangeHeadgate: "Cambiar compuerta", + a11ySignOut: "Cerrar sesión", + }, + brand: { + waterLog: "REGISTRO DE AGUA TUXEDO", }, pin: { title: "Ingresa tu PIN", @@ -205,6 +241,8 @@ export const LABELS: Record = { unlock: "Desbloquear", forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.", genericError: "Algo salió mal. Inténtalo de nuevo.", + a11yPin: "PIN", + a11yPinEntry: "Entrada de PIN — toca para enfocar", }, headgates: { title: "Compuertas", @@ -216,13 +254,18 @@ export const LABELS: Record = { pullToRefresh: "Jala hacia abajo para actualizar la lista", thresholdHigh: "Alto", thresholdLow: "Bajo", + refresh: "Actualizar", + refreshing: "Verificando…", + loadError: "No se pudieron cargar las compuertas. Jala para intentar de nuevo.", }, log: { title: "Registrar lectura", measurement: "Medición", + unit: "Unidad", timestamp: "Hora", loggedNow: "Registrando ahora", notes: "Notas", + notesOptional: "opcional", notesPlaceholder: "¿Algo que el supervisor deba saber sobre esta lectura?", notesVisibility: "Visible solo para administradores", diff --git a/tests/unit/water-log-i18n.test.ts b/tests/unit/water-log-i18n.test.ts new file mode 100644 index 0000000..87d49ea --- /dev/null +++ b/tests/unit/water-log-i18n.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for the shared i18n module. + * + * Covers the label dictionary (en/es), the unit display mapping, + * the formatter helpers, and the lang-detection function. The + * vitest env is "node" (no jsdom), so we stub `document` and + * `navigator` directly via property assignment. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + detectInitialLang, + displayUnit, + getT, + LABELS, + setLangCookie, + WL_LANG_COOKIE, + type Lang, +} from "@/lib/water-log/i18n"; + +// ── Minimal DOM stub ───────────────────────────────────────────────── + +type CookieStore = Record; + +function installDomStub(initialCookies: CookieStore = {}) { + const cookies: CookieStore = { ...initialCookies }; + const document = { + get cookie() { + return Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + }, + set cookie(value: string) { + // Parse "name=value; ..." — keep just the first pair. + const [pair] = value.split(";"); + const eq = pair.indexOf("="); + if (eq < 0) return; + const name = pair.slice(0, eq).trim(); + const val = pair.slice(eq + 1).trim(); + // Treat "name=; expires=..." as a delete. + if ( + val === "" || + /expires=Thu, 01 Jan 1970/i.test(value) || + /max-age=0/i.test(value) + ) { + delete cookies[name]; + } else { + cookies[name] = val; + } + }, + }; + const originalDoc = (globalThis as { document?: unknown }).document; + const originalNav = (globalThis as { navigator?: unknown }).navigator; + Object.defineProperty(globalThis, "document", { + value: document, + writable: true, + configurable: true, + }); + return { + setNav(nav: { language: string } | undefined) { + Object.defineProperty(globalThis, "navigator", { + value: nav, + writable: true, + configurable: true, + }); + }, + restore() { + Object.defineProperty(globalThis, "document", { + value: originalDoc, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: originalNav, + writable: true, + configurable: true, + }); + }, + }; +} + +// ── Lang dictionary ──────────────────────────────────────────────────── + +describe("LABELS dictionary", () => { + it("has both en and es keys", () => { + expect(LABELS.en).toBeDefined(); + expect(LABELS.es).toBeDefined(); + }); + + it("en and es share the same top-level screen keys", () => { + const enKeys = Object.keys(LABELS.en).sort(); + const esKeys = Object.keys(LABELS.es).sort(); + expect(esKeys).toEqual(enKeys); + }); + + it("every screen's label has matching en and es inner keys", () => { + for (const screen of Object.keys(LABELS.en) as Array< + keyof typeof LABELS.en + >) { + const enScreen = LABELS.en[screen] as Record; + const esScreen = LABELS.es[screen] as Record; + const enInner = Object.keys(enScreen).sort(); + const esInner = Object.keys(esScreen).sort(); + expect(esInner, `screen "${String(screen)}"`).toEqual(enInner); + } + }); + + it("PIN screen labels are non-empty and translated", () => { + expect(LABELS.en.pin.title.length).toBeGreaterThan(0); + expect(LABELS.es.pin.title.length).toBeGreaterThan(0); + expect(LABELS.en.pin.title).not.toBe(LABELS.es.pin.title); + }); +}); + +// ── getT ────────────────────────────────────────────────────────────── + +describe("getT()", () => { + it("returns the en dict for 'en'", () => { + expect(getT("en")).toBe(LABELS.en); + }); + + it("returns the es dict for 'es'", () => { + expect(getT("es")).toBe(LABELS.es); + }); +}); + +// ── displayUnit ────────────────────────────────────────────────────── + +describe("displayUnit()", () => { + it("translates 'holes' to 'hoyos' in Spanish", () => { + expect(displayUnit("holes", "es")).toBe("hoyos"); + }); + + it("leaves 'holes' as 'holes' in English", () => { + expect(displayUnit("holes", "en")).toBe("holes"); + }); + + it("leaves CFS, GPM, gal, ac-in, ac-ft unchanged in both langs", () => { + const passThrough: Lang[] = ["en", "es"]; + for (const lang of passThrough) { + expect(displayUnit("CFS", lang)).toBe("CFS"); + expect(displayUnit("GPM", lang)).toBe("GPM"); + expect(displayUnit("gal", lang)).toBe("gal"); + expect(displayUnit("ac-in", lang)).toBe("ac-in"); + expect(displayUnit("ac-ft", lang)).toBe("ac-ft"); + } + }); + + it("passes through unknown units unchanged", () => { + expect(displayUnit("custom-unit", "en")).toBe("custom-unit"); + expect(displayUnit("custom-unit", "es")).toBe("custom-unit"); + }); +}); + +// ── detectInitialLang ─────────────────────────────────────────────── + +describe("detectInitialLang()", () => { + let stub: ReturnType; + + beforeEach(() => { + stub = installDomStub(); + }); + + afterEach(() => { + stub.restore(); + }); + + it("returns 'es' when document is undefined (SSR)", () => { + Object.defineProperty(globalThis, "document", { + value: undefined, + writable: true, + configurable: true, + }); + expect(detectInitialLang()).toBe("es"); + }); + + it("returns 'en' when wl_lang cookie is 'en'", () => { + document.cookie = `${WL_LANG_COOKIE}=en; path=/`; + expect(detectInitialLang()).toBe("en"); + }); + + it("returns 'es' when wl_lang cookie is 'es'", () => { + document.cookie = `${WL_LANG_COOKIE}=es; path=/`; + expect(detectInitialLang()).toBe("es"); + }); + + it("ignores a malformed wl_lang cookie and falls back to navigator", () => { + document.cookie = `${WL_LANG_COOKIE}=fr; path=/`; + stub.setNav({ language: "en-US" }); + expect(detectInitialLang()).toBe("en"); + }); + + it("falls back to 'es' when no cookie and navigator is non-English", () => { + stub.setNav({ language: "fr-FR" }); + expect(detectInitialLang()).toBe("es"); + }); +}); + +// ── setLangCookie ─────────────────────────────────────────────────── + +describe("setLangCookie()", () => { + let stub: ReturnType; + + beforeEach(() => { + stub = installDomStub(); + }); + + afterEach(() => { + stub.restore(); + }); + + it("writes the wl_lang cookie with the chosen lang", () => { + setLangCookie("en"); + expect(document.cookie).toMatch(/wl_lang=en/); + }); + + it("overwrites a previous value", () => { + setLangCookie("en"); + setLangCookie("es"); + expect(document.cookie).toMatch(/wl_lang=es/); + }); + + it("is a no-op when document is undefined (SSR)", () => { + Object.defineProperty(globalThis, "document", { + value: undefined, + writable: true, + configurable: true, + }); + expect(() => setLangCookie("en")).not.toThrow(); + }); +}); + +// ── Mobile label interpolation ───────────────────────────────────── + +describe("Mobile label helpers (interpolation)", () => { + it("headgates.signedInAs interpolates the name", () => { + expect(LABELS.en.headgates.signedInAs("Tyler")).toBe( + "Signed in as Tyler", + ); + expect(LABELS.es.headgates.signedInAs("Tyler")).toBe( + "Sesión iniciada como Tyler", + ); + }); + + it("log.normalRange interpolates the bounds + unit", () => { + expect(LABELS.en.log.normalRange(2, 8, "CFS")).toBe( + "Normal range: 2 – 8 CFS", + ); + 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( + "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"); + }); + + it("success.loggedBy interpolates the irrigator name", () => { + expect(LABELS.en.success.loggedBy("Maria")).toBe("Logged by Maria"); + expect(LABELS.es.success.loggedBy("Maria")).toBe("Registrado por Maria"); + }); + + it("format.neverLogged / justNow are non-empty and translated", () => { + expect(LABELS.en.format.neverLogged.length).toBeGreaterThan(0); + expect(LABELS.es.format.neverLogged.length).toBeGreaterThan(0); + expect(LABELS.en.format.justNow).not.toBe(LABELS.es.format.justNow); + }); + + it("format.minutesAgo / hoursAgo interpolate the count", () => { + expect(LABELS.en.format.minutesAgo(5)).toBe("5m ago"); + expect(LABELS.es.format.minutesAgo(5)).toBe("hace 5m"); + expect(LABELS.en.format.hoursAgo(3)).toBe("3h ago"); + expect(LABELS.es.format.hoursAgo(3)).toBe("hace 3h"); + }); +}); + +// ── Unit catalog (regression guard for "holes") ───────────────────── + +describe("Unit catalog includes 'holes'", () => { + it("'holes' is in the en catalog and translates to 'hoyos' in es", () => { + expect(LABELS.en.units.holes).toBe("holes"); + expect(LABELS.es.units.holes).toBe("hoyos"); + }); + + it("all six MOBILE_UNITS keys exist in the units dict for both langs", () => { + const expected = ["CFS", "GPM", "gal", "ac-in", "ac-ft", "holes"]; + for (const unit of expected) { + expect(LABELS.en.units).toHaveProperty(unit); + expect(LABELS.es.units).toHaveProperty(unit); + } + }); +}); \ No newline at end of file