docs(spec): fuel attention dismissal design

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>
This commit is contained in:
DEls 2026-08-28 12:19:59 +02:00
parent 3f1ef9dab7
commit 0e13720fa7
2 changed files with 376 additions and 0 deletions

View file

@ -0,0 +1 @@
- button "Open Next.js Dev Tools" [ref=e10] [cursor=pointer]

View file

@ -0,0 +1,375 @@
# 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:
1. **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.
2. **Same vehicle flagged repeatedly.** A single over-limit event generates
`N` rows 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_PASSWORD` env vars using the same
`urllib.request` shape as the existing migration.
- PATCHes `/api/collections/vehicles` with the new field merged onto the
existing fields list.
- Prints the final field list for the operator.
- Single field:
```python
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`: max `date_logged` across that vehicle's `warn`/`alert`
rows, or `null` if none.
- `attentionDismissedAt`: `v.attention_dismissed_at ?? null`, normalized to
ISO string (PB returns dates as ISO).
Extend the `VehicleSummary` interface in `fuel-view.tsx`:
```ts
export interface VehicleSummary {
// ... existing fields ...
attentionDismissedAt: string | null;
maxFlaggedDate: string | null;
}
```
`FuelView` filters `flaggedVehicles` instead:
```ts
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.
- `attentionDismissedAt` set but `maxFlaggedDate === null` → vehicle has
`vFlagged > 0` so at least one flagged log exists; `null <= ISO` evaluates
safely because the early branch handles `null` separately. Kept explicit
for clarity.
- ISO date strings compare lexicographically the same way they compare as
dates (YYYY-MM-DD) — no `Date` parsing needed in the filter.
- A freshly-imported flagged log with `date_logged > attentionDismissedAt`
will 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:
```tsx
<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:
```tsx
<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`:
```ts
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:
```tsx
{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`):
```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 read `attention_dismissed_at`.
## Critical files
- `scripts/migrations/2026-08-28-vehicles-attention-dismissed.py` *(new)* —
one-shot PB schema migration
- `scripts/pb-bootstrap-schema.py` — add `attention_dismissed_at` to the
`DEFS["vehicles"]` field list
- `src/app/api/vehicles/[id]/attention-dismiss/route.ts` *(new)* — POST +
DELETE handlers
- `src/app/dashboard/fuel/page.tsx` — extend `vehicleSummaries` mapper to
include `attentionDismissedAt` and `maxFlaggedDate`
- `src/app/dashboard/fuel/fuel-view.tsx` — extend `VehicleSummary` interface,
add `useState` overlay + `dismiss()` handler, replace `flaggedVehicles`
filter, render Dismiss button on `AlertCard` and `AlertListRow`, add
`EyeOff` import
- `src/app/dashboard/fuel/[vehicleId]/page.tsx` — include
`attention_dismissed_at` on 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()` from `src/lib/pb.ts` — same auth pattern as the existing
`vehicles/[id]` route.
- `Button` from `@/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 of `fuel-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 of
`pb-bootstrap-schema.py`.
## Verification
1. **Dismiss hides a vehicle from the alert list.**
- Open `/dashboard/fuel`. Identify a vehicle currently in "Attention
required" with `flagged > 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).
2. **Dismiss persists across deploys.**
- Run the PB migration against the prod-like environment. Confirm
`attention_dismissed_at` exists on the `vehicles` collection.
- Set a value via the UI. Stop and restart the Next dev server. The
dismissal still applies.
3. **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_at` is now `null` for that vehicle.
4. **Auto-undismiss on a new flagged log.**
- Dismiss a vehicle.
- Insert a fuel log for that vehicle with `status = alert` and
`date_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`.
5. **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).
6. **No regressions.**
- Typecheck + `npm run test:run` pass at the existing baseline (`1 failed
| 188 passed` — unrelated `process-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 read `attention_dismissed_at`).
7. **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.
## 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).