feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s

- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft
  with integer-only validation: submit button greys out and the keyboard
  raises the numeric keypad (no decimal) when the unit is selected.
- Language toggle in the headgate list header (next to logout) flips
  every screen between English and Spanish. Choice is persisted to the
  existing 'wl_lang' cookie for 1y, so the user's last selection
  sticks across logout/relogin.
- i18n strings lifted into shared src/lib/water-log/i18n.ts (single
  source of truth, en/es parity enforced by tests). All mobile screens
  (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error
  in the orchestrator consume the dictionary. Brand strip, aria-labels,
  segmented control labels, success recap, and submit-button labels
  all go through getT()/formatUnit()/displayUnit().
- 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es
  dict parity, holes catalog + hoyos translation, all the
  detectInitialLang() branches (SSR/cookie/navigator), setLangCookie
  read/write, label interpolation helpers, and the unit catalog.

Refs /tmp/refactor-water-log.md (Commit B plan)
This commit is contained in:
Tyler
2026-07-02 11:18:23 -06:00
parent b29caa0f30
commit 7795263944
9 changed files with 613 additions and 111 deletions
+39 -24
View File
@@ -29,7 +29,9 @@ import {
Spinner, Spinner,
} from "./icons"; } from "./icons";
import { MobilePullToRefresh } from "./PullToRefresh"; 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 = { type Props = {
/** Optional display name of the logged-in irrigator (for the header). */ /** Optional display name of the logged-in irrigator (for the header). */
@@ -51,6 +53,7 @@ export function MobileWaterHeadgateList({
onRefresh, onRefresh,
onLogout, onLogout,
}: Props) { }: Props) {
const { t, lang } = useLang();
const active = headgates.filter((h) => h.active); const active = headgates.filter((h) => h.active);
return ( return (
@@ -84,16 +87,19 @@ export function MobileWaterHeadgateList({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
Headgates {t.headgates.title}
</h1> </h1>
<button <div className="flex shrink-0 items-center gap-1.5 pr-1">
type="button" <LanguageToggle size="sm" />
onClick={onLogout} <button
aria-label="Sign out" type="button"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]" onClick={onLogout}
> aria-label={t.common.a11ySignOut}
<Logout size={20} /> className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
</button> >
<Logout size={20} />
</button>
</div>
</header> </header>
{/* ── Scrollable list ─────────────────────────────────────── */} {/* ── Scrollable list ─────────────────────────────────────── */}
@@ -101,7 +107,7 @@ export function MobileWaterHeadgateList({
<div className="px-4 pb-[max(env(safe-area-inset-bottom),1.25rem)] pt-3"> <div className="px-4 pb-[max(env(safe-area-inset-bottom),1.25rem)] pt-3">
{irrigatorName && ( {irrigatorName && (
<p className="mb-4 px-1 text-[13px] font-medium uppercase tracking-[0.08em] text-[rgba(60,60,67,0.5)]"> <p className="mb-4 px-1 text-[13px] font-medium uppercase tracking-[0.08em] text-[rgba(60,60,67,0.5)]">
Signed in as {irrigatorName} {t.headgates.signedInAs(irrigatorName)}
</p> </p>
)} )}
@@ -119,7 +125,7 @@ export function MobileWaterHeadgateList({
{/* Section header */} {/* Section header */}
<div className="mb-2 flex items-end justify-between px-3 pt-1"> <div className="mb-2 flex items-end justify-between px-3 pt-1">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.06em] text-[rgba(60,60,67,0.55)]"> <h2 className="text-[13px] font-semibold uppercase tracking-[0.06em] text-[rgba(60,60,67,0.55)]">
Active headgates {t.headgates.activeSection}
</h2> </h2>
<span className="text-[13px] font-medium text-[rgba(60,60,67,0.45)]"> <span className="text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{active.length} {active.length}
@@ -138,6 +144,7 @@ export function MobileWaterHeadgateList({
headgate={hg} headgate={hg}
isLast={i === active.length - 1} isLast={i === active.length - 1}
onClick={() => onSelect(hg)} onClick={() => onSelect(hg)}
lang={lang}
/> />
</li> </li>
))} ))}
@@ -147,7 +154,7 @@ export function MobileWaterHeadgateList({
{/* Footer note */} {/* Footer note */}
{active.length > 0 && ( {active.length > 0 && (
<p className="mt-4 px-3 text-center text-[12px] text-[rgba(60,60,67,0.4)]"> <p className="mt-4 px-3 text-center text-[12px] text-[rgba(60,60,67,0.4)]">
Pull down to refresh the list {t.headgates.pullToRefresh}
</p> </p>
)} )}
</div> </div>
@@ -162,13 +169,17 @@ function HeadgateCard({
headgate, headgate,
isLast, isLast,
onClick, onClick,
lang,
}: { }: {
headgate: FieldHeadgate; headgate: FieldHeadgate;
isLast: boolean; isLast: boolean;
onClick: () => void; onClick: () => void;
lang: "en" | "es";
}) { }) {
const subtitle = headgateSubtitle(headgate); const { t } = useLang();
const lastUsed = formatRelativeTime(headgate.last_used_at); const subtitle = headgateSubtitle(headgate, lang);
const lastUsed = formatRelativeTime(headgate.last_used_at, lang);
const unitLabel = formatUnit(headgate.unit, lang);
const isClosed = headgate.status === "closed"; const isClosed = headgate.status === "closed";
const isMaintenance = headgate.status === "maintenance"; const isMaintenance = headgate.status === "maintenance";
@@ -222,7 +233,8 @@ function HeadgateCard({
headgate.low_threshold != null) && ( headgate.low_threshold != null) && (
<ThresholdDot <ThresholdDot
high={headgate.high_threshold} high={headgate.high_threshold}
low={headgate.low_threshold} labelHigh={t.headgates.thresholdHigh}
labelLow={t.headgates.thresholdLow}
/> />
)} )}
</div> </div>
@@ -232,7 +244,7 @@ function HeadgateCard({
</div> </div>
<div className="mt-1 flex items-center gap-2 text-[12px] text-[rgba(60,60,67,0.45)]"> <div className="mt-1 flex items-center gap-2 text-[12px] text-[rgba(60,60,67,0.45)]">
<span className="rounded-full bg-[rgba(120,120,128,0.12)] px-2 py-0.5 font-medium"> <span className="rounded-full bg-[rgba(120,120,128,0.12)] px-2 py-0.5 font-medium">
{headgate.unit} {unitLabel}
</span> </span>
<span>·</span> <span>·</span>
<span>{lastUsed}</span> <span>{lastUsed}</span>
@@ -249,9 +261,12 @@ function HeadgateCard({
function ThresholdDot({ function ThresholdDot({
high, high,
labelHigh,
labelLow,
}: { }: {
high: number | null; high: number | null;
low: number | null; labelHigh: string;
labelLow: string;
}) { }) {
return ( return (
<span <span
@@ -261,7 +276,7 @@ function ThresholdDot({
color: high != null ? "#7f1d1d" : "#1e3a8a", color: high != null ? "#7f1d1d" : "#1e3a8a",
}} }}
> >
{high != null ? "Hi" : "Lo"} {high != null ? labelHigh : labelLow}
</span> </span>
); );
} }
@@ -299,6 +314,7 @@ function EmptyState({
onRefresh: () => Promise<void> | void; onRefresh: () => Promise<void> | void;
refreshing: boolean; refreshing: boolean;
}) { }) {
const { t } = useLang();
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
if (!refreshing) await onRefresh(); if (!refreshing) await onRefresh();
}, [onRefresh, refreshing]); }, [onRefresh, refreshing]);
@@ -309,11 +325,10 @@ function EmptyState({
<Droplet size={28} className="text-[#14532d]" /> <Droplet size={28} className="text-[#14532d]" />
</div> </div>
<h3 className="mb-1 text-[17px] font-semibold text-[#1d1d1f]"> <h3 className="mb-1 text-[17px] font-semibold text-[#1d1d1f]">
No active headgates {t.headgates.emptyTitle}
</h3> </h3>
<p className="mx-auto mb-5 max-w-xs text-[15px] leading-[1.4] text-[rgba(60,60,67,0.6)]"> <p className="mx-auto mb-5 max-w-xs text-[15px] leading-[1.4] text-[rgba(60,60,67,0.6)]">
Once your supervisor activates a headgate, it&apos;ll show up here. {t.headgates.emptyBody}
Pull down to check for updates.
</p> </p>
<button <button
type="button" type="button"
@@ -327,10 +342,10 @@ function EmptyState({
{refreshing ? ( {refreshing ? (
<> <>
<Spinner size={16} className="text-white" style={{ animation: "spin 0.8s linear infinite" }} /> <Spinner size={16} className="text-white" style={{ animation: "spin 0.8s linear infinite" }} />
Checking {t.headgates.refreshing}
</> </>
) : ( ) : (
"Refresh" t.headgates.refresh
)} )}
</button> </button>
</div> </div>
+67 -33
View File
@@ -33,13 +33,15 @@ import {
} from "react"; } from "react";
import { SegmentedControl } from "./SegmentedControl"; import { SegmentedControl } from "./SegmentedControl";
import type { FieldHeadgate, MobileUnit } from "./types"; 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 { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons";
import { import {
formatLongTimestamp, formatLongTimestamp,
formatMeasurement, formatMeasurement,
formatUnit,
headgateSubtitle, headgateSubtitle,
} from "./format"; } from "./format";
import { useLang } from "@/lib/water-log/lang-context";
type Props = { type Props = {
headgate: FieldHeadgate; headgate: FieldHeadgate;
@@ -61,6 +63,7 @@ export function MobileWaterLogForm({
onLogout, onLogout,
units = MOBILE_UNITS, units = MOBILE_UNITS,
}: Props) { }: Props) {
const { t, lang } = useLang();
// ── State ──────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────
const [measurement, setMeasurement] = useState(""); const [measurement, setMeasurement] = useState("");
const [unit, setUnit] = useState<string>( const [unit, setUnit] = useState<string>(
@@ -72,6 +75,11 @@ export function MobileWaterLogForm({
const [now, setNow] = useState<Date>(() => new Date()); const [now, setNow] = useState<Date>(() => new Date());
const measurementRef = useRef<HTMLInputElement>(null); const measurementRef = useRef<HTMLInputElement>(null);
// True when the current unit is an integer count (e.g. "holes").
// Drives the input's keyboard + step constraint + the recap
// formatting on the success screen.
const integerUnit = isIntegerUnit(unit);
// ── Auto-focus the measurement input on mount so the keyboard // ── Auto-focus the measurement input on mount so the keyboard
// rises immediately. // rises immediately.
useEffect(() => { useEffect(() => {
@@ -91,24 +99,32 @@ export function MobileWaterLogForm({
const thresholdHint = useMemo(() => { const thresholdHint = useMemo(() => {
const h = headgate.high_threshold; const h = headgate.high_threshold;
const l = headgate.low_threshold; const l = headgate.low_threshold;
const u = formatUnit(unit, lang);
if (h == null && l == null) return null; if (h == null && l == null) return null;
if (h != null && l != null) { if (h != null && l != null) {
return `Normal range: ${l} ${h} ${unit}`; return t.log.normalRange(l, h, u);
} }
if (h != null) return `Alert above ${h} ${unit}`; if (h != null) return t.log.alertAbove(h, u);
return `Alert below ${l} ${unit}`; // TS narrowing: reaching this line means `h == null && l != null`.
}, [headgate, unit]); return t.log.alertBelow(l as number, u);
}, [headgate, unit, lang, t]);
const submitted = (e?: FormEvent) => { const submitted = (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (submitting) return; if (submitting) return;
const value = parseFloat(measurement); const value = parseFloat(measurement);
if (!Number.isFinite(value) || value < 0) { if (!Number.isFinite(value) || value < 0) {
setError("Enter a measurement greater than or equal to 0"); setError(t.log.measurementError);
return;
}
// Integer-count units (holes) reject fractional values — you
// can't have 2.5 holes.
if (integerUnit && !Number.isInteger(value)) {
setError(t.log.measurementError);
return; return;
} }
if (value > 1_000_000) { if (value > 1_000_000) {
setError("That value is implausibly large"); setError(t.log.implausiblyLarge);
return; return;
} }
setSubmitting(true); setSubmitting(true);
@@ -116,18 +132,24 @@ export function MobileWaterLogForm({
onSubmit({ measurement: value, unit, notes }) onSubmit({ measurement: value, unit, notes })
.catch((err) => { .catch((err) => {
setError( setError(
err instanceof Error ? err.message : "Couldn't submit. Try again.", err instanceof Error ? err.message : t.log.submitting,
); );
setSubmitting(false); setSubmitting(false);
}); });
// On success the parent swaps to the success screen. // On success the parent swaps to the success screen.
}; };
const subtitle = headgateSubtitle(headgate); const subtitle = headgateSubtitle(headgate, lang);
const unitLabel = formatUnit(unit, lang);
// Disable submit when the value wouldn't survive `submitted()`'s
// validation — including "holes" rejecting fractional values so
// the button stays grey until the user types a valid integer.
const parsed = parseFloat(measurement);
const canSubmit = const canSubmit =
measurement.trim().length > 0 && measurement.trim().length > 0 &&
Number.isFinite(parseFloat(measurement)) && Number.isFinite(parsed) &&
parseFloat(measurement) >= 0 && parsed >= 0 &&
(!integerUnit || Number.isInteger(parsed)) &&
!submitting; !submitting;
return ( return (
@@ -149,7 +171,7 @@ export function MobileWaterLogForm({
<button <button
type="button" type="button"
onClick={onChangeHeadgate} onClick={onChangeHeadgate}
aria-label="Back to headgates" aria-label={t.common.a11yBackToHeadgates}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
> >
<ChevronLeft size={22} /> <ChevronLeft size={22} />
@@ -163,7 +185,7 @@ export function MobileWaterLogForm({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
Log Reading {t.log.title}
</h1> </h1>
{irrigatorName ? ( {irrigatorName ? (
<div className="mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-[rgba(60,60,67,0.6)]"> <div className="mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-[rgba(60,60,67,0.6)]">
@@ -181,7 +203,7 @@ export function MobileWaterLogForm({
<button <button
type="button" type="button"
onClick={onLogout} onClick={onLogout}
aria-label="Sign out" aria-label={t.common.a11ySignOut}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
> >
<Logout size={20} /> <Logout size={20} />
@@ -200,7 +222,7 @@ export function MobileWaterLogForm({
<button <button
type="button" type="button"
onClick={onChangeHeadgate} onClick={onChangeHeadgate}
aria-label="Change headgate" aria-label={t.common.a11yChangeHeadgate}
className="mb-5 flex w-full items-center gap-3 rounded-[14px] bg-white p-3 text-left active:bg-[rgba(120,120,128,0.10)]" className="mb-5 flex w-full items-center gap-3 rounded-[14px] bg-white p-3 text-left active:bg-[rgba(120,120,128,0.10)]"
style={{ style={{
boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 1px 0 rgba(0,0,0,0.02)", boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 1px 0 rgba(0,0,0,0.02)",
@@ -225,19 +247,22 @@ export function MobileWaterLogForm({
</div> </div>
</div> </div>
<span className="rounded-full bg-[rgba(20,83,45,0.10)] px-2.5 py-1 text-[12px] font-semibold text-[#14532d]"> <span className="rounded-full bg-[rgba(20,83,45,0.10)] px-2.5 py-1 text-[12px] font-semibold text-[#14532d]">
{headgate.unit} {formatUnit(headgate.unit, lang)}
</span> </span>
</button> </button>
{/* ── Measurement section ─────────────────────────── */} {/* ── Measurement section ─────────────────────────── */}
<SectionHeader>Measurement</SectionHeader> <SectionHeader>{t.log.measurement}</SectionHeader>
<div className="mb-2 overflow-hidden rounded-[14px] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]"> <div className="mb-2 overflow-hidden rounded-[14px] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<input <input
ref={measurementRef} ref={measurementRef}
type="number" type="number"
inputMode="decimal" // Integer-count units (holes) bring up the numeric
step="any" // keypad without a decimal key, making it physically
// harder to type 2.5.
inputMode={integerUnit ? "numeric" : "decimal"}
step={integerUnit ? "1" : "any"}
min={0} min={0}
value={measurement} value={measurement}
onChange={(e) => { onChange={(e) => {
@@ -245,7 +270,7 @@ export function MobileWaterLogForm({
if (error) setError(null); if (error) setError(null);
}} }}
placeholder="0" placeholder="0"
aria-label="Measurement value" aria-label={t.log.measurement}
className="min-w-0 flex-1 appearance-none bg-transparent text-[44px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.25)]" className="min-w-0 flex-1 appearance-none bg-transparent text-[44px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.25)]"
style={{ style={{
fontFamily: fontFamily:
@@ -256,7 +281,7 @@ export function MobileWaterLogForm({
}} }}
/> />
<span className="shrink-0 pb-2 text-[20px] font-semibold text-[rgba(60,60,67,0.5)]"> <span className="shrink-0 pb-2 text-[20px] font-semibold text-[rgba(60,60,67,0.5)]">
{unit} {unitLabel}
</span> </span>
</div> </div>
{thresholdHint && ( {thresholdHint && (
@@ -266,35 +291,43 @@ export function MobileWaterLogForm({
)} )}
</div> </div>
{/* Unit segmented control */} {/* Unit segmented control — labels use the localized
* display string so "holes" becomes "hoyos" in Spanish,
* while the value (the canonical DB code) stays English. */}
<div className="mb-5"> <div className="mb-5">
<SegmentedControl <SegmentedControl
ariaLabel="Unit" ariaLabel={t.log.unit}
value={unit as MobileUnit} value={unit as MobileUnit}
onChange={(v: string) => setUnit(v)} onChange={(v: string) => setUnit(v)}
options={units.map((u) => ({ value: u as string, label: u }))} options={units.map((u) => ({
value: u as string,
label: formatUnit(u as string, lang),
}))}
/> />
</div> </div>
{/* ── Timestamp section ───────────────────────────── */} {/* ── Timestamp section ───────────────────────────── */}
<SectionHeader>Timestamp</SectionHeader> <SectionHeader>{t.log.timestamp}</SectionHeader>
<div className="mb-5 flex items-center gap-3 rounded-[14px] bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]"> <div className="mb-5 flex items-center gap-3 rounded-[14px] bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)] text-[#14532d]"> <div className="flex h-9 w-9 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)] text-[#14532d]">
<Clock size={18} /> <Clock size={18} />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-[#1d1d1f]"> <div className="text-[15px] font-semibold text-[#1d1d1f]">
Logged now {t.log.loggedNow}
</div> </div>
<div className="mt-0.5 truncate text-[13px] text-[rgba(60,60,67,0.6)]"> <div className="mt-0.5 truncate text-[13px] text-[rgba(60,60,67,0.6)]">
{formatLongTimestamp(now)} {formatLongTimestamp(now, lang)}
</div> </div>
</div> </div>
</div> </div>
{/* ── Notes section ────────────────────────────────── */} {/* ── Notes section ────────────────────────────────── */}
<SectionHeader> <SectionHeader>
Notes <span className="font-normal text-[rgba(60,60,67,0.45)]"> optional</span> {t.log.notes}{" "}
<span className="font-normal text-[rgba(60,60,67,0.45)]">
{t.log.notesOptional}
</span>
</SectionHeader> </SectionHeader>
<div className="mb-5 rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]"> <div className="mb-5 rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<label className="flex items-start gap-3 p-4"> <label className="flex items-start gap-3 p-4">
@@ -307,7 +340,7 @@ export function MobileWaterLogForm({
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))} onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
rows={3} rows={3}
maxLength={NOTES_MAX} maxLength={NOTES_MAX}
placeholder="Anything the supervisor should know about this reading?" placeholder={t.log.notesPlaceholder}
className="min-h-[68px] w-full resize-none bg-transparent text-[16px] leading-[1.4] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]" className="min-h-[68px] w-full resize-none bg-transparent text-[16px] leading-[1.4] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]"
style={{ style={{
fontFamily: fontFamily:
@@ -316,7 +349,7 @@ export function MobileWaterLogForm({
/> />
</label> </label>
<div className="flex items-center justify-between border-t border-[rgba(60,60,67,0.10)] px-4 py-2 text-[12px] text-[rgba(60,60,67,0.45)]"> <div className="flex items-center justify-between border-t border-[rgba(60,60,67,0.10)] px-4 py-2 text-[12px] text-[rgba(60,60,67,0.45)]">
<span>Visible to admins only</span> <span>{t.log.notesVisibility}</span>
<span> <span>
{notes.length}/{NOTES_MAX} {notes.length}/{NOTES_MAX}
</span> </span>
@@ -362,14 +395,15 @@ export function MobileWaterLogForm({
className="text-white" className="text-white"
style={{ animation: "spin 0.8s linear infinite" }} style={{ animation: "spin 0.8s linear infinite" }}
/> />
Submitting {t.log.submitting}
</> </>
) : ( ) : (
<> <>
Submit Log {t.log.submit}
{measurement && Number.isFinite(parseFloat(measurement)) && ( {measurement && Number.isFinite(parseFloat(measurement)) && (
<span className="ml-1 opacity-80"> <span className="ml-1 opacity-80">
· {formatMeasurement(parseFloat(measurement))} {unit} · {formatMeasurement(parseFloat(measurement), integerUnit)}{" "}
{unitLabel}
</span> </span>
)} )}
</> </>
+66 -9
View File
@@ -27,7 +27,7 @@
* - `submitWaterEntry` → posts a new reading * - `submitWaterEntry` → posts a new reading
* - `logoutWater` → clears the cookie + session row * - `logoutWater` → clears the cookie + session row
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { import {
verifyWaterPin, verifyWaterPin,
submitWaterEntry, submitWaterEntry,
@@ -41,9 +41,25 @@ import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen"; import { MobileWaterSuccessScreen } from "./SuccessScreen";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session"; import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand"; import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
import { LangProvider } from "@/lib/water-log/lang-context";
import {
detectInitialLang,
getT,
type Lang,
} from "@/lib/water-log/i18n";
export function MobileWaterApp() { export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin"); const [screen, setScreen] = useState<Screen>("pin");
// Language comes from `document.cookie` (the wl_lang cookie) which
// doesn't exist on the server. We use `useSyncExternalStore` so
// the server snapshot ("es") and the client snapshot (cookie or
// navigator) are read from stable getSnapshot functions — no
// hydration mismatch, no cascading setState in an effect.
const lang = useSyncExternalStore(
subscribeNoop,
getClientLangSnapshot,
getServerLangSnapshot,
);
const [irrigatorName, setIrrigatorName] = useState<string | null>(null); const [irrigatorName, setIrrigatorName] = useState<string | null>(null);
const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]); const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
const [loadingHeadgates, setLoadingHeadgates] = useState(false); const [loadingHeadgates, setLoadingHeadgates] = useState(false);
@@ -57,6 +73,10 @@ export function MobileWaterApp() {
const [logFormKey, setLogFormKey] = useState(0); const [logFormKey, setLogFormKey] = useState(0);
// ── Headgate fetch (shared by initial load + pull-to-refresh) ── // ── Headgate fetch (shared by initial load + pull-to-refresh) ──
// `lang` is read here (instead of via `useLang`) because this
// orchestrator lives outside the <LangProvider>; the provider
// wraps each child screen. We just want the fallback error string
// in the user's chosen language.
const loadHeadgates = useCallback(async () => { const loadHeadgates = useCallback(async () => {
setLoadingHeadgates(true); setLoadingHeadgates(true);
setHeadgateError(null); setHeadgateError(null);
@@ -67,12 +87,12 @@ export function MobileWaterApp() {
setHeadgateError( setHeadgateError(
err instanceof Error err instanceof Error
? err.message ? err.message
: "Couldn't load headgates. Pull down to try again.", : getT(lang).headgates.loadError,
); );
} finally { } finally {
setLoadingHeadgates(false); setLoadingHeadgates(false);
} }
}, []); }, [lang]);
// ── Initial bootstrap: if `wl_session` exists, jump straight to // ── Initial bootstrap: if `wl_session` exists, jump straight to
// the headgate list. We defer the cookie read into an effect // the headgate list. We defer the cookie read into an effect
@@ -188,12 +208,17 @@ export function MobileWaterApp() {
}, []); }, []);
// ── Switch on the current screen ────────────────────────────── // ── Switch on the current screen ──────────────────────────────
// Compute the screen element first, then wrap it once in
// <LangProvider>. Wrapping per-case would remount the provider on
// every screen change, wiping lang state.
let screenElement: React.ReactNode = null;
switch (screen) { switch (screen) {
case "pin": case "pin":
return <MobileWaterPinScreen onSubmit={handlePinSubmit} />; screenElement = <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
break;
case "headgates": case "headgates":
return ( screenElement = (
<MobileWaterHeadgateList <MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined} irrigatorName={irrigatorName ?? undefined}
headgates={headgates} headgates={headgates}
@@ -204,14 +229,16 @@ export function MobileWaterApp() {
onLogout={handleLogout} onLogout={handleLogout}
/> />
); );
break;
case "log": case "log":
if (!selectedHeadgate) { if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list. // Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates")); queueMicrotask(() => setScreen("headgates"));
return null; screenElement = null;
break;
} }
return ( screenElement = (
<MobileWaterLogForm <MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`} key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate} headgate={selectedHeadgate}
@@ -221,13 +248,15 @@ export function MobileWaterApp() {
onLogout={handleLogout} onLogout={handleLogout}
/> />
); );
break;
case "success": case "success":
if (!submittedLog) { if (!submittedLog) {
queueMicrotask(() => setScreen("headgates")); queueMicrotask(() => setScreen("headgates"));
return null; screenElement = null;
break;
} }
return ( screenElement = (
<MobileWaterSuccessScreen <MobileWaterSuccessScreen
headgateName={submittedLog.headgateName} headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement} measurement={submittedLog.measurement}
@@ -238,7 +267,35 @@ export function MobileWaterApp() {
onBackToHeadgates={handleBackToHeadgates} onBackToHeadgates={handleBackToHeadgates}
/> />
); );
break;
} }
// `initialLang` is computed at module-load time — the value will
// match the first client render (SSR returns "es" because
// `document` is undefined; the client effect then syncs if the
// cookie disagrees). This avoids the hydration mismatch that a
// `useState(detectInitialLang)` initializer would cause.
return (
<LangProvider initialLang={lang}>{screenElement}</LangProvider>
);
} }
/**
* Subscribers don't make sense for the cookie source — it's read
* once on mount and updated when the user toggles via the
* `LanguageToggle` component (which writes to the same cookie +
* dispatches a manual re-render via setState in the provider).
*/
const subscribeNoop = () => () => {};
const getServerLangSnapshot = (): Lang => "es";
const getClientLangSnapshot = (): Lang => {
// Cache the value so we don't re-read the cookie on every render.
// The cookie is only mutated by the LanguageToggle's setLang →
// setLangCookie path, which causes a re-render through the
// provider's setState.
return detectInitialLang();
};
export default MobileWaterApp; export default MobileWaterApp;
+13 -11
View File
@@ -27,6 +27,7 @@ import {
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { Droplet, Lock, Spinner } from "./icons"; import { Droplet, Lock, Spinner } from "./icons";
import { useLang } from "@/lib/water-log/lang-context";
type Props = { type Props = {
/** Called when the user submits a 4-8 digit PIN. */ /** Called when the user submits a 4-8 digit PIN. */
@@ -36,6 +37,7 @@ type Props = {
}; };
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
const { t } = useLang();
const [pin, setPin] = useState(""); const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -72,7 +74,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
} catch (err) { } catch (err) {
// Network / unexpected error // Network / unexpected error
setError( setError(
err instanceof Error ? err.message : "Something went wrong. Try again.", err instanceof Error ? err.message : t.pin.genericError,
); );
setShake(true); setShake(true);
window.setTimeout(() => setShake(false), 420); window.setTimeout(() => setShake(false), 420);
@@ -81,7 +83,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
setSubmitting(false); setSubmitting(false);
} }
}, },
[pin, length, submitting, onSubmit], [pin, length, submitting, onSubmit, t.pin.genericError],
); );
const onFailureShake = useCallback(() => { const onFailureShake = useCallback(() => {
@@ -122,7 +124,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
TUXEDO WATER LOG {t.brand.waterLog}
</span> </span>
</div> </div>
</header> </header>
@@ -152,7 +154,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
Enter your PIN {t.pin.title}
</h1> </h1>
{/* Caption OR inline error */} {/* Caption OR inline error */}
@@ -164,7 +166,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
role={error ? "alert" : undefined} role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined} aria-live={error ? "assertive" : undefined}
> >
{error ?? "Sign in to log a water reading"} {error ?? t.pin.caption}
</p> </p>
{/* ── Digit boxes ────────────────────────────────────── */} {/* ── Digit boxes ────────────────────────────────────── */}
@@ -185,13 +187,13 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
// keyboard rises correctly. Visually hidden via the // keyboard rises correctly. Visually hidden via the
// standard "sr-only" technique. // standard "sr-only" technique.
className="sr-only" className="sr-only"
aria-label="PIN" aria-label={t.pin.a11yPin}
disabled={submitting} disabled={submitting}
/> />
<button <button
type="button" type="button"
onClick={() => inputRef.current?.focus()} onClick={() => inputRef.current?.focus()}
aria-label="PIN entry — tap to focus" aria-label={t.pin.a11yPinEntry}
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none" className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
> >
{Array.from({ length }).map((_, i) => { {Array.from({ length }).map((_, i) => {
@@ -234,7 +236,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
{/* Hidden submit so Enter from the keyboard triggers Unlock */} {/* Hidden submit so Enter from the keyboard triggers Unlock */}
<button type="submit" className="sr-only" tabIndex={-1}> <button type="submit" className="sr-only" tabIndex={-1}>
Unlock {t.pin.unlock}
</button> </button>
</form> </form>
@@ -263,16 +265,16 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
className="text-white" className="text-white"
style={{ animation: "spin 0.8s linear infinite" }} style={{ animation: "spin 0.8s linear infinite" }}
/> />
Verifying {t.pin.verifying}
</> </>
) : ( ) : (
<>Unlock</> <>{t.pin.unlock}</>
)} )}
</button> </button>
{/* Footer hint */} {/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]"> <p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
Forgot your PIN? Ask the ditch supervisor. {t.pin.forgot}
</p> </p>
</div> </div>
</main> </main>
+19 -11
View File
@@ -19,7 +19,9 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Grid, Refresh } from "./icons"; import { Grid, Refresh } from "./icons";
import { formatClockTime, formatLongTimestamp, formatMeasurement } from "./format"; import { formatClockTime, formatLongTimestamp, formatMeasurement, formatUnit } from "./format";
import { isIntegerUnit } from "./types";
import { useLang } from "@/lib/water-log/lang-context";
type Props = { type Props = {
headgateName: string; headgateName: string;
@@ -42,10 +44,16 @@ export function MobileWaterSuccessScreen({
onLogAnother, onLogAnother,
onBackToHeadgates, onBackToHeadgates,
}: Props) { }: Props) {
const { t, lang } = useLang();
// Reveal animation — staggered for ring → checkmark → content. // Reveal animation — staggered for ring → checkmark → content.
const [ringDone, setRingDone] = useState(false); const [ringDone, setRingDone] = useState(false);
const [contentShown, setContentShown] = useState(false); const [contentShown, setContentShown] = useState(false);
// Integer-count units (e.g. "holes") render without decimals on
// the recap. Reuse the shared catalog predicate so adding a new
// integer unit in `types.ts` automatically handles the recap too.
const integerUnit = isIntegerUnit(unit);
useEffect(() => { useEffect(() => {
const ringTimer = window.setTimeout(() => setRingDone(true), 380); const ringTimer = window.setTimeout(() => setRingDone(true), 380);
const contentTimer = window.setTimeout(() => setContentShown(true), 480); const contentTimer = window.setTimeout(() => setContentShown(true), 480);
@@ -81,7 +89,7 @@ export function MobileWaterSuccessScreen({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
TUXEDO WATER LOG {t.brand.waterLog}
</span> </span>
</div> </div>
</header> </header>
@@ -159,7 +167,7 @@ export function MobileWaterSuccessScreen({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
Reading logged {t.success.title}
</h1> </h1>
{/* Recap card */} {/* Recap card */}
@@ -181,7 +189,7 @@ export function MobileWaterSuccessScreen({
{headgateName} {headgateName}
</span> </span>
<span className="shrink-0 text-[13px] font-medium text-[rgba(60,60,67,0.45)]"> <span className="shrink-0 text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{formatClockTime(timestamp)} {formatClockTime(timestamp, lang)}
</span> </span>
</div> </div>
<div className="mt-1 flex items-baseline gap-1.5"> <div className="mt-1 flex items-baseline gap-1.5">
@@ -192,14 +200,14 @@ export function MobileWaterSuccessScreen({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}} }}
> >
{formatMeasurement(measurement)} {formatMeasurement(measurement, integerUnit)}
</span> </span>
<span className="pb-1 text-[16px] font-semibold text-[rgba(60,60,67,0.55)]"> <span className="pb-1 text-[16px] font-semibold text-[rgba(60,60,67,0.55)]">
{unit} {formatUnit(unit, lang)}
</span> </span>
</div> </div>
<div className="mt-2 border-t border-[rgba(60,60,67,0.10)] pt-2 text-[12px] text-[rgba(60,60,67,0.45)]"> <div className="mt-2 border-t border-[rgba(60,60,67,0.10)] pt-2 text-[12px] text-[rgba(60,60,67,0.45)]">
{formatLongTimestamp(timestamp)} {formatLongTimestamp(timestamp, lang)}
</div> </div>
{irrigatorName ? ( {irrigatorName ? (
<div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-[rgba(60,60,67,0.55)]"> <div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-[rgba(60,60,67,0.55)]">
@@ -209,7 +217,7 @@ export function MobileWaterSuccessScreen({
> >
{initials(irrigatorName)} {initials(irrigatorName)}
</span> </span>
<span>Logged by {irrigatorName}</span> <span>{t.success.loggedBy(irrigatorName)}</span>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -237,7 +245,7 @@ export function MobileWaterSuccessScreen({
}} }}
> >
<Refresh size={20} /> <Refresh size={20} />
Log Another Entry {t.success.logAnother}
</button> </button>
<button <button
@@ -249,12 +257,12 @@ export function MobileWaterSuccessScreen({
}} }}
> >
<Grid size={20} /> <Grid size={20} />
Back to Headgates {t.success.backToHeadgates}
</button> </button>
</div> </div>
<p className="mt-7 text-[13px] text-[rgba(60,60,67,0.45)]"> <p className="mt-7 text-[13px] text-[rgba(60,60,67,0.45)]">
Thanks for keeping the records flowing. {t.success.thanks}
</p> </p>
</div> </div>
</main> </main>
+50 -21
View File
@@ -4,8 +4,14 @@
* Pure functions, no React. Used by the PIN, headgate list, and log * Pure functions, no React. Used by the PIN, headgate list, and log
* screens for consistent display of timestamps, units, and "geocode" * screens for consistent display of timestamps, units, and "geocode"
* secondary text. * 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. * Format an ISO timestamp as a short relative phrase.
* "just now" < 45s * "just now" < 45s
@@ -14,17 +20,24 @@
* "Mar 14" same calendar year * "Mar 14" same calendar year
* "Mar 14 '25" previous calendar year * "Mar 14 '25" previous calendar year
*/ */
export function formatRelativeTime(iso: string | null): string { export function formatRelativeTime(
if (!iso) return "Never logged"; iso: string | null,
lang: Lang = "en",
): string {
const t = getT(lang).format;
if (!iso) return t.neverLogged;
const then = new Date(iso).getTime(); 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); const diffSec = Math.round((Date.now() - then) / 1000);
if (diffSec < 45) return "Just now"; if (diffSec < 45) return t.justNow;
if (diffSec < 60 * 60) return `${Math.round(diffSec / 60)}m ago`; if (diffSec < 60 * 60) return t.minutesAgo(Math.round(diffSec / 60));
if (diffSec < 60 * 60 * 24) return `${Math.round(diffSec / 3600)}h ago`; if (diffSec < 60 * 60 * 24) return t.hoursAgo(Math.round(diffSec / 3600));
const d = new Date(iso); const d = new Date(iso);
const sameYear = d.getFullYear() === new Date().getFullYear(); 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", month: "short",
day: "numeric", 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 * Format a Date as a 12-hour time suitable for the sticky log header
* (e.g. "2:37 PM"). * (e.g. "2:37 PM").
*/ */
export function formatClockTime(d: Date): string { export function formatClockTime(d: Date, lang: Lang = "en"): string {
return d.toLocaleTimeString("en-US", { const locale = lang === "es" ? "es" : "en-US";
return d.toLocaleTimeString(locale, {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: true, 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 * 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 { export function formatLongTimestamp(d: Date, lang: Lang = "en"): string {
const date = d.toLocaleDateString("en-US", { const locale = lang === "es" ? "es" : "en-US";
const date = d.toLocaleDateString(locale, {
weekday: "short", weekday: "short",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
return `${date} · ${formatClockTime(d)}`; return `${date} · ${formatClockTime(d, lang)}`;
} }
/** /**
* Format a measurement for the success-screen recap. Strips trailing * Format a measurement for the success-screen recap. Strips trailing
* zeros and clamps to 4 significant fraction digits so "12.5000" * 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 (!Number.isFinite(value)) return "0";
if (integer) return String(Math.round(value));
const fixed = value.toFixed(4); const fixed = value.toFixed(4);
return fixed.replace(/\.?0+$/, "") || "0"; 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 * a dedicated `geocode` column, so we fall back through the available
* context fields in priority order: * context fields in priority order:
* 1. headgate.notes — admin's free-text label * 1. headgate.notes — admin's free-text label
* 2. status label — "Open", "Closed", "Maintenance" * 2. status label — localized "Open · Ready to log" / etc.
* 3. default "Headgate" * 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(); if (h.notes && h.notes.trim()) return h.notes.trim();
const t = getT(lang).status;
const status = (h.status ?? "open").toLowerCase(); const status = (h.status ?? "open").toLowerCase();
if (status === "open") return "Open · Ready to log"; if (status === "open") return t.openReady;
if (status === "closed") return "Closed"; if (status === "closed") return t.closed;
if (status === "maintenance") return "Under maintenance"; if (status === "maintenance") return t.maintenance;
return status; 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 /** Type alias used by `headgateSubtitle` — accepts any object with the
* relevant subset, so callers don't need the full FieldHeadgate type. */ * relevant subset, so callers don't need the full FieldHeadgate type. */
type FieldHeadgateLike = { type FieldHeadgateLike = {
+20 -2
View File
@@ -20,10 +20,28 @@ export type FieldHeadgate = {
last_used_at: string | null; last_used_at: string | null;
}; };
/** The five units the mobile app offers in its segmented control. */ /** The units the mobile app offers in its segmented control.
export const MOBILE_UNITS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"] as const; *
* 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]; 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. */ /** Top-level screen identifiers — drives the state machine. */
export type Screen = "pin" | "headgates" | "log" | "success"; export type Screen = "pin" | "headgates" | "log" | "success";
+43
View File
@@ -45,6 +45,14 @@ export type Labels = {
save: string; save: string;
saving: string; saving: string;
back: 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. */ /** `/water` PIN entry screen. */
pin: { pin: {
@@ -54,6 +62,8 @@ export type Labels = {
unlock: string; unlock: string;
forgot: string; forgot: string;
genericError: string; genericError: string;
a11yPin: string;
a11yPinEntry: string;
}; };
/** `/water` headgate list screen. */ /** `/water` headgate list screen. */
headgates: { headgates: {
@@ -65,14 +75,19 @@ export type Labels = {
pullToRefresh: string; pullToRefresh: string;
thresholdHigh: string; thresholdHigh: string;
thresholdLow: string; thresholdLow: string;
refresh: string;
refreshing: string;
loadError: string;
}; };
/** `/water` measurement entry screen. */ /** `/water` measurement entry screen. */
log: { log: {
title: string; title: string;
measurement: string; measurement: string;
unit: string;
timestamp: string; timestamp: string;
loggedNow: string; loggedNow: string;
notes: string; notes: string;
notesOptional: string;
notesPlaceholder: string; notesPlaceholder: string;
notesVisibility: string; notesVisibility: string;
measurementError: string; measurementError: string;
@@ -124,6 +139,13 @@ export const LABELS: Record<Lang, Labels> = {
save: "Save", save: "Save",
saving: "Saving…", saving: "Saving…",
back: "Back", back: "Back",
signOut: "Sign out",
a11yBackToHeadgates: "Back to headgates",
a11yChangeHeadgate: "Change headgate",
a11ySignOut: "Sign out",
},
brand: {
waterLog: "TUXEDO WATER LOG",
}, },
pin: { pin: {
title: "Enter your PIN", title: "Enter your PIN",
@@ -132,6 +154,8 @@ export const LABELS: Record<Lang, Labels> = {
unlock: "Unlock", unlock: "Unlock",
forgot: "Forgot your PIN? Ask the ditch supervisor.", forgot: "Forgot your PIN? Ask the ditch supervisor.",
genericError: "Something went wrong. Try again.", genericError: "Something went wrong. Try again.",
a11yPin: "PIN",
a11yPinEntry: "PIN entry — tap to focus",
}, },
headgates: { headgates: {
title: "Headgates", title: "Headgates",
@@ -143,13 +167,18 @@ export const LABELS: Record<Lang, Labels> = {
pullToRefresh: "Pull down to refresh the list", pullToRefresh: "Pull down to refresh the list",
thresholdHigh: "Hi", thresholdHigh: "Hi",
thresholdLow: "Lo", thresholdLow: "Lo",
refresh: "Refresh",
refreshing: "Checking…",
loadError: "Couldn't load headgates. Pull down to try again.",
}, },
log: { log: {
title: "Log Reading", title: "Log Reading",
measurement: "Measurement", measurement: "Measurement",
unit: "Unit",
timestamp: "Timestamp", timestamp: "Timestamp",
loggedNow: "Logged now", loggedNow: "Logged now",
notes: "Notes", notes: "Notes",
notesOptional: "optional",
notesPlaceholder: notesPlaceholder:
"Anything the supervisor should know about this reading?", "Anything the supervisor should know about this reading?",
notesVisibility: "Visible to admins only", notesVisibility: "Visible to admins only",
@@ -197,6 +226,13 @@ export const LABELS: Record<Lang, Labels> = {
save: "Guardar", save: "Guardar",
saving: "Guardando…", saving: "Guardando…",
back: "Atrás", back: "Atrás",
signOut: "Salir",
a11yBackToHeadgates: "Volver a compuertas",
a11yChangeHeadgate: "Cambiar compuerta",
a11ySignOut: "Cerrar sesión",
},
brand: {
waterLog: "REGISTRO DE AGUA TUXEDO",
}, },
pin: { pin: {
title: "Ingresa tu PIN", title: "Ingresa tu PIN",
@@ -205,6 +241,8 @@ export const LABELS: Record<Lang, Labels> = {
unlock: "Desbloquear", unlock: "Desbloquear",
forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.", forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.",
genericError: "Algo salió mal. Inténtalo de nuevo.", genericError: "Algo salió mal. Inténtalo de nuevo.",
a11yPin: "PIN",
a11yPinEntry: "Entrada de PIN — toca para enfocar",
}, },
headgates: { headgates: {
title: "Compuertas", title: "Compuertas",
@@ -216,13 +254,18 @@ export const LABELS: Record<Lang, Labels> = {
pullToRefresh: "Jala hacia abajo para actualizar la lista", pullToRefresh: "Jala hacia abajo para actualizar la lista",
thresholdHigh: "Alto", thresholdHigh: "Alto",
thresholdLow: "Bajo", thresholdLow: "Bajo",
refresh: "Actualizar",
refreshing: "Verificando…",
loadError: "No se pudieron cargar las compuertas. Jala para intentar de nuevo.",
}, },
log: { log: {
title: "Registrar lectura", title: "Registrar lectura",
measurement: "Medición", measurement: "Medición",
unit: "Unidad",
timestamp: "Hora", timestamp: "Hora",
loggedNow: "Registrando ahora", loggedNow: "Registrando ahora",
notes: "Notas", notes: "Notas",
notesOptional: "opcional",
notesPlaceholder: notesPlaceholder:
"¿Algo que el supervisor deba saber sobre esta lectura?", "¿Algo que el supervisor deba saber sobre esta lectura?",
notesVisibility: "Visible solo para administradores", notesVisibility: "Visible solo para administradores",
+296
View File
@@ -0,0 +1,296 @@
/**
* Unit tests for the shared i18n module.
*
* Covers the label dictionary (en/es), the unit display mapping,
* the formatter helpers, and the lang-detection function. The
* vitest env is "node" (no jsdom), so we stub `document` and
* `navigator` directly via property assignment.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
detectInitialLang,
displayUnit,
getT,
LABELS,
setLangCookie,
WL_LANG_COOKIE,
type Lang,
} from "@/lib/water-log/i18n";
// ── Minimal DOM stub ─────────────────────────────────────────────────
type CookieStore = Record<string, string>;
function installDomStub(initialCookies: CookieStore = {}) {
const cookies: CookieStore = { ...initialCookies };
const document = {
get cookie() {
return Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
},
set cookie(value: string) {
// Parse "name=value; ..." — keep just the first pair.
const [pair] = value.split(";");
const eq = pair.indexOf("=");
if (eq < 0) return;
const name = pair.slice(0, eq).trim();
const val = pair.slice(eq + 1).trim();
// Treat "name=; expires=..." as a delete.
if (
val === "" ||
/expires=Thu, 01 Jan 1970/i.test(value) ||
/max-age=0/i.test(value)
) {
delete cookies[name];
} else {
cookies[name] = val;
}
},
};
const originalDoc = (globalThis as { document?: unknown }).document;
const originalNav = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, "document", {
value: document,
writable: true,
configurable: true,
});
return {
setNav(nav: { language: string } | undefined) {
Object.defineProperty(globalThis, "navigator", {
value: nav,
writable: true,
configurable: true,
});
},
restore() {
Object.defineProperty(globalThis, "document", {
value: originalDoc,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: originalNav,
writable: true,
configurable: true,
});
},
};
}
// ── Lang dictionary ────────────────────────────────────────────────────
describe("LABELS dictionary", () => {
it("has both en and es keys", () => {
expect(LABELS.en).toBeDefined();
expect(LABELS.es).toBeDefined();
});
it("en and es share the same top-level screen keys", () => {
const enKeys = Object.keys(LABELS.en).sort();
const esKeys = Object.keys(LABELS.es).sort();
expect(esKeys).toEqual(enKeys);
});
it("every screen's label has matching en and es inner keys", () => {
for (const screen of Object.keys(LABELS.en) as Array<
keyof typeof LABELS.en
>) {
const enScreen = LABELS.en[screen] as Record<string, unknown>;
const esScreen = LABELS.es[screen] as Record<string, unknown>;
const enInner = Object.keys(enScreen).sort();
const esInner = Object.keys(esScreen).sort();
expect(esInner, `screen "${String(screen)}"`).toEqual(enInner);
}
});
it("PIN screen labels are non-empty and translated", () => {
expect(LABELS.en.pin.title.length).toBeGreaterThan(0);
expect(LABELS.es.pin.title.length).toBeGreaterThan(0);
expect(LABELS.en.pin.title).not.toBe(LABELS.es.pin.title);
});
});
// ── getT ──────────────────────────────────────────────────────────────
describe("getT()", () => {
it("returns the en dict for 'en'", () => {
expect(getT("en")).toBe(LABELS.en);
});
it("returns the es dict for 'es'", () => {
expect(getT("es")).toBe(LABELS.es);
});
});
// ── displayUnit ──────────────────────────────────────────────────────
describe("displayUnit()", () => {
it("translates 'holes' to 'hoyos' in Spanish", () => {
expect(displayUnit("holes", "es")).toBe("hoyos");
});
it("leaves 'holes' as 'holes' in English", () => {
expect(displayUnit("holes", "en")).toBe("holes");
});
it("leaves CFS, GPM, gal, ac-in, ac-ft unchanged in both langs", () => {
const passThrough: Lang[] = ["en", "es"];
for (const lang of passThrough) {
expect(displayUnit("CFS", lang)).toBe("CFS");
expect(displayUnit("GPM", lang)).toBe("GPM");
expect(displayUnit("gal", lang)).toBe("gal");
expect(displayUnit("ac-in", lang)).toBe("ac-in");
expect(displayUnit("ac-ft", lang)).toBe("ac-ft");
}
});
it("passes through unknown units unchanged", () => {
expect(displayUnit("custom-unit", "en")).toBe("custom-unit");
expect(displayUnit("custom-unit", "es")).toBe("custom-unit");
});
});
// ── detectInitialLang ───────────────────────────────────────────────
describe("detectInitialLang()", () => {
let stub: ReturnType<typeof installDomStub>;
beforeEach(() => {
stub = installDomStub();
});
afterEach(() => {
stub.restore();
});
it("returns 'es' when document is undefined (SSR)", () => {
Object.defineProperty(globalThis, "document", {
value: undefined,
writable: true,
configurable: true,
});
expect(detectInitialLang()).toBe("es");
});
it("returns 'en' when wl_lang cookie is 'en'", () => {
document.cookie = `${WL_LANG_COOKIE}=en; path=/`;
expect(detectInitialLang()).toBe("en");
});
it("returns 'es' when wl_lang cookie is 'es'", () => {
document.cookie = `${WL_LANG_COOKIE}=es; path=/`;
expect(detectInitialLang()).toBe("es");
});
it("ignores a malformed wl_lang cookie and falls back to navigator", () => {
document.cookie = `${WL_LANG_COOKIE}=fr; path=/`;
stub.setNav({ language: "en-US" });
expect(detectInitialLang()).toBe("en");
});
it("falls back to 'es' when no cookie and navigator is non-English", () => {
stub.setNav({ language: "fr-FR" });
expect(detectInitialLang()).toBe("es");
});
});
// ── setLangCookie ───────────────────────────────────────────────────
describe("setLangCookie()", () => {
let stub: ReturnType<typeof installDomStub>;
beforeEach(() => {
stub = installDomStub();
});
afterEach(() => {
stub.restore();
});
it("writes the wl_lang cookie with the chosen lang", () => {
setLangCookie("en");
expect(document.cookie).toMatch(/wl_lang=en/);
});
it("overwrites a previous value", () => {
setLangCookie("en");
setLangCookie("es");
expect(document.cookie).toMatch(/wl_lang=es/);
});
it("is a no-op when document is undefined (SSR)", () => {
Object.defineProperty(globalThis, "document", {
value: undefined,
writable: true,
configurable: true,
});
expect(() => setLangCookie("en")).not.toThrow();
});
});
// ── Mobile label interpolation ─────────────────────────────────────
describe("Mobile label helpers (interpolation)", () => {
it("headgates.signedInAs interpolates the name", () => {
expect(LABELS.en.headgates.signedInAs("Tyler")).toBe(
"Signed in as Tyler",
);
expect(LABELS.es.headgates.signedInAs("Tyler")).toBe(
"Sesión iniciada como Tyler",
);
});
it("log.normalRange interpolates the bounds + unit", () => {
expect(LABELS.en.log.normalRange(2, 8, "CFS")).toBe(
"Normal range: 2 8 CFS",
);
expect(LABELS.es.log.normalRange(2, 8, "CFS")).toBe(
"Rango normal: 2 8 CFS",
);
});
it("log.alertAbove / alertBelow interpolate single bound + unit", () => {
expect(LABELS.en.log.alertAbove(10, "CFS")).toBe("Alert above 10 CFS");
expect(LABELS.es.log.alertAbove(10, "CFS")).toBe(
"Alerta arriba de 10 CFS",
);
expect(LABELS.en.log.alertBelow(1, "CFS")).toBe("Alert below 1 CFS");
expect(LABELS.es.log.alertBelow(1, "CFS")).toBe("Alerta abajo de 1 CFS");
});
it("success.loggedBy interpolates the irrigator name", () => {
expect(LABELS.en.success.loggedBy("Maria")).toBe("Logged by Maria");
expect(LABELS.es.success.loggedBy("Maria")).toBe("Registrado por Maria");
});
it("format.neverLogged / justNow are non-empty and translated", () => {
expect(LABELS.en.format.neverLogged.length).toBeGreaterThan(0);
expect(LABELS.es.format.neverLogged.length).toBeGreaterThan(0);
expect(LABELS.en.format.justNow).not.toBe(LABELS.es.format.justNow);
});
it("format.minutesAgo / hoursAgo interpolate the count", () => {
expect(LABELS.en.format.minutesAgo(5)).toBe("5m ago");
expect(LABELS.es.format.minutesAgo(5)).toBe("hace 5m");
expect(LABELS.en.format.hoursAgo(3)).toBe("3h ago");
expect(LABELS.es.format.hoursAgo(3)).toBe("hace 3h");
});
});
// ── Unit catalog (regression guard for "holes") ─────────────────────
describe("Unit catalog includes 'holes'", () => {
it("'holes' is in the en catalog and translates to 'hoyos' in es", () => {
expect(LABELS.en.units.holes).toBe("holes");
expect(LABELS.es.units.holes).toBe("hoyos");
});
it("all six MOBILE_UNITS keys exist in the units dict for both langs", () => {
const expected = ["CFS", "GPM", "gal", "ac-in", "ac-ft", "holes"];
for (const unit of expected) {
expect(LABELS.en.units).toHaveProperty(unit);
expect(LABELS.es.units).toHaveProperty(unit);
}
});
});