Files
route-commerce/src/lib/water-log/i18n.ts
T
Nora dfd6b63888 feat(water-log): cycle 4 — Tuxedo mobile worker Time tab + unified PIN
The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.

- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
  after PIN, screen='time' case embeds <TimeTrackingFieldClient>
  with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
  the water PIN succeeds, so the Time tab never re-prompts a worker
  who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
  cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
  back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
  Tuxedo brand display constants into @/lib/water-log/brand so the
  standalone /tuxedo/time-clock page and the embedded Time tab share
  one source of truth.
2026-07-03 18:26:32 -06:00

396 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
signOut: string;
a11yBackToHeadgates: string;
a11yChangeHeadgate: string;
a11ySignOut: string;
/** ARIA group label for the language toggle. */
a11yLanguage: string;
};
/** The brand strip shown on the PIN and Success screens. */
brand: {
waterLog: string;
};
/** `/water` PIN entry screen. */
pin: {
title: string;
caption: string;
verifying: string;
unlock: string;
forgot: string;
genericError: string;
a11yPin: string;
a11yPinEntry: string;
};
/** `/water` headgate list screen. */
headgates: {
title: string;
signedInAs: (name: string) => string;
activeSection: string;
emptyTitle: string;
emptyBody: string;
pullToRefresh: string;
thresholdHigh: string;
thresholdLow: string;
refresh: string;
refreshing: string;
loadError: string;
};
/** `/water` measurement entry screen. */
log: {
title: string;
measurement: string;
unit: string;
timestamp: string;
loggedNow: string;
notes: string;
notesOptional: string;
notesPlaceholder: string;
notesVisibility: string;
measurementError: string;
implausiblyLarge: string;
submit: string;
submitting: string;
/** Shown when a session expires mid-shift; bounce user to PIN. */
sessionExpired: string;
/** Generic submit-failure fallback (e.g. server-side validation). */
submitError: string;
/** Defensive guard — shouldn't ever happen. */
noHeadgateSelected: 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;
};
/** Tuxedo worker post-PIN shell (Cycle 4) — names for the two
* top tabs and the inline logout affordance. */
tab: {
headgates: string;
time: string;
logout: string;
};
};
export const LABELS: Record<Lang, Labels> = {
en: {
common: {
cancel: "Cancel",
save: "Save",
saving: "Saving…",
back: "Back",
signOut: "Sign out",
a11yBackToHeadgates: "Back to headgates",
a11yChangeHeadgate: "Change headgate",
a11ySignOut: "Sign out",
a11yLanguage: "Language",
},
brand: {
waterLog: "TUXEDO WATER LOG",
},
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.",
a11yPin: "PIN",
a11yPinEntry: "PIN entry — tap to focus",
},
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",
refresh: "Refresh",
refreshing: "Checking…",
loadError: "Couldn't load headgates. Pull down to try again.",
},
log: {
title: "Log Reading",
measurement: "Measurement",
unit: "Unit",
timestamp: "Timestamp",
loggedNow: "Logged now",
notes: "Notes",
notesOptional: "optional",
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…",
sessionExpired: "Your session expired. Please sign in again.",
submitError: "Something went wrong submitting that entry. Try again.",
noHeadgateSelected: "Pick a headgate first.",
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`,
},
tab: {
headgates: "Headgates",
time: "Time",
logout: "Log out",
},
},
es: {
common: {
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando…",
back: "Atrás",
signOut: "Salir",
a11yBackToHeadgates: "Volver a compuertas",
a11yChangeHeadgate: "Cambiar compuerta",
a11ySignOut: "Cerrar sesión",
a11yLanguage: "Idioma",
},
brand: {
waterLog: "REGISTRO DE AGUA TUXEDO",
},
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.",
a11yPin: "PIN",
a11yPinEntry: "Entrada de PIN — toca para enfocar",
},
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",
refresh: "Actualizar",
refreshing: "Verificando…",
loadError: "No se pudieron cargar las compuertas. Jala para intentar de nuevo.",
},
log: {
title: "Registrar lectura",
measurement: "Medición",
unit: "Unidad",
timestamp: "Hora",
loggedNow: "Registrando ahora",
notes: "Notas",
notesOptional: "opcional",
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…",
sessionExpired: "Tu sesión expiró. Inicia sesión otra vez.",
submitError:
"Algo salió mal al enviar ese registro. Inténtalo de nuevo.",
noHeadgateSelected: "Elige una compuerta primero.",
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`,
},
tab: {
headgates: "Compuertas",
time: "Tiempo",
logout: "Salir",
},
},
};
/**
* 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;
}