feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s

- '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)
This commit is contained in:
Tyler
2026-07-02 11:18:23 -06:00
parent b29caa0f30
commit 7795263944
9 changed files with 613 additions and 111 deletions
+66 -9
View File
@@ -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<Screen>("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<string | null>(null);
const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
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 <LangProvider>; 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
// <LangProvider>. Wrapping per-case would remount the provider on
// every screen change, wiping lang state.
let screenElement: React.ReactNode = null;
switch (screen) {
case "pin":
return <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
screenElement = <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
break;
case "headgates":
return (
screenElement = (
<MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined}
headgates={headgates}
@@ -204,14 +229,16 @@ export function MobileWaterApp() {
onLogout={handleLogout}
/>
);
break;
case "log":
if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates"));
return null;
screenElement = null;
break;
}
return (
screenElement = (
<MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate}
@@ -221,13 +248,15 @@ export function MobileWaterApp() {
onLogout={handleLogout}
/>
);
break;
case "success":
if (!submittedLog) {
queueMicrotask(() => setScreen("headgates"));
return null;
screenElement = null;
break;
}
return (
screenElement = (
<MobileWaterSuccessScreen
headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement}
@@ -238,7 +267,35 @@ export function MobileWaterApp() {
onBackToHeadgates={handleBackToHeadgates}
/>
);
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 (
<LangProvider initialLang={lang}>{screenElement}</LangProvider>
);
}
/**
* 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;