feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
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:
@@ -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;
|
||||
Reference in New Issue
Block a user