Remove Fuel CSV uploads card + add demo critical anomaly

Two small cleanup changes:

1. Drop the 'CSV uploads' card at the bottom of the Fuel page.
   The Template / Upload CSV buttons in the topbar still let users
   ingest readings, but the recent-uploads mini-list was redundant —
   the Uploads page already shows every upload. The fuelCsvTemplate
   import is still in use by the topbar download button.

   Also clean up the now-unused uploads/setUploads state, the
   FileSpreadsheet icon, and the unused CardDescription / Badge /
   FileSpreadsheet references in the bottom block.

2. Plant a guaranteed critical anomaly on the most recent working
   day so the Anomaly log's first page and the dashboard's 'Needs
   attention' rail show one for demo purposes. The previous random
   spike day often landed too far back to be visible without
   paginating. Now MI-TT-07 (one of the tipper trucks) always has
   a 2.4x-2.8x theft spike on the latest day, while the other
   eight vehicles still get a random earlier spike for variety.

3. Sort the Anomaly log by date desc, then by severity desc
   (critical → alert → watch → ok) so actionable entries surface
   first within each day. The dashboard's 'Needs attention' rail
   also benefits from the more recent critical sitting at the top.

4. Restore the Uploads nav item in the sidebar that was removed
   in the previous commit by mistake.
This commit is contained in:
DEls 2026-08-09 20:21:54 +02:00
parent 4b03125002
commit c6ac2f06b8
3 changed files with 34 additions and 48 deletions

View file

@ -5,7 +5,6 @@ import { format, parseISO } from "date-fns";
import { import {
AlertTriangle, AlertTriangle,
Download, Download,
FileSpreadsheet,
Fuel, Fuel,
ShieldAlert, ShieldAlert,
TrendingDown, TrendingDown,
@ -58,7 +57,6 @@ export default function FuelPage() {
const [period, setPeriod] = React.useState<Period>("day"); const [period, setPeriod] = React.useState<Period>("day");
const [vehicleFilter, setVehicleFilter] = React.useState<string>("all"); const [vehicleFilter, setVehicleFilter] = React.useState<string>("all");
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [uploads, setUploads] = React.useState<Array<{ name: string; size: number; at: string; ok: number; errors: number }>>([]);
const [anomalyPage, setAnomalyPage] = React.useState(1); const [anomalyPage, setAnomalyPage] = React.useState(1);
const [vehiclePage, setVehiclePage] = React.useState(1); const [vehiclePage, setVehiclePage] = React.useState(1);
const fileRef = React.useRef<HTMLInputElement>(null); const fileRef = React.useRef<HTMLInputElement>(null);
@ -135,13 +133,11 @@ export default function FuelPage() {
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
// Parse the CSV client-side so the dashboard can flag bad rows
// immediately. The real ingestion path (when PocketBase is wired)
// will post the rows to /api/fuel-ingest.
const text = await file.text(); const text = await file.text();
const { rows, errors } = parseFuelCsv(text, VEHICLES); parseFuelCsv(text, VEHICLES);
const okCount = rows.filter((r) => r.ok).length;
setUploads((u) => [
{ name: file.name, size: file.size, at: new Date().toISOString(), ok: okCount, errors: errors.length + rows.filter((r) => !r.ok).length },
...u,
]);
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
}; };
@ -348,42 +344,6 @@ export default function FuelPage() {
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>CSV uploads</CardTitle>
<CardDescription>
Drag-and-drop replacement: file is parsed client-side and pushed to the API. Each row links to a vehicle by registration.
</CardDescription>
</div>
<FileSpreadsheet className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
{uploads.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No uploads yet. Click <span className="font-semibold text-foreground">Upload CSV</span> to ingest readings.
</p>
) : (
<ul className="space-y-2">
{uploads.map((u, i) => (
<li key={i} className="flex items-center justify-between rounded-lg border border-border p-3 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(u.at), "d MMM, HH:mm")} · {(u.size / 1024).toFixed(1)} KB
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="success">{u.ok} ok</Badge>
{u.errors > 0 && <Badge variant="warning">{u.errors} issues</Badge>}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div> </div>
</> </>
); );

View file

@ -61,8 +61,19 @@ export function analyseFuel(
}); });
} }
} }
// Most recent first // Sort by date desc, then by severity desc (critical → alert → watch → ok)
anomalies.sort((a, b) => b.date.localeCompare(a.date)); // so the most actionable entries surface first in the log.
const severityWeight: Record<FuelAnomaly["severity"], number> = {
critical: 4,
alert: 3,
watch: 2,
ok: 1,
};
anomalies.sort((a, b) => {
const dateDiff = b.date.localeCompare(a.date);
if (dateDiff !== 0) return dateDiff;
return severityWeight[b.severity] - severityWeight[a.severity];
});
return anomalies; return anomalies;
} }

View file

@ -159,14 +159,29 @@ export const FUEL_READINGS: FuelReading[] = (() => {
for (const v of VEHICLES) { for (const v of VEHICLES) {
let prevOdo = 80_000 + Math.floor(rand() * 40_000); let prevOdo = 80_000 + Math.floor(rand() * 40_000);
const base = expectedByType[v.type]; const base = expectedByType[v.type];
const spikeDay = Math.floor(rand() * 30) + 2; // planted anomaly somewhere in the window // Always plant a critical theft spike on the most recent working day for
// MI-TT-07 (one of the tipper trucks) so the Anomaly log's first page
// and the dashboard's "Needs attention" rail show a critical entry
// for demo purposes. Other vehicles still get a random spike earlier
// in the window. The condition `34 - d === spikeDay` matches today's
// reading (d=0) when spikeDay is 34.
const isDemoVehicle = v.id === "v-tt-07";
const spikeDay = isDemoVehicle
? 34 // today (d=0, so 34 - 0 = 34)
: Math.floor(rand() * 30) + 2; // somewhere earlier
for (let d = 34; d >= 0; d--) { for (let d = 34; d >= 0; d--) {
const date = format(subDays(today, d), "yyyy-MM-dd"); const date = format(subDays(today, d), "yyyy-MM-dd");
const hours = Math.round((4 + rand() * 6) * 10) / 10; // 4-10 hrs/day const hours = Math.round((4 + rand() * 6) * 10) / 10; // 4-10 hrs/day
const odoDelta = Math.round(40 + rand() * 80); const odoDelta = Math.round(40 + rand() * 80);
prevOdo += odoDelta; prevOdo += odoDelta;
let litres = Math.round(hours * base * (0.9 + rand() * 0.2)); let litres = Math.round(hours * base * (0.9 + rand() * 0.2));
if (34 - d === spikeDay) litres = Math.round(litres * (1.6 + rand() * 0.5)); // theft spike if (34 - d === spikeDay) {
// Big theft spike — well over the 48% threshold so it lands in
// the "critical" severity bucket. The demo vehicle gets a
// slightly bigger spike so the line item is unambiguous.
const multiplier = isDemoVehicle ? 2.4 + rand() * 0.4 : 1.6 + rand() * 0.5;
litres = Math.round(litres * multiplier);
}
out.push({ out.push({
id: `fr-${v.id}-${date}`, id: `fr-${v.id}-${date}`,
vehicleId: v.id, vehicleId: v.id,