diff --git a/src/components/water/mobile/HeadgateList.tsx b/src/components/water/mobile/HeadgateList.tsx index d6be925..cdedea5 100644 --- a/src/components/water/mobile/HeadgateList.tsx +++ b/src/components/water/mobile/HeadgateList.tsx @@ -29,7 +29,9 @@ import { Spinner, } from "./icons"; import { MobilePullToRefresh } from "./PullToRefresh"; -import { formatRelativeTime, headgateSubtitle } from "./format"; +import { formatRelativeTime, headgateSubtitle, formatUnit } from "./format"; +import { useLang } from "@/lib/water-log/lang-context"; +import { LanguageToggle } from "@/components/water/LanguageToggle"; type Props = { /** Optional display name of the logged-in irrigator (for the header). */ @@ -51,6 +53,7 @@ export function MobileWaterHeadgateList({ onRefresh, onLogout, }: Props) { + const { t, lang } = useLang(); const active = headgates.filter((h) => h.active); return ( @@ -84,16 +87,19 @@ export function MobileWaterHeadgateList({ "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }} > - Headgates + {t.headgates.title} - +
- Signed in as {irrigatorName} + {t.headgates.signedInAs(irrigatorName)}
)} @@ -119,7 +125,7 @@ export function MobileWaterHeadgateList({ {/* Section header */}- Pull down to refresh the list + {t.headgates.pullToRefresh}
)}- Once your supervisor activates a headgate, it'll show up here. - Pull down to check for updates. + {t.headgates.emptyBody}
diff --git a/src/components/water/mobile/LogForm.tsx b/src/components/water/mobile/LogForm.tsx index fc07917..c571029 100644 --- a/src/components/water/mobile/LogForm.tsx +++ b/src/components/water/mobile/LogForm.tsx @@ -33,13 +33,15 @@ import { } from "react"; import { SegmentedControl } from "./SegmentedControl"; import type { FieldHeadgate, MobileUnit } from "./types"; -import { MOBILE_UNITS } from "./types"; +import { isIntegerUnit, MOBILE_UNITS } from "./types"; import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons"; import { formatLongTimestamp, formatMeasurement, + formatUnit, headgateSubtitle, } from "./format"; +import { useLang } from "@/lib/water-log/lang-context"; type Props = { headgate: FieldHeadgate; @@ -61,6 +63,7 @@ export function MobileWaterLogForm({ onLogout, units = MOBILE_UNITS, }: Props) { + const { t, lang } = useLang(); // ── State ──────────────────────────────────────────────────────── const [measurement, setMeasurement] = useState(""); const [unit, setUnit] = useState- Forgot your PIN? Ask the ditch supervisor. + {t.pin.forgot}
- Thanks for keeping the records flowing. + {t.success.thanks}
diff --git a/src/components/water/mobile/format.ts b/src/components/water/mobile/format.ts index 89c303f..e603af5 100644 --- a/src/components/water/mobile/format.ts +++ b/src/components/water/mobile/format.ts @@ -4,8 +4,14 @@ * Pure functions, no React. Used by the PIN, headgate list, and log * screens for consistent display of timestamps, units, and "geocode" * secondary text. + * + * Functions that emit user-facing strings accept a `Lang` so they + * localize correctly (en / es). Defaults to `"en"` for back-compat + * with any code path that hasn't been threaded through `useLang()`. */ +import { displayUnit, getT, type Lang } from "@/lib/water-log/i18n"; + /** * Format an ISO timestamp as a short relative phrase. * "just now" < 45s @@ -14,17 +20,24 @@ * "Mar 14" same calendar year * "Mar 14 '25" previous calendar year */ -export function formatRelativeTime(iso: string | null): string { - if (!iso) return "Never logged"; +export function formatRelativeTime( + iso: string | null, + lang: Lang = "en", +): string { + const t = getT(lang).format; + if (!iso) return t.neverLogged; const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return "Never logged"; + if (Number.isNaN(then)) return t.neverLogged; const diffSec = Math.round((Date.now() - then) / 1000); - if (diffSec < 45) return "Just now"; - if (diffSec < 60 * 60) return `${Math.round(diffSec / 60)}m ago`; - if (diffSec < 60 * 60 * 24) return `${Math.round(diffSec / 3600)}h ago`; + if (diffSec < 45) return t.justNow; + if (diffSec < 60 * 60) return t.minutesAgo(Math.round(diffSec / 60)); + if (diffSec < 60 * 60 * 24) return t.hoursAgo(Math.round(diffSec / 3600)); const d = new Date(iso); const sameYear = d.getFullYear() === new Date().getFullYear(); - const date = d.toLocaleDateString("en-US", { + // Use Intl.DateTimeFormat with the appropriate locale. "es" covers + // es-ES, es-MX, etc. for date format strings. + const locale = lang === "es" ? "es" : "en-US"; + const date = d.toLocaleDateString(locale, { month: "short", day: "numeric", }); @@ -35,8 +48,9 @@ export function formatRelativeTime(iso: string | null): string { * Format a Date as a 12-hour time suitable for the sticky log header * (e.g. "2:37 PM"). */ -export function formatClockTime(d: Date): string { - return d.toLocaleTimeString("en-US", { +export function formatClockTime(d: Date, lang: Lang = "en"): string { + const locale = lang === "es" ? "es" : "en-US"; + return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit", hour12: true, @@ -45,24 +59,27 @@ export function formatClockTime(d: Date): string { /** * Format a Date as a long human label for the submit timestamp preview - * (e.g. "Wed, Mar 14 · 2:37 PM"). + * (e.g. "Wed, Mar 14 · 2:37 PM" / "mié, mar 14 · 2:37 PM"). */ -export function formatLongTimestamp(d: Date): string { - const date = d.toLocaleDateString("en-US", { +export function formatLongTimestamp(d: Date, lang: Lang = "en"): string { + const locale = lang === "es" ? "es" : "en-US"; + const date = d.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric", }); - return `${date} · ${formatClockTime(d)}`; + return `${date} · ${formatClockTime(d, lang)}`; } /** * Format a measurement for the success-screen recap. Strips trailing * zeros and clamps to 4 significant fraction digits so "12.5000" - * becomes "12.5". + * becomes "12.5". For integer-count units ("holes") we render the + * whole-number form ("12" not "12.0"). */ -export function formatMeasurement(value: number): string { +export function formatMeasurement(value: number, integer = false): string { if (!Number.isFinite(value)) return "0"; + if (integer) return String(Math.round(value)); const fixed = value.toFixed(4); return fixed.replace(/\.?0+$/, "") || "0"; } @@ -72,18 +89,30 @@ export function formatMeasurement(value: number): string { * a dedicated `geocode` column, so we fall back through the available * context fields in priority order: * 1. headgate.notes — admin's free-text label - * 2. status label — "Open", "Closed", "Maintenance" - * 3. default "Headgate" + * 2. status label — localized "Open · Ready to log" / etc. + * 3. raw status string */ -export function headgateSubtitle(h: FieldHeadgateLike): string { +export function headgateSubtitle( + h: FieldHeadgateLike, + lang: Lang = "en", +): string { if (h.notes && h.notes.trim()) return h.notes.trim(); + const t = getT(lang).status; const status = (h.status ?? "open").toLowerCase(); - if (status === "open") return "Open · Ready to log"; - if (status === "closed") return "Closed"; - if (status === "maintenance") return "Under maintenance"; + if (status === "open") return t.openReady; + if (status === "closed") return t.closed; + if (status === "maintenance") return t.maintenance; return status; } +/** + * Display label for a headgate's stored unit (e.g. "CFS" / "hoyos"), + * localized. Falls back to the raw string for unknown units. + */ +export function formatUnit(unit: string, lang: Lang): string { + return displayUnit(unit, lang); +} + /** Type alias used by `headgateSubtitle` — accepts any object with the * relevant subset, so callers don't need the full FieldHeadgate type. */ type FieldHeadgateLike = { diff --git a/src/components/water/mobile/types.ts b/src/components/water/mobile/types.ts index 489263d..981e5fb 100644 --- a/src/components/water/mobile/types.ts +++ b/src/components/water/mobile/types.ts @@ -20,10 +20,28 @@ export type FieldHeadgate = { last_used_at: string | null; }; -/** The five units the mobile app offers in its segmented control. */ -export const MOBILE_UNITS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"] as const; +/** The units the mobile app offers in its segmented control. + * + * The order is significant — CFS is the default for Tuxedo headgates + * (it's the most common measurement). "holes" is the count of open + * holes on a headgate plate, used by some Tuxedo operations where + * the field is metered by orifice count rather than CFS. + */ +export const MOBILE_UNITS = [ + "CFS", + "GPM", + "gal", + "ac-in", + "ac-ft", + "holes", +] as const; export type MobileUnit = (typeof MOBILE_UNITS)[number]; +/** True when the unit is an integer count (no decimal allowed). */ +export function isIntegerUnit(unit: string): boolean { + return unit === "holes"; +} + /** Top-level screen identifiers — drives the state machine. */ export type Screen = "pin" | "headgates" | "log" | "success"; diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts index e7778ba..66970ac 100644 --- a/src/lib/water-log/i18n.ts +++ b/src/lib/water-log/i18n.ts @@ -45,6 +45,14 @@ export type Labels = { save: string; saving: string; back: string; + signOut: string; + a11yBackToHeadgates: string; + a11yChangeHeadgate: string; + a11ySignOut: string; + }; + /** The brand strip shown on the PIN and Success screens. */ + brand: { + waterLog: string; }; /** `/water` PIN entry screen. */ pin: { @@ -54,6 +62,8 @@ export type Labels = { unlock: string; forgot: string; genericError: string; + a11yPin: string; + a11yPinEntry: string; }; /** `/water` headgate list screen. */ headgates: { @@ -65,14 +75,19 @@ export type Labels = { 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; @@ -124,6 +139,13 @@ export const LABELS: Record