Per-vehicle 'I've seen this' control on the Fuel dashboard. Stamps attention_dismissed_at on the vehicles collection; auto-undismisses when a flagged fuel log with date_logged > dismissal arrives. Timestamp only, no reason field. Tiny POST/DELETE route + filter line in page mapper + Dismiss button on alert rows + Dismissed/Restore pill on vehicle detail. Co-Authored-By: Claude <noreply@anthropic.com>
15 KiB
Fuel: dismiss false-positive "Attention required" alerts per vehicle
Context
The "Attention required" panel on /dashboard/fuel lists every vehicle with at
least one flagged fuel log (status warn or alert). The list is computed in
src/app/dashboard/fuel/page.tsx from per-vehicle summaries and rendered by
src/app/dashboard/fuel/fuel-view.tsx as AlertCard (mobile) and
AlertListRow (desktop).
Two known false-positive scenarios:
- No start-date odo reading. A vehicle's first imported fuel log has no prior reading, so the variance computation has nothing to compare against. The row lands in the alert list even though there's nothing genuinely wrong.
- Same vehicle flagged repeatedly. A single over-limit event generates
Nrows of "alert" state (one per affected log). The vehicle is listed once but the operator has to mentally aggregate that all those alerts trace back to one event — and click through to the vehicle to verify that.
Today there's no way to mark a vehicle as "seen" — every refresh re-surfaces
the same false positives. This adds an explicit, per-vehicle "I've seen this"
control with auto-undismiss on new flags: the dismissal sticks until a
fresh flagged log with a date_logged strictly later than the dismissal
arrives, at which point the vehicle pops back. Timestamp only — no reason
field, no per-user attribution.
Changes
1. PocketBase schema — add attention_dismissed_at to vehicles
Add one optional date field attention_dismissed_at to the vehicles
collection. Auto-undismiss is a pure server-side comparison against fuel log
date_logged, so no other collections are touched.
New migration scripts/migrations/2026-08-28-vehicles-attention-dismissed.py
(modeled on scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py):
- Idempotent — re-runs are safe; existing fields are skipped.
- Auths via
PB_ADMIN_EMAIL/PB_ADMIN_PASSWORDenv vars using the sameurllib.requestshape as the existing migration. - PATCHes
/api/collections/vehicleswith the new field merged onto the existing fields list. - Prints the final field list for the operator.
- Single field:
NEW_FIELDS = { "attention_dismissed_at": { "name": "attention_dismissed_at", "type": "date", "required": False, }, }
scripts/pb-bootstrap-schema.py — also add the field to the
DEFS["vehicles"] field list (around line 174) so freshly bootstrapped
environments get it. Keeps the two paths in sync.
No index needed (date field, queried per-vehicle, not in bulk).
2. Server mapper — extend VehicleSummary
src/app/dashboard/fuel/page.tsx — for each vehicle, also compute:
vMaxFlaggedDate: maxdate_loggedacross that vehicle'swarn/alertrows, ornullif none.attentionDismissedAt:v.attention_dismissed_at ?? null, normalized to ISO string (PB returns dates as ISO).
Extend the VehicleSummary interface in fuel-view.tsx:
export interface VehicleSummary {
// ... existing fields ...
attentionDismissedAt: string | null;
maxFlaggedDate: string | null;
}
FuelView filters flaggedVehicles instead:
const flaggedVehicles = React.useMemo(
() =>
vehicles
.filter((v) => {
if (v.flagged === 0) return false;
// Dismissed AND every flagged log is at-or-before the dismissal
// → we've already seen this state. Hide.
if (
v.attentionDismissedAt !== null &&
(v.maxFlaggedDate === null ||
v.maxFlaggedDate <= v.attentionDismissedAt)
) {
return false;
}
return true;
})
.sort(/* unchanged: alert desc → warn desc → spend desc */),
[vehicles],
);
Edge cases covered:
v.flagged === 0→ already excluded before the dismissal check, so a dismissed vehicle with no flagged logs doesn't appear at all.attentionDismissedAtset butmaxFlaggedDate === null→ vehicle hasvFlagged > 0so at least one flagged log exists;null <= ISOevaluates safely because the early branch handlesnullseparately. Kept explicit for clarity.- ISO date strings compare lexicographically the same way they compare as
dates (YYYY-MM-DD) — no
Dateparsing needed in the filter. - A freshly-imported flagged log with
date_logged > attentionDismissedAtwill re-trigger inclusion on the next page load — that's the auto-undismiss.
3. UI — "Dismiss" button on each alert row
Both AlertCard (mobile, lines ~713–786 of fuel-view.tsx) and
AlertListRow (desktop, lines ~788–846) currently render a <Link> wrapping
the whole row. Click anywhere → navigates to the vehicle detail. We need a
button that doesn't trigger navigation.
Pattern: add a small <Button variant="ghost" size="sm"> rendered
inside the <Link>. Use e.preventDefault() + e.stopPropagation() on
its onClick to suppress navigation. This is the same pattern React Router's
docs use for nested interactive children inside a <Link>.
Mobile AlertCard — append a Dismiss control to the bottom row (the
current "txn / spend" row at lines ~753–783). Layout:
<div className="flex items-center justify-between gap-2 text-xs">
{/* existing badges + txn/spend text */}
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); dismiss(v.id); }}
disabled={busyId === v.id}
className="..."
>
<EyeOff className="h-3.5 w-3.5" />
{busyId === v.id ? "Dismissing…" : "Dismiss"}
</button>
</div>
Desktop AlertListRow — append at the far right, sitting next to the
existing ArrowRight icon:
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); dismiss(v.id); }}
disabled={busyId === v.id}
className="..."
>
<EyeOff className="h-3.5 w-3.5" />
<span className="ml-1.5">{busyId === v.id ? "Dismissing…" : "Dismiss"}</span>
</button>
Optimistic update + state — small useState in FuelView:
const [dismissedAt, setDismissedAt] = React.useState<Record<string, string>>({});
const [busyId, setBusyId] = React.useState<string | null>(null);
const router = useRouter();
async function dismiss(vehicleId: string) {
const stamp = new Date().toISOString();
setBusyId(vehicleId);
setDismissedAt((m) => ({ ...m, [vehicleId]: stamp }));
try {
const res = await fetch(`/api/vehicles/${vehicleId}/attention-dismiss`, {
method: "POST",
});
if (!res.ok) throw new Error("Dismiss failed");
router.refresh(); // re-pull server data so the optimistic value sticks
} catch (err) {
setDismissedAt((m) => {
const { [vehicleId]: _, ...rest } = m;
return rest;
});
window.alert(err instanceof Error ? err.message : "Dismiss failed");
} finally {
setBusyId(null);
}
}
A useMemo overlays the optimistic dismissedAt onto the vehicles prop
before the flaggedVehicles filter runs, so the row disappears the moment
the button is clicked (no network wait). router.refresh() re-reads the
server data and the overlay becomes a no-op.
Hover/tooltip: the Dismiss label + EyeOff icon is enough on its own;
no extra tooltip needed. Keyboard: <button type="button"> is in the tab
order. aria-label="Mark ${v.description} as seen" on the button for screen
readers; the visible "Dismiss" text is redundant.
New icon import: add EyeOff to the lucide-react import block at the
top of fuel-view.tsx (lines 7–18).
4. UI — "Dismissed" pill + Restore on the vehicle detail page
src/app/dashboard/fuel/[vehicleId]/page.tsx — pass
attentionDismissedAt down (probably already mapped into detail; if not,
add it to the vehicle detail fetch).
src/app/dashboard/fuel/[vehicleId]/vehicle-detail-view.tsx — if
detail.attentionDismissedAt is set, render a small pill in the header
(next to the project badge), with an inline "Restore" button:
{detail.attentionDismissedAt && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground">
<EyeOff className="h-3 w-3" />
Attention dismissed
<span className="text-[10px] opacity-70">
{format(parseISO(detail.attentionDismissedAt), "yyyy-MM-dd HH:mm")}
</span>
<button
type="button"
onClick={restore}
className="ml-1 underline-offset-2 hover:underline"
>
Restore
</button>
</span>
)}
restore() is the inverse of dismiss: DELETE to the same route, then
router.refresh() on the detail page (which the page already wires up).
5. API route
New: src/app/api/vehicles/[id]/attention-dismiss/route.ts
Two verbs on one route, both auth'd via pbAdmin() (same pattern as
src/app/api/vehicles/[id]/route.ts):
// POST /api/vehicles/[id]/attention-dismiss
// body: empty
// returns: { attentionDismissedAt: ISO }
// errors: 404 (vehicle not found), 500 (PB error)
// DELETE /api/vehicles/[id]/attention-dismiss
// body: empty
// returns: { attentionDismissedAt: null }
// errors: 404 (vehicle not found), 500 (PB error)
Both validate the id segment (return 400 if missing/empty), call the
appropriate pb.collection("vehicles").update(id, payload), and return the
updated value.
Server-side admin only — no user-token passthrough. The dashboard is gated to authenticated staff; if that ever changes, add a session check here.
6. No other changes
- List view (
purchasing-view.tsx, fleet table): unchanged. Dismissed vehicles still appear in the Fleet table — only the alert list filters them. src/lib/utils.ts,src/lib/fuel-variance.ts: unchanged.src/lib/use-inline-edit.ts: unchanged.- Other pages reading
vehicles(orders, employees, etc.): unchanged — none of them readattention_dismissed_at.
Critical files
scripts/migrations/2026-08-28-vehicles-attention-dismissed.py(new) — one-shot PB schema migrationscripts/pb-bootstrap-schema.py— addattention_dismissed_atto theDEFS["vehicles"]field listsrc/app/api/vehicles/[id]/attention-dismiss/route.ts(new) — POST + DELETE handlerssrc/app/dashboard/fuel/page.tsx— extendvehicleSummariesmapper to includeattentionDismissedAtandmaxFlaggedDatesrc/app/dashboard/fuel/fuel-view.tsx— extendVehicleSummaryinterface, adduseStateoverlay +dismiss()handler, replaceflaggedVehiclesfilter, render Dismiss button onAlertCardandAlertListRow, addEyeOffimportsrc/app/dashboard/fuel/[vehicleId]/page.tsx— includeattention_dismissed_aton the vehicle fetch (if not already present)src/app/dashboard/fuel/[vehicleId]/vehicle-detail-view.tsx— render "Dismissed" pill + Restore button in the header
Reused existing pieces
pbAdmin()fromsrc/lib/pb.ts— same auth pattern as the existingvehicles/[id]route.Buttonfrom@/components/ui/button.tsx— same component as the filter-bar buttons.useRouter().refresh()— already used in the inline-edit flow on the same page (line ~268 offuel-view.tsx).- Migration script template —
scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py. - Bootstrap schema edit pattern —
DEFS["vehicles"]block at line ~174 ofpb-bootstrap-schema.py.
Verification
-
Dismiss hides a vehicle from the alert list.
- Open
/dashboard/fuel. Identify a vehicle currently in "Attention required" withflagged > 0. - Click its "Dismiss" button.
- The row disappears immediately. The badge count above the panel drops by one. The KPI "Flagged over-limit" tile is unchanged (it counts rows, not vehicles) — this matches existing behaviour.
- Refresh the page: the vehicle is still gone (persisted).
- Open
-
Dismiss persists across deploys.
- Run the PB migration against the prod-like environment. Confirm
attention_dismissed_atexists on thevehiclescollection. - Set a value via the UI. Stop and restart the Next dev server. The dismissal still applies.
- Run the PB migration against the prod-like environment. Confirm
-
Restore brings a vehicle back.
- Open the dismissed vehicle's detail page (
/dashboard/fuel/[id]). - See the "Dismissed" pill with the timestamp.
- Click "Restore". Pill disappears. Back on the fuel list, the vehicle re-appears in "Attention required" (assuming it still has flagged logs).
- DB check:
attention_dismissed_atis nownullfor that vehicle.
- Open the dismissed vehicle's detail page (
-
Auto-undismiss on a new flagged log.
- Dismiss a vehicle.
- Insert a fuel log for that vehicle with
status = alertanddate_logged = tomorrow(or any timestamp after the dismissal). - Reload
/dashboard/fuel. The vehicle is back in "Attention required". - No new code path runs — this is the same filter logic, just with a
newer
maxFlaggedDate.
-
Optimistic UI + error rollback.
- Throttle the network to slow 3G in devtools.
- Click "Dismiss". Row disappears immediately (optimistic).
- Once the request resolves, the row stays gone.
- Force a 500 by stopping the dev server mid-request. Row re-appears, alert dialog shows "Dismiss failed", no DB write happened (verify via PB admin UI).
-
No regressions.
- Typecheck +
npm run test:runpass at the existing baseline (1 failed | 188 passed— unrelatedprocess-batch/route.test.ts). - Existing inline-edit flow on vehicle descriptions still works.
- Filter / pagination on the Fleet table still work.
- The fuel transactions table on
/dashboard/fuel/[vehicleId]still works. - KPI "Flagged over-limit" tile still counts row-level flags, not vehicles — no change.
- Other dashboards that read
vehicles(orders, employees, etc.) render identically (none of them readattention_dismissed_at).
- Typecheck +
-
Browser verification.
- Use Agent Browser (per saved memory
use-agent-browser-for-testing) to visually confirm the Dismiss button, the optimistic hide, the "Dismissed" pill on the detail page, and the Restore flow. Playwright has been observed to return stale renders on this project.
- Use Agent Browser (per saved memory
Out of scope
- Per-user dismissals (no auth model yet).
- Free-text reason / note field (timestamp only, per design decision).
- Bulk-dismiss across multiple vehicles.
- "Snooze for N days" — auto-undismiss already covers the recurring case.
- Showing dismissed vehicles in a collapsed secondary list. Keeps the alert card simple; can be added later if needed.
- Editing the dismissal timestamp from the UI (only POST/DELETE).
- Auto-undismiss when flagged logs are deleted (only new arrivals trigger it — matches the user's intent of "we've seen this state").
- Dismissing individual flagged log rows instead of whole vehicles (per design decision — per-vehicle scope was chosen).