diff --git a/src/components/water/mobile/ThresholdMeter.tsx b/src/components/water/mobile/ThresholdMeter.tsx
new file mode 100644
index 0000000..a59b23e
--- /dev/null
+++ b/src/components/water/mobile/ThresholdMeter.tsx
@@ -0,0 +1,298 @@
+"use client";
+
+/**
+ * ThresholdMeter — a live "blip" indicator under the 44pt
+ * measurement input that ties the form to its real domain (a
+ * worker reading a real gauge) instead of being a decorative
+ * gauge trophy.
+ *
+ * This is the screen's signature element. It mirrors Apple's
+ * Volume Limit / Screen Time sliders: a neutral track with
+ * colored zones (red → green → red) and a white thumb with a
+ * colored ring that slides horizontally as the worker types.
+ *
+ * Visual layering, back to front:
+ * 1. Neutral track (iOS systemGray, 6px tall, fully rounded).
+ * 2. Optional colored zones (only when thresholds are set):
+ * below-low · between low-high · above-high.
+ * 3. Thumb (18px white circle with a 3px colored ring) that
+ * uses `transform: translateX(...)` with Apple's standard
+ * spring curve, so the motion reads as physical.
+ * 4. Two-line status caption: left = color-coded zone label,
+ * right = numeric scale (monospaced).
+ *
+ * Auto-grows the visual scale to fit the entered value when no
+ * thresholds are configured, so the bar still has a usable
+ * range even on first-run headgates without admin-configured
+ * limits.
+ */
+
+import { useMemo } from "react";
+import { formatMeasurement, formatUnit } from "./format";
+import { isIntegerUnit } from "./types";
+import { useLang } from "@/lib/water-log/lang-context";
+
+type Zone = "none" | "low" | "normal" | "high";
+
+type Props = {
+ /** Raw input value (string from the input). Empty → bar shows no thumb. */
+ value: string;
+ /** Selected unit. Captions + scale thumb reposition on changes. */
+ unit: string;
+ /** Headgate's configured low threshold, or null. */
+ lowThreshold: number | null;
+ /** Headgate's configured high threshold, or null. */
+ highThreshold: number | null;
+};
+
+// Apple system colors — pulled from HIG. Reusing the same palette
+// as the success button (systemGreen) and the error toast
+// (systemRed) so the meter's color language is already familiar
+// to anyone who's used the rest of the app.
+const COLOR_TRACK = "rgba(120,120,128,0.16)";
+const COLOR_NORMAL = "#34C759";
+const COLOR_ALERT = "#FF3B30";
+
+export function ThresholdMeter({
+ value,
+ unit,
+ lowThreshold,
+ highThreshold,
+}: Props) {
+ const { t, lang } = useLang();
+
+ const numeric = parseFloat(value);
+ const hasValue =
+ value.trim().length > 0 && Number.isFinite(numeric) && numeric >= 0;
+ const integerUnit = isIntegerUnit(unit);
+ const unitLabel = formatUnit(unit, lang);
+ const hasThresholds = lowThreshold != null || highThreshold != null;
+
+ // ── Visual scale (max value the track represents) ────────
+ // Grow to whichever is largest: 1.5× the high threshold (so
+ // there's always a "tail" of alert zone past the alert line),
+ // 2× the low threshold (so the in-range area starts before
+ // the low edge — symmetric on either side), or 1.3× the
+ // entered value (so the thumb isn't pinned at the right edge
+ // for the typical first entry).
+ const maxScale = useMemo(() => {
+ const seed = hasValue ? numeric : 0;
+ if (highThreshold != null && lowThreshold != null) {
+ return Math.max(highThreshold * 1.5, lowThreshold * 0.6, seed * 1.2, 10);
+ }
+ if (highThreshold != null) {
+ return Math.max(highThreshold * 1.5, seed * 1.2, 10);
+ }
+ if (lowThreshold != null) {
+ return Math.max(lowThreshold * 1.8, seed * 1.3, 10);
+ }
+ return Math.max(seed * 1.3, 10);
+ }, [hasValue, numeric, lowThreshold, highThreshold]);
+
+ // ── Marker position & zone fills ───────────────────────
+ const markerPct = hasValue
+ ? Math.min(1, Math.max(0, numeric / maxScale))
+ : 0;
+
+ const lowPct =
+ lowThreshold != null
+ ? Math.min(1, Math.max(0, lowThreshold / maxScale))
+ : 0;
+ const highPct =
+ highThreshold != null
+ ? Math.min(1, Math.max(0, highThreshold / maxScale))
+ : 1;
+
+ // ── Zone classification ─────────────────────────────────
+ const zone: Zone = !hasValue
+ ? "none"
+ : lowThreshold != null && numeric < lowThreshold
+ ? "low"
+ : highThreshold != null && numeric > highThreshold
+ ? "high"
+ : "normal";
+
+ // ── Captions ────────────────────────────────────────────
+ const displayValue = hasValue
+ ? formatMeasurement(numeric, integerUnit)
+ : null;
+
+ let leftLabel: string;
+ let leftColor: string;
+ let rightLabel: string | null;
+
+ if (!hasValue) {
+ if (hasThresholds && lowThreshold != null && highThreshold != null) {
+ leftLabel = t.log.meterNormalRange(
+ formatMeasurement(lowThreshold, integerUnit),
+ formatMeasurement(highThreshold, integerUnit),
+ unitLabel,
+ );
+ } else if (highThreshold != null) {
+ leftLabel = t.log.alertAbove(
+ formatMeasurement(highThreshold, integerUnit),
+ unitLabel,
+ );
+ } else if (lowThreshold != null) {
+ leftLabel = t.log.alertBelow(
+ formatMeasurement(lowThreshold, integerUnit),
+ unitLabel,
+ );
+ } else {
+ leftLabel = t.log.zoneTypeToLog;
+ }
+ leftColor = "rgba(60,60,67,0.5)";
+ rightLabel = null;
+ } else if (zone === "low") {
+ leftLabel = t.log.zoneBelowNormal;
+ leftColor = COLOR_ALERT;
+ rightLabel = t.log.alertBelow(
+ formatMeasurement(lowThreshold as number, integerUnit),
+ unitLabel,
+ );
+ } else if (zone === "high") {
+ leftLabel = t.log.zoneAboveAlert;
+ leftColor = COLOR_ALERT;
+ rightLabel = t.log.alertAbove(
+ formatMeasurement(highThreshold as number, integerUnit),
+ unitLabel,
+ );
+ } else if (lowThreshold != null && highThreshold != null) {
+ leftLabel = t.log.zoneInRange;
+ leftColor = COLOR_NORMAL;
+ rightLabel = t.log.meterNormalRange(
+ formatMeasurement(lowThreshold, integerUnit),
+ formatMeasurement(highThreshold, integerUnit),
+ unitLabel,
+ );
+ } else if (highThreshold != null) {
+ leftLabel = t.log.zoneInRange;
+ leftColor = COLOR_NORMAL;
+ rightLabel = t.log.alertAbove(
+ formatMeasurement(highThreshold, integerUnit),
+ unitLabel,
+ );
+ } else if (lowThreshold != null) {
+ leftLabel = t.log.zoneAboveMin;
+ leftColor = COLOR_NORMAL;
+ rightLabel = t.log.alertBelow(
+ formatMeasurement(lowThreshold, integerUnit),
+ unitLabel,
+ );
+ } else {
+ leftLabel = t.log.zoneReading;
+ leftColor = "rgba(60,60,67,0.85)";
+ rightLabel = null;
+ }
+
+ // Append "· {value} {unit}" to the left label only when the
+ // zone label is meaningful on its own. That keeps the empty /
+ // no-threshold states readable (no trailing "· undefined CFS").
+ const formattedReading = hasValue
+ ? `${displayValue} ${unitLabel}`
+ : null;
+
+ return (
+
+ {/* ── Track + zones + thumb ──────────────────────────── */}
+
+ {/* 1. Neutral track */}
+
+ {/* 2a. Below-low zone */}
+ {hasThresholds && (
+
+ )}
+ {/* 2b. Normal zone */}
+ {hasThresholds && (
+
+ )}
+ {/* 2c. Above-high zone */}
+ {hasThresholds && highThreshold != null && (
+
+ )}
+ {/* 3. Thumb */}
+ {hasValue && (
+
+ )}
+
+ {/* ── Caption (zone label + scale range) ─────────────── */}
+
+
+ {leftLabel}
+ {formattedReading && (
+ <>
+ ·
+ {formattedReading}
+ >
+ )}
+
+ {rightLabel && zone === "normal" && (
+
+ {rightLabel}
+
+ )}
+
+
+ );
+}
+
+export default ThresholdMeter;
diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts
index a0ca81b..3d3f736 100644
--- a/src/lib/water-log/i18n.ts
+++ b/src/lib/water-log/i18n.ts
@@ -102,9 +102,29 @@ export type Labels = {
submitError: string;
/** Defensive guard — shouldn't ever happen. */
noHeadgateSelected: string;
- normalRange: (low: number, high: number, unit: string) => string;
- alertAbove: (high: number, unit: string) => string;
- alertBelow: (low: number, unit: string) => string;
+ /** Accepts pre-formatted strings so callers can render the same
+ * shape (integer-cleaned, locale-aware) the meter uses. */
+ normalRange: (low: string, high: string, unit: string) => string;
+ alertAbove: (value: string, unit: string) => string;
+ alertBelow: (value: string, unit: string) => string;
+ // ThresholdMeter zone labels. Names only — the value + unit are
+ // rendered beside each label so we don't have to re-localize
+ // the variables. Strings live here so translators see them in
+ // context with the rest of `log.*` instead of under a separate
+ // namespace that nobody updates.
+ zoneBelowNormal: string;
+ zoneAboveAlert: string;
+ zoneInRange: string;
+ /** Only a `low` threshold configured; value is below it. */
+ zoneBelowAlert: string;
+ /** Only a `low` threshold configured; value is above it. */
+ zoneAboveMin: string;
+ /** Value entered, but no thresholds configured. */
+ zoneReading: string;
+ /** Empty value, no thresholds configured. */
+ zoneTypeToLog: string;
+ /** Compact "Normal 2.5 – 8 CFS" form for the meter's right side. */
+ meterNormalRange: (low: string, high: string, unit: string) => string;
};
/** `/water` success screen after submit. */
success: {
@@ -208,8 +228,18 @@ export const LABELS: Record
= {
noHeadgateSelected: "Pick a headgate first.",
normalRange: (low, high, unit) =>
`Normal range: ${low} – ${high} ${unit}`,
- alertAbove: (high, unit) => `Alert above ${high} ${unit}`,
- alertBelow: (low, unit) => `Alert below ${low} ${unit}`,
+ alertAbove: (value, unit) => `Alert above ${value} ${unit}`,
+ alertBelow: (value, unit) => `Alert below ${value} ${unit}`,
+ // ThresholdMeter zone labels — see type def above.
+ zoneBelowNormal: "Below normal",
+ zoneAboveAlert: "Above alert",
+ zoneInRange: "In range",
+ zoneBelowAlert: "Below alert",
+ zoneAboveMin: "Above minimum",
+ zoneReading: "Reading",
+ zoneTypeToLog: "Type a reading above",
+ meterNormalRange: (low, high, unit) =>
+ `Normal ${low} – ${high} ${unit}`,
},
success: {
title: "Reading logged",
@@ -305,8 +335,18 @@ export const LABELS: Record = {
noHeadgateSelected: "Elige una compuerta primero.",
normalRange: (low, high, unit) =>
`Rango normal: ${low} – ${high} ${unit}`,
- alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`,
- alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`,
+ alertAbove: (value, unit) => `Alerta arriba de ${value} ${unit}`,
+ alertBelow: (value, unit) => `Alerta abajo de ${value} ${unit}`,
+ // ThresholdMeter zone labels — Spanish.
+ zoneBelowNormal: "Bajo lo normal",
+ zoneAboveAlert: "Sobre alerta",
+ zoneInRange: "En rango",
+ zoneBelowAlert: "Bajo alerta",
+ zoneAboveMin: "Sobre el mínimo",
+ zoneReading: "Lectura",
+ zoneTypeToLog: "Ingresa una lectura arriba",
+ meterNormalRange: (low, high, unit) =>
+ `Normal ${low} – ${high} ${unit}`,
},
success: {
title: "Lectura registrada",
diff --git a/tests/unit/water-log-i18n.test.ts b/tests/unit/water-log-i18n.test.ts
index 094b03f..13885e0 100644
--- a/tests/unit/water-log-i18n.test.ts
+++ b/tests/unit/water-log-i18n.test.ts
@@ -252,21 +252,21 @@ describe("Mobile label helpers (interpolation)", () => {
});
it("log.normalRange interpolates the bounds + unit", () => {
- expect(LABELS.en.log.normalRange(2, 8, "CFS")).toBe(
+ expect(LABELS.en.log.normalRange("2", "8", "CFS")).toBe(
"Normal range: 2 – 8 CFS",
);
- expect(LABELS.es.log.normalRange(2, 8, "CFS")).toBe(
+ 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(
+ 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");
+ 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", () => {