dfd6b63888
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.
396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
/**
|
||
* 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;
|
||
} |