refactor(water-log): add i18n foundation for mobile EN/ES
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user