MEGAINFRA-WEBAPP/docs/superpowers/specs/2026-08-28-fuel-attention-dismissal-design.md
DEls 0e13720fa7 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>
2026-08-28 12:19:59 +02:00

15 KiB
Raw Blame History

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:
    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:

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.
  • 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:

<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 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).