feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
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:
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user