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,
|
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'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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user