refactor(water-log): add i18n foundation for mobile EN/ES
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s

NEW infrastructure for upcoming mobile app localization:

- src/lib/water-log/i18n.ts
    Shared EN/ES label dictionary for the mobile (/water) portal.
    Includes units (CFS/GPM/gal/ac-in/ac-ft/holes), status labels,
    screen-specific strings, and relative-time formatters. No React,
    no next/headers — safe to import from client, server, and tests.

- src/lib/water-log/lang-context.tsx
    <LangProvider> + useLang() hook. Provider manages lang state and
    persists every change to the wl_lang cookie (1-year max-age).

- src/components/water/LanguageToggle.tsx
    Reusable EN | ES pill component for the mobile nav bar.
    Themed for the Tuxedo green palette.

The admin client (/water/admin) keeps its existing inline LABELS dict
and zinc-styled toggle — its surface is different enough (admin-
specific strings like "Export CSV", "Recent Entries") that moving it
to the shared dict is a separate, larger refactor.

No behavior change. Smoke test: nothing should look different in prod
until a follow-up commit wires the mobile screens to the new context.
This commit is contained in:
Tyler
2026-07-02 10:59:40 -06:00
parent c3da54af50
commit b29caa0f30
3 changed files with 498 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* LanguageToggle — EN | ES segmented pill.
*
* A compact two-button control that switches the active language.
* Used in both the mobile app (header of the headgate list) and
* the admin client (settings page).
*
* The pill is intentionally narrow so it can fit in a sticky nav bar
* next to the logout icon. The active button has the brand-green
* background; the inactive is a subtle pill outline.
*/
import { useLang } from "@/lib/water-log/lang-context";
type Props = {
/** Visual size. `sm` fits inside a 44pt iOS-style nav bar. */
size?: "sm" | "md";
};
export function LanguageToggle({ size = "sm" }: Props) {
const { lang, setLang } = useLang();
const isSmall = size === "sm";
const containerStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "stretch",
borderRadius: 999,
background: "rgba(120,120,128,0.14)",
padding: 2,
height: isSmall ? 28 : 32,
};
const baseButton: React.CSSProperties = {
appearance: "none",
border: 0,
borderRadius: 999,
padding: isSmall ? "0 9px" : "0 12px",
fontSize: isSmall ? 12 : 13,
fontWeight: 700,
letterSpacing: "0.06em",
cursor: "pointer",
transition: "background-color 160ms, color 160ms",
minWidth: isSmall ? 30 : 36,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const activeStyle: React.CSSProperties = {
...baseButton,
background: "#14532d",
color: "#ffffff",
boxShadow: "0 1px 2px rgba(20,83,45,0.20)",
};
const inactiveStyle: React.CSSProperties = {
...baseButton,
background: "transparent",
color: "rgba(60,60,67,0.65)",
};
return (
<div
role="group"
aria-label="Language"
data-testid="language-toggle"
style={containerStyle}
>
<button
type="button"
onClick={() => setLang("en")}
aria-pressed={lang === "en"}
aria-label="English"
style={lang === "en" ? activeStyle : inactiveStyle}
>
EN
</button>
<button
type="button"
onClick={() => setLang("es")}
aria-pressed={lang === "es"}
aria-label="Español"
style={lang === "es" ? activeStyle : inactiveStyle}
>
ES
</button>
</div>
);
}
export default LanguageToggle;
+319
View File
@@ -0,0 +1,319 @@
/**
* Water Log — internationalization (i18n).
*
* Single source of truth for the user-facing strings in the mobile
* (`/water`) and admin (`/water/admin`) portals. Both portals share
* the same `wl_lang` cookie and the same audience (Tuxedo field
* workers + ditch supervisors, mostly Spanish-first).
*
* **Why this lives in `lib/` rather than each component:**
*
* - The two portals used to have divergent i18n implementations:
* `WaterAdminClient.tsx` had a full `LABELS` dict; the mobile
* screens had no i18n at all. Lifting the labels here lets the
* admin client drop its copy and lets the mobile app adopt
* Spanish without re-inventing the dictionary.
* - A single source means when we add a string to one screen we
* notice the other and add it there too (or at least see that
* it's missing).
* - The labels are pure data — no React, no `next/headers`, no
* `cookies()` — so they're safe to import from client
* components, server components, and unit tests alike.
*
* Persistence: the `wl_lang` cookie. See `./session-server.ts`
* (currently `setWaterLang` in `actions/water-log/field.ts` sets it
* via a thin wrapper; we keep that wrapper for back-compat).
*
* Default: Spanish — the deployed audience is predominantly
* Spanish-speaking. Override with the cookie or with
* `detectInitialLang()`.
*/
export const WL_LANG_COOKIE = "wl_lang";
export type Lang = "en" | "es";
/**
* The full label dictionary. Organized by screen so the writer can
* find the right string quickly and so we can see at a glance which
* screens are missing translations.
*/
export type Labels = {
/** Strings shared by multiple screens. */
common: {
cancel: string;
save: string;
saving: string;
back: string;
};
/** `/water` PIN entry screen. */
pin: {
title: string;
caption: string;
verifying: string;
unlock: string;
forgot: string;
genericError: string;
};
/** `/water` headgate list screen. */
headgates: {
title: string;
signedInAs: (name: string) => string;
activeSection: string;
emptyTitle: string;
emptyBody: string;
pullToRefresh: string;
thresholdHigh: string;
thresholdLow: string;
};
/** `/water` measurement entry screen. */
log: {
title: string;
measurement: string;
timestamp: string;
loggedNow: string;
notes: string;
notesPlaceholder: string;
notesVisibility: string;
measurementError: string;
implausiblyLarge: string;
submit: string;
submitting: string;
normalRange: (low: number, high: number, unit: string) => string;
alertAbove: (high: number, unit: string) => string;
alertBelow: (low: number, unit: string) => string;
};
/** `/water` success screen after submit. */
success: {
title: string;
logAnother: string;
backToHeadgates: string;
thanks: string;
loggedBy: (name: string) => string;
};
/** Headgate status string shown in card subtitles. */
status: {
open: string;
openReady: string;
closed: string;
maintenance: string;
};
/** Unit labels. The keys are the canonical English codes stored in
* the DB. Values are the display strings per language. */
units: {
CFS: string;
GPM: string;
gal: string;
"ac-in": string;
"ac-ft": string;
holes: string;
};
/** Short relative-time phrases for the headgate card meta row. */
format: {
neverLogged: string;
justNow: string;
minutesAgo: (n: number) => string;
hoursAgo: (n: number) => string;
};
};
export const LABELS: Record<Lang, Labels> = {
en: {
common: {
cancel: "Cancel",
save: "Save",
saving: "Saving…",
back: "Back",
},
pin: {
title: "Enter your PIN",
caption: "Sign in to log a water reading",
verifying: "Verifying…",
unlock: "Unlock",
forgot: "Forgot your PIN? Ask the ditch supervisor.",
genericError: "Something went wrong. Try again.",
},
headgates: {
title: "Headgates",
signedInAs: (name) => `Signed in as ${name}`,
activeSection: "Active headgates",
emptyTitle: "No active headgates",
emptyBody:
"Once your supervisor activates a headgate, it'll show up here. Pull down to check for updates.",
pullToRefresh: "Pull down to refresh the list",
thresholdHigh: "Hi",
thresholdLow: "Lo",
},
log: {
title: "Log Reading",
measurement: "Measurement",
timestamp: "Timestamp",
loggedNow: "Logged now",
notes: "Notes",
notesPlaceholder:
"Anything the supervisor should know about this reading?",
notesVisibility: "Visible to admins only",
measurementError:
"Enter a measurement greater than or equal to 0",
implausiblyLarge: "That value is implausibly large",
submit: "Submit Log",
submitting: "Submitting…",
normalRange: (low, high, unit) =>
`Normal range: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alert above ${high} ${unit}`,
alertBelow: (low, unit) => `Alert below ${low} ${unit}`,
},
success: {
title: "Reading logged",
logAnother: "Log Another Entry",
backToHeadgates: "Back to Headgates",
thanks: "Thanks for keeping the records flowing.",
loggedBy: (name) => `Logged by ${name}`,
},
status: {
open: "Open",
openReady: "Open · Ready to log",
closed: "Closed",
maintenance: "Under maintenance",
},
units: {
CFS: "CFS",
GPM: "GPM",
gal: "gal",
"ac-in": "ac-in",
"ac-ft": "ac-ft",
holes: "holes",
},
format: {
neverLogged: "Never logged",
justNow: "Just now",
minutesAgo: (n) => `${n}m ago`,
hoursAgo: (n) => `${n}h ago`,
},
},
es: {
common: {
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando…",
back: "Atrás",
},
pin: {
title: "Ingresa tu PIN",
caption: "Inicia sesión para registrar una lectura de agua",
verifying: "Verificando…",
unlock: "Desbloquear",
forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.",
genericError: "Algo salió mal. Inténtalo de nuevo.",
},
headgates: {
title: "Compuertas",
signedInAs: (name) => `Sesión iniciada como ${name}`,
activeSection: "Compuertas activas",
emptyTitle: "No hay compuertas activas",
emptyBody:
"Cuando tu supervisor active una compuerta, aparecerá aquí. Jala hacia abajo para actualizar.",
pullToRefresh: "Jala hacia abajo para actualizar la lista",
thresholdHigh: "Alto",
thresholdLow: "Bajo",
},
log: {
title: "Registrar lectura",
measurement: "Medición",
timestamp: "Hora",
loggedNow: "Registrando ahora",
notes: "Notas",
notesPlaceholder:
"¿Algo que el supervisor deba saber sobre esta lectura?",
notesVisibility: "Visible solo para administradores",
measurementError:
"Ingresa una medición mayor o igual a 0",
implausiblyLarge: "Ese valor es demasiado grande",
submit: "Enviar registro",
submitting: "Enviando…",
normalRange: (low, high, unit) =>
`Rango normal: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`,
alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`,
},
success: {
title: "Lectura registrada",
logAnother: "Registrar otra",
backToHeadgates: "Volver a compuertas",
thanks: "Gracias por mantener los registros al día.",
loggedBy: (name) => `Registrado por ${name}`,
},
status: {
open: "Abierta",
openReady: "Abierta · Lista para registrar",
closed: "Cerrada",
maintenance: "En mantenimiento",
},
units: {
CFS: "CFS",
GPM: "GPM",
gal: "gal",
"ac-in": "ac-in",
"ac-ft": "ac-ft",
holes: "hoyos",
},
format: {
neverLogged: "Nunca registrada",
justNow: "Ahora",
minutesAgo: (n) => `hace ${n}m`,
hoursAgo: (n) => `hace ${n}h`,
},
},
};
/**
* Detect the initial language for a freshly-hydrated client.
*
* Order of preference:
* 1. The `wl_lang` cookie (the user's last explicit choice).
* 2. `navigator.language` (with weak "starts with" matching).
* 3. Spanish (the deployed audience is predominantly Spanish-speaking).
*
* Safe to call during SSR — when `document` is undefined, returns the
* default (`es`). The component calling this should pair the read
* with a `useEffect` to re-detect on the client if needed.
*/
export function detectInitialLang(): Lang {
if (typeof document === "undefined") return "es";
const cookie = document.cookie.match(/wl_lang=(en|es)/)?.[1];
if (cookie === "en" || cookie === "es") return cookie;
if (typeof navigator !== "undefined") {
const lang = navigator.language?.toLowerCase() ?? "";
if (lang.startsWith("en")) return "en";
}
return "es";
}
/** Persist the user's language choice. Client-side only. */
export function setLangCookie(lang: Lang): void {
if (typeof document === "undefined") return;
// 1 year max-age — long enough that returning users see their
// preferred language without re-prompting.
document.cookie = `${WL_LANG_COOKIE}=${lang}; path=/; max-age=31536000; SameSite=Lax`;
}
/** Get the labels dictionary for a given lang. */
export function getT(lang: Lang): Labels {
return LABELS[lang];
}
/**
* Display string for a headgate's stored `unit` value, localized.
* Falls back to the raw unit string when not in the catalog (so we
* don't break on legacy or admin-typed custom units).
*/
export function displayUnit(unit: string, lang: Lang): string {
const t = LABELS[lang].units;
if (unit === "CFS") return t.CFS;
if (unit === "GPM") return t.GPM;
if (unit === "gal") return t.gal;
if (unit === "ac-in") return t["ac-in"];
if (unit === "ac-ft") return t["ac-ft"];
if (unit === "holes") return t.holes;
return unit;
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
/**
* Water Log — React context for the current language.
*
* Wraps a `LangProvider` around a tree of components that need to
* read or change the active language. The provider keeps `lang` in
* React state, persists every change to the `wl_lang` cookie, and
* exposes a `setLang` callback.
*
* The provider does NOT call `detectInitialLang()` itself — callers
* pass `initialLang` in (typically computed in a `useState`
* initializer so SSR and the first client render agree).
*/
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
type Lang,
type Labels,
getT,
setLangCookie,
} from "./i18n";
type LangContextValue = {
lang: Lang;
/** Pre-resolved label dictionary for the current lang. */
t: Labels;
setLang: (lang: Lang) => void;
toggleLang: () => void;
};
const LangContext = createContext<LangContextValue | null>(null);
export function LangProvider({
initialLang,
children,
}: {
initialLang: Lang;
children: ReactNode;
}) {
const [lang, setLangState] = useState<Lang>(initialLang);
const setLang = useCallback((next: Lang) => {
setLangState(next);
setLangCookie(next);
}, []);
const toggleLang = useCallback(() => {
setLangState((prev) => {
const next: Lang = prev === "en" ? "es" : "en";
setLangCookie(next);
return next;
});
}, []);
const value = useMemo<LangContextValue>(
() => ({ lang, t: getT(lang), setLang, toggleLang }),
[lang, setLang, toggleLang],
);
return (
<LangContext.Provider value={value}>{children}</LangContext.Provider>
);
}
/**
* Read the current language + labels from context. Throws if used
* outside a `<LangProvider>` — that's almost certainly a bug.
*/
export function useLang(): LangContextValue {
const ctx = useContext(LangContext);
if (!ctx) {
throw new Error(
"useLang() must be called inside <LangProvider>. " +
"Wrap your tree (e.g. MobileWaterApp) in <LangProvider initialLang={...}>.",
);
}
return ctx;
}