feat(water-log): remember last unit per headgate (cross-device)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
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:
@@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user