From b29caa0f305312e84de47e9c590dc3c590287390 Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 2 Jul 2026 10:59:40 -0600 Subject: [PATCH] refactor(water-log): add i18n foundation for mobile EN/ES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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. --- src/components/water/LanguageToggle.tsx | 93 +++++++ src/lib/water-log/i18n.ts | 319 ++++++++++++++++++++++++ src/lib/water-log/lang-context.tsx | 86 +++++++ 3 files changed, 498 insertions(+) create mode 100644 src/components/water/LanguageToggle.tsx create mode 100644 src/lib/water-log/i18n.ts create mode 100644 src/lib/water-log/lang-context.tsx diff --git a/src/components/water/LanguageToggle.tsx b/src/components/water/LanguageToggle.tsx new file mode 100644 index 0000000..41271b9 --- /dev/null +++ b/src/components/water/LanguageToggle.tsx @@ -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 ( +
+ + +
+ ); +} + +export default LanguageToggle; \ No newline at end of file diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts new file mode 100644 index 0000000..e7778ba --- /dev/null +++ b/src/lib/water-log/i18n.ts @@ -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 = { + 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; +} \ No newline at end of file diff --git a/src/lib/water-log/lang-context.tsx b/src/lib/water-log/lang-context.tsx new file mode 100644 index 0000000..582f597 --- /dev/null +++ b/src/lib/water-log/lang-context.tsx @@ -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(null); + +export function LangProvider({ + initialLang, + children, +}: { + initialLang: Lang; + children: ReactNode; +}) { + const [lang, setLangState] = useState(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( + () => ({ lang, t: getT(lang), setLang, toggleLang }), + [lang, setLang, toggleLang], + ); + + return ( + {children} + ); +} + +/** + * Read the current language + labels from context. Throws if used + * outside a `` — that's almost certainly a bug. + */ +export function useLang(): LangContextValue { + const ctx = useContext(LangContext); + if (!ctx) { + throw new Error( + "useLang() must be called inside . " + + "Wrap your tree (e.g. MobileWaterApp) in .", + ); + } + return ctx; +} \ No newline at end of file