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,
} 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}
</h1>
<button
type="button"
onClick={onLogout}
aria-label="Sign out"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
<Logout size={20} />
</button>
<div className="flex shrink-0 items-center gap-1.5 pr-1">
<LanguageToggle size="sm" />
<button
type="button"
onClick={onLogout}
aria-label={t.common.a11ySignOut}
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
<Logout size={20} />
</button>
</div>
</header>
{/* ── Scrollable list ─────────────────────────────────────── */}
@@ -101,7 +107,7 @@ export function MobileWaterHeadgateList({
<div className="px-4 pb-[max(env(safe-area-inset-bottom),1.25rem)] pt-3">
{irrigatorName && (
<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>
)}
@@ -119,7 +125,7 @@ export function MobileWaterHeadgateList({
{/* Section header */}
<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)]">
Active headgates
{t.headgates.activeSection}
</h2>
<span className="text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{active.length}
@@ -138,6 +144,7 @@ export function MobileWaterHeadgateList({
headgate={hg}
isLast={i === active.length - 1}
onClick={() => onSelect(hg)}
lang={lang}
/>
</li>
))}
@@ -147,7 +154,7 @@ export function MobileWaterHeadgateList({
{/* Footer note */}
{active.length > 0 && (
<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>
)}
</div>
@@ -162,13 +169,17 @@ function HeadgateCard({
headgate,
isLast,
onClick,
lang,
}: {
headgate: FieldHeadgate;
isLast: boolean;
onClick: () => void;
lang: "en" | "es";
}) {
const subtitle = headgateSubtitle(headgate);
const lastUsed = formatRelativeTime(headgate.last_used_at);
const { t } = useLang();
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 isMaintenance = headgate.status === "maintenance";
@@ -222,7 +233,8 @@ function HeadgateCard({
headgate.low_threshold != null) && (
<ThresholdDot
high={headgate.high_threshold}
low={headgate.low_threshold}
labelHigh={t.headgates.thresholdHigh}
labelLow={t.headgates.thresholdLow}
/>
)}
</div>
@@ -232,7 +244,7 @@ function HeadgateCard({
</div>
<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">
{headgate.unit}
{unitLabel}
</span>
<span>·</span>
<span>{lastUsed}</span>
@@ -249,9 +261,12 @@ function HeadgateCard({
function ThresholdDot({
high,
labelHigh,
labelLow,
}: {
high: number | null;
low: number | null;
labelHigh: string;
labelLow: string;
}) {
return (
<span
@@ -261,7 +276,7 @@ function ThresholdDot({
color: high != null ? "#7f1d1d" : "#1e3a8a",
}}
>
{high != null ? "Hi" : "Lo"}
{high != null ? labelHigh : labelLow}
</span>
);
}
@@ -299,6 +314,7 @@ function EmptyState({
onRefresh: () => Promise<void> | void;
refreshing: boolean;
}) {
const { t } = useLang();
const handleRefresh = useCallback(async () => {
if (!refreshing) await onRefresh();
}, [onRefresh, refreshing]);
@@ -309,11 +325,10 @@ function EmptyState({
<Droplet size={28} className="text-[#14532d]" />
</div>
<h3 className="mb-1 text-[17px] font-semibold text-[#1d1d1f]">
No active headgates
{t.headgates.emptyTitle}
</h3>
<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.
Pull down to check for updates.
{t.headgates.emptyBody}
</p>
<button
type="button"
@@ -327,10 +342,10 @@ function EmptyState({
{refreshing ? (
<>
<Spinner size={16} className="text-white" style={{ animation: "spin 0.8s linear infinite" }} />
Checking
{t.headgates.refreshing}
</>
) : (
"Refresh"
t.headgates.refresh
)}
</button>
</div>
+67 -33
View File
@@ -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<string>(
@@ -72,6 +75,11 @@ export function MobileWaterLogForm({
const [now, setNow] = useState<Date>(() => new Date());
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
// rises immediately.
useEffect(() => {
@@ -91,24 +99,32 @@ export function MobileWaterLogForm({
const thresholdHint = useMemo(() => {
const h = headgate.high_threshold;
const l = headgate.low_threshold;
const u = formatUnit(unit, lang);
if (h == null && l == null) return 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}`;
return `Alert below ${l} ${unit}`;
}, [headgate, unit]);
if (h != null) return t.log.alertAbove(h, u);
// TS narrowing: reaching this line means `h == null && l != null`.
return t.log.alertBelow(l as number, u);
}, [headgate, unit, lang, t]);
const submitted = (e?: FormEvent) => {
e?.preventDefault();
if (submitting) return;
const value = parseFloat(measurement);
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;
}
if (value > 1_000_000) {
setError("That value is implausibly large");
setError(t.log.implausiblyLarge);
return;
}
setSubmitting(true);
@@ -116,18 +132,24 @@ export function MobileWaterLogForm({
onSubmit({ measurement: value, unit, notes })
.catch((err) => {
setError(
err instanceof Error ? err.message : "Couldn't submit. Try again.",
err instanceof Error ? err.message : t.log.submitting,
);
setSubmitting(false);
});
// 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 =
measurement.trim().length > 0 &&
Number.isFinite(parseFloat(measurement)) &&
parseFloat(measurement) >= 0 &&
Number.isFinite(parsed) &&
parsed >= 0 &&
(!integerUnit || Number.isInteger(parsed)) &&
!submitting;
return (
@@ -149,7 +171,7 @@ export function MobileWaterLogForm({
<button
type="button"
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)]"
>
<ChevronLeft size={22} />
@@ -163,7 +185,7 @@ export function MobileWaterLogForm({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Log Reading
{t.log.title}
</h1>
{irrigatorName ? (
<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
type="button"
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)]"
>
<Logout size={20} />
@@ -200,7 +222,7 @@ export function MobileWaterLogForm({
<button
type="button"
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)]"
style={{
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>
<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>
</button>
{/* ── 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="flex items-baseline gap-2">
<input
ref={measurementRef}
type="number"
inputMode="decimal"
step="any"
// Integer-count units (holes) bring up the numeric
// keypad without a decimal key, making it physically
// harder to type 2.5.
inputMode={integerUnit ? "numeric" : "decimal"}
step={integerUnit ? "1" : "any"}
min={0}
value={measurement}
onChange={(e) => {
@@ -245,7 +270,7 @@ export function MobileWaterLogForm({
if (error) setError(null);
}}
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)]"
style={{
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)]">
{unit}
{unitLabel}
</span>
</div>
{thresholdHint && (
@@ -266,35 +291,43 @@ export function MobileWaterLogForm({
)}
</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">
<SegmentedControl
ariaLabel="Unit"
ariaLabel={t.log.unit}
value={unit as MobileUnit}
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>
{/* ── 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="flex h-9 w-9 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)] text-[#14532d]">
<Clock size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-[#1d1d1f]">
Logged now
{t.log.loggedNow}
</div>
<div className="mt-0.5 truncate text-[13px] text-[rgba(60,60,67,0.6)]">
{formatLongTimestamp(now)}
{formatLongTimestamp(now, lang)}
</div>
</div>
</div>
{/* ── Notes section ────────────────────────────────── */}
<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>
<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">
@@ -307,7 +340,7 @@ export function MobileWaterLogForm({
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
rows={3}
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)]"
style={{
fontFamily:
@@ -316,7 +349,7 @@ export function MobileWaterLogForm({
/>
</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)]">
<span>Visible to admins only</span>
<span>{t.log.notesVisibility}</span>
<span>
{notes.length}/{NOTES_MAX}
</span>
@@ -362,14 +395,15 @@ export function MobileWaterLogForm({
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
Submitting
{t.log.submitting}
</>
) : (
<>
Submit Log
{t.log.submit}
{measurement && Number.isFinite(parseFloat(measurement)) && (
<span className="ml-1 opacity-80">
· {formatMeasurement(parseFloat(measurement))} {unit}
· {formatMeasurement(parseFloat(measurement), integerUnit)}{" "}
{unitLabel}
</span>
)}
</>
+66 -9
View File
@@ -27,7 +27,7 @@
* - `submitWaterEntry` → posts a new reading
* - `logoutWater` → clears the cookie + session row
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import {
verifyWaterPin,
submitWaterEntry,
@@ -41,9 +41,25 @@ import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
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() {
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 [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
const [loadingHeadgates, setLoadingHeadgates] = useState(false);
@@ -57,6 +73,10 @@ export function MobileWaterApp() {
const [logFormKey, setLogFormKey] = useState(0);
// ── 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 () => {
setLoadingHeadgates(true);
setHeadgateError(null);
@@ -67,12 +87,12 @@ export function MobileWaterApp() {
setHeadgateError(
err instanceof Error
? err.message
: "Couldn't load headgates. Pull down to try again.",
: getT(lang).headgates.loadError,
);
} finally {
setLoadingHeadgates(false);
}
}, []);
}, [lang]);
// ── Initial bootstrap: if `wl_session` exists, jump straight to
// the headgate list. We defer the cookie read into an effect
@@ -188,12 +208,17 @@ export function MobileWaterApp() {
}, []);
// ── 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) {
case "pin":
return <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
screenElement = <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
break;
case "headgates":
return (
screenElement = (
<MobileWaterHeadgateList
irrigatorName={irrigatorName ?? undefined}
headgates={headgates}
@@ -204,14 +229,16 @@ export function MobileWaterApp() {
onLogout={handleLogout}
/>
);
break;
case "log":
if (!selectedHeadgate) {
// Defensive: shouldn't happen, but bounce back to list.
queueMicrotask(() => setScreen("headgates"));
return null;
screenElement = null;
break;
}
return (
screenElement = (
<MobileWaterLogForm
key={`${selectedHeadgate.id}-${logFormKey}`}
headgate={selectedHeadgate}
@@ -221,13 +248,15 @@ export function MobileWaterApp() {
onLogout={handleLogout}
/>
);
break;
case "success":
if (!submittedLog) {
queueMicrotask(() => setScreen("headgates"));
return null;
screenElement = null;
break;
}
return (
screenElement = (
<MobileWaterSuccessScreen
headgateName={submittedLog.headgateName}
measurement={submittedLog.measurement}
@@ -238,7 +267,35 @@ export function MobileWaterApp() {
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;
+13 -11
View File
@@ -27,6 +27,7 @@ import {
type FormEvent,
} from "react";
import { Droplet, Lock, Spinner } from "./icons";
import { useLang } from "@/lib/water-log/lang-context";
type Props = {
/** Called when the user submits a 4-8 digit PIN. */
@@ -36,6 +37,7 @@ type Props = {
};
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
const { t } = useLang();
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -72,7 +74,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
} catch (err) {
// Network / unexpected error
setError(
err instanceof Error ? err.message : "Something went wrong. Try again.",
err instanceof Error ? err.message : t.pin.genericError,
);
setShake(true);
window.setTimeout(() => setShake(false), 420);
@@ -81,7 +83,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit],
[pin, length, submitting, onSubmit, t.pin.genericError],
);
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",
}}
>
TUXEDO WATER LOG
{t.brand.waterLog}
</span>
</div>
</header>
@@ -152,7 +154,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Enter your PIN
{t.pin.title}
</h1>
{/* Caption OR inline error */}
@@ -164,7 +166,7 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? "Sign in to log a water reading"}
{error ?? t.pin.caption}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
@@ -185,13 +187,13 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
// keyboard rises correctly. Visually hidden via the
// standard "sr-only" technique.
className="sr-only"
aria-label="PIN"
aria-label={t.pin.a11yPin}
disabled={submitting}
/>
<button
type="button"
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"
>
{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 */}
<button type="submit" className="sr-only" tabIndex={-1}>
Unlock
{t.pin.unlock}
</button>
</form>
@@ -263,16 +265,16 @@ export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
Verifying
{t.pin.verifying}
</>
) : (
<>Unlock</>
<>{t.pin.unlock}</>
)}
</button>
{/* Footer hint */}
<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>
</div>
</main>
+19 -11
View File
@@ -19,7 +19,9 @@
*/
import { useEffect, useState } from "react";
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 = {
headgateName: string;
@@ -42,10 +44,16 @@ export function MobileWaterSuccessScreen({
onLogAnother,
onBackToHeadgates,
}: Props) {
const { t, lang } = useLang();
// Reveal animation — staggered for ring → checkmark → content.
const [ringDone, setRingDone] = 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(() => {
const ringTimer = window.setTimeout(() => setRingDone(true), 380);
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",
}}
>
TUXEDO WATER LOG
{t.brand.waterLog}
</span>
</div>
</header>
@@ -159,7 +167,7 @@ export function MobileWaterSuccessScreen({
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Reading logged
{t.success.title}
</h1>
{/* Recap card */}
@@ -181,7 +189,7 @@ export function MobileWaterSuccessScreen({
{headgateName}
</span>
<span className="shrink-0 text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{formatClockTime(timestamp)}
{formatClockTime(timestamp, lang)}
</span>
</div>
<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",
}}
>
{formatMeasurement(measurement)}
{formatMeasurement(measurement, integerUnit)}
</span>
<span className="pb-1 text-[16px] font-semibold text-[rgba(60,60,67,0.55)]">
{unit}
{formatUnit(unit, lang)}
</span>
</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)]">
{formatLongTimestamp(timestamp)}
{formatLongTimestamp(timestamp, lang)}
</div>
{irrigatorName ? (
<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)}
</span>
<span>Logged by {irrigatorName}</span>
<span>{t.success.loggedBy(irrigatorName)}</span>
</div>
) : null}
</div>
@@ -237,7 +245,7 @@ export function MobileWaterSuccessScreen({
}}
>
<Refresh size={20} />
Log Another Entry
{t.success.logAnother}
</button>
<button
@@ -249,12 +257,12 @@ export function MobileWaterSuccessScreen({
}}
>
<Grid size={20} />
Back to Headgates
{t.success.backToHeadgates}
</button>
</div>
<p className="mt-7 text-[13px] text-[rgba(60,60,67,0.45)]">
Thanks for keeping the records flowing.
{t.success.thanks}
</p>
</div>
</main>
+50 -21
View File
@@ -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 = {
+20 -2
View File
@@ -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";