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:
parent
3f1ef9dab7
commit
0e13720fa7
2 changed files with 376 additions and 0 deletions
1
.playwright-mcp/page-2026-08-26T22-41-55-734Z.yml
Normal file
1
.playwright-mcp/page-2026-08-26T22-41-55-734Z.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
- button "Open Next.js Dev Tools" [ref=e10] [cursor=pointer]
|
||||
|
|
@ -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).
|
||||
Loading…
Reference in a new issue