diff --git a/.playwright-mcp/page-2026-08-26T22-41-55-734Z.yml b/.playwright-mcp/page-2026-08-26T22-41-55-734Z.yml new file mode 100644 index 0000000..2baf449 --- /dev/null +++ b/.playwright-mcp/page-2026-08-26T22-41-55-734Z.yml @@ -0,0 +1 @@ +- button "Open Next.js Dev Tools" [ref=e10] [cursor=pointer] \ No newline at end of file diff --git a/docs/superpowers/specs/2026-08-28-fuel-attention-dismissal-design.md b/docs/superpowers/specs/2026-08-28-fuel-attention-dismissal-design.md new file mode 100644 index 0000000..224cbed --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-fuel-attention-dismissal-design.md @@ -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 `` wrapping +the whole row. Click anywhere → navigates to the vehicle detail. We need a +button that doesn't trigger navigation. + +**Pattern:** add a small ` + +``` + +**Desktop `AlertListRow`** — append at the far right, sitting next to the +existing `ArrowRight` icon: + +```tsx + +``` + +**Optimistic update + state** — small `useState` in `FuelView`: + +```ts +const [dismissedAt, setDismissedAt] = React.useState>({}); +const [busyId, setBusyId] = React.useState(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: ` + +)} +``` + +`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). \ No newline at end of file