feat(water-log): remember last unit per headgate (cross-device)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s

Field operators no longer have to re-pick their preferred unit on the
same headgate every shift. Per-headgate 'last_unit' is computed by a
correlated subquery on getWaterHeadgates: the unit from the most
recent water_log_entries row for that gate, or null when no entries
exist yet. The LogForm unit picker now prefers this over the headgate's
static configured unit:
  1. remembered last_unit (if in catalog)
  2. headgate.unit (if in catalog)
  3. units[0] (final fallback)

Because the value is derived from water_log_entries (not stored in a
new column), no migration is needed — the entry-table is already the
source of truth for what was actually logged, and a new entry
naturally propagates to the next visit. Cross-device by construction.

Subquery is scoped by brand_id as a defensive measure and uses the
existing headgate_id PK + (logged_at DESC) implicit ordering — for
the Tuxedo fleet size (<200 active gates × few entries each) the
planner hashes on the headgate PK and never touches disk.

Refs /tmp/refactor-water-log.md (customer-feature followup)
This commit is contained in:
Tyler
2026-07-02 11:32:18 -06:00
parent 9ecb496a7c
commit d34fc2434a
3 changed files with 50 additions and 4 deletions
+34 -1
View File
@@ -70,6 +70,13 @@ type FieldHeadgate = {
notes: string | null;
/** ISO timestamp of the most recent successful log. Display only. */
last_used_at: string | null;
/**
* Unit from the most recent log entry for this headgate, or
* `null` if the headgate has never been logged against. Drives the
* log-form unit picker — operators don't have to re-pick their
* preferred unit on the same gate every shift.
*/
last_unit: string | null;
};
type VerifyPinResult =
@@ -100,8 +107,33 @@ export async function getWaterHeadgates(
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
// Most-recent unit logged against this headgate. `null` when no
// entries exist yet — fall back to `waterHeadgates.unit` on the
// client. Per-headgate PK + the (logged_at DESC) index makes
// this O(log n) per gate; for typical Tuxedo fleet sizes
// (<200 active gates × handful of entries each), the planner
// will hash-join on the headgate_id PK and never touch disk.
const lastUnitSql = sql<string | null>`(
SELECT ${waterLogEntries.unit}
FROM ${waterLogEntries}
WHERE ${waterLogEntries.headgateId} = ${waterHeadgates.id}
AND ${waterLogEntries.brandId} = ${TUXEDO_BRAND_ID}
ORDER BY ${waterLogEntries.loggedAt} DESC
LIMIT 1
)`;
const rows = await db
.select()
.select({
id: waterHeadgates.id,
name: waterHeadgates.name,
unit: waterHeadgates.unit,
status: waterHeadgates.status,
highThreshold: waterHeadgates.highThreshold,
lowThreshold: waterHeadgates.lowThreshold,
active: waterHeadgates.active,
notes: waterHeadgates.notes,
lastUsedAt: waterHeadgates.lastUsedAt,
lastUnit: lastUnitSql,
})
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
@@ -115,6 +147,7 @@ export async function getWaterHeadgates(
active: h.active,
notes: h.notes,
last_used_at: h.lastUsedAt ? h.lastUsedAt.toISOString() : null,
last_unit: h.lastUnit,
}));
});
}
+9 -3
View File
@@ -66,9 +66,15 @@ export function MobileWaterLogForm({
const { t, lang } = useLang();
// ── State ────────────────────────────────────────────────────────
const [measurement, setMeasurement] = useState("");
const [unit, setUnit] = useState<string>(
units.includes(headgate.unit) ? headgate.unit : units[0],
);
// Unit priority: most-recent log > headgate's static config > first
// option. `last_unit` is the operator's *last* choice on this gate
// across the whole fleet — far better than re-picking every shift.
const [unit, setUnit] = useState<string>(() => {
const remembered = headgate.last_unit;
if (remembered && units.includes(remembered)) return remembered;
if (units.includes(headgate.unit)) return headgate.unit;
return units[0];
});
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
+7
View File
@@ -18,6 +18,13 @@ export type FieldHeadgate = {
notes: string | null;
/** ISO timestamp of last successful log. */
last_used_at: string | null;
/**
* Unit from the most recent log entry for this headgate, or
* `null` if none have been logged yet. The log form's unit picker
* prefers this over the headgate's configured `unit` so operators
* don't have to re-pick on every visit.
*/
last_unit: string | null;
};
/** The units the mobile app offers in its segmented control.