Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/LOCATION_REFRESH_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ Builds directly on the last-seen cache (PR #61): `vehicles.last_lat/last_lon/
last_seen` + the telemetry write-through already exist.

Created: 2026-06-30
Status: design / pre-implementation.
Status: SHIPPED (PRs #64 window + #66 real-time toggle, prod 2026-06-30).
Follow-up 2026-07-09: FleetCache persisted to IndexedDB (idb-keyval) for instant
cold-load paint (paint-then-revalidate, server wins); global "Live Tracking"
60s force-refresh button removed (per-vehicle quick-view toggle is the live
mechanism); fleet-list-view unified onto the shared freshness-window loader.

---

Expand Down
25 changes: 7 additions & 18 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@lit/localize": "^0.12.2",
"@types/leaflet": "^1.9.21",
"dayjs": "^1.11.20",
"idb-keyval": "^6.3.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lit": "^3.3.2"
Expand Down
3 changes: 0 additions & 3 deletions web/src/generated/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
's21b06c38fc4164db': `Avanzado`,
's236ae937cbcd6a39': `Acercar`,
's24ff675ff8ff0ed4': `vehículos`,
's25bebd009ff852a4': `Seguimiento en Vivo`,
's25c9b139cc1a6be8': `Contraer panel`,
's25edbe26de08a2af': `tiempo con el motor encendido durante el paso`,
's2868ccc6ec4b7b45': `No se pudo reenviar la invitación.`,
Expand Down Expand Up @@ -186,7 +185,6 @@
's83f3b9e92ba665c7': `Quitar de la geocerca`,
's846b3790b6e25c1c': `Cuenta`,
's84fe348d9e99e6b0': `Creando…`,
's8652720463582db6': `Detener actualización automática`,
's8773d074e96d8e74': `Agregar un documento`,
's879ecc0cdb12e5e3': `Introduce un nombre para la geocerca.`,
's87e746b950339c11': `No se pudo quitar el miembro.`,
Expand Down Expand Up @@ -286,7 +284,6 @@
'sce4e2b0f570b1bd8': `Su acceso`,
'sd01aa11617062a46': str`${0} de ${1} vehículos atravesaron · ${2} pasos`,
'sd08a6db66b8b4603': `Entrada`,
'sd11b62914720d468': `Iniciar actualización automática`,
'sd12e9358e7e366cd': `Aún no hay integración con DIMO`,
'sd1544db4dc3b46fc': str`Invitaciones anteriores (${0})`,
'sd1e8f2f9e01188e8': `Acceso limitado a los siguientes grupos:`,
Expand Down
36 changes: 36 additions & 0 deletions web/src/services/fleet-cache.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval';
import { VehicleCard } from '../types/vehicle.ts';

export interface FleetOverviewData {
vehicles: VehicleCard[];
locations: Record<string, { lat: number; lon: number }>;
}

interface PersistedFleetData extends FleetOverviewData {
savedAt: number;
}

// Persisted snapshots older than this are ignored — painting a days-old fleet
// is more misleading than a brief loading state.
const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000;

const idbKey = (tenantId: string) => `fleet:${tenantId}`;

/**
* Holds the last-loaded fleet overview (vehicle list + map locations) so
* navigating away to vehicle details and back doesn't re-trigger the loading
Expand All @@ -13,6 +24,10 @@ export interface FleetOverviewData {
*
* Keyed by tenant id so switching tenants doesn't serve the previous tenant's
* cached vehicles/locations.
*
* The in-memory slot is the trusted intra-session path; each set() also
* writes through to IndexedDB (best-effort) so a cold load can paint
* instantly from the last snapshot via loadPersisted() while it revalidates.
*/
let cached: { tenantId: string; data: FleetOverviewData } | null = null;

Expand All @@ -22,8 +37,29 @@ export const FleetCache = {
},
set(tenantId: string, data: FleetOverviewData): void {
cached = { tenantId, data };
const persisted: PersistedFleetData = { ...data, savedAt: Date.now() };
idbSet(idbKey(tenantId), persisted).catch(() => {
// Persistence failing (private mode, quota) must never break the view.
});
},
/**
* Last persisted snapshot for the tenant, or null if absent/expired.
* Paint-only: callers must still fetch /vehicles and revalidate — this
* never substitutes for the network.
*/
async loadPersisted(tenantId: string): Promise<FleetOverviewData | null> {
try {
const stored = await idbGet<PersistedFleetData>(idbKey(tenantId));
if (!stored || Date.now() - stored.savedAt > PERSIST_MAX_AGE_MS) return null;
return { vehicles: stored.vehicles, locations: stored.locations };
} catch {
return null;
}
},
invalidate(): void {
if (cached) {
idbDel(idbKey(cached.tenantId)).catch(() => {});
}
cached = null;
},
};
57 changes: 28 additions & 29 deletions web/src/views/fleet-list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { msg, str } from '@lit/localize';
import { sharedStyles } from '../global-styles.ts';
import { hiddenVehiclesService } from '../services/hidden-vehicles-service.ts';
import { ApiService } from '../services/api-service.ts';
import { TelemetryService } from '../services/telemetry-service.ts';
import { FleetCache } from '../services/fleet-cache.ts';
import { Vehicle, VehicleCard, VehiclesResponse } from '../types/vehicle.ts';
import { seedLocationsFromDb, fetchFleetLocations } from '../utils/fleet-map.ts';
import '../elements/tenant-switcher.ts';

type SortKey = 'status' | 'name' | 'tokenId';
Expand All @@ -29,8 +29,6 @@ export class FleetListView extends LitElement {
private unsubscribeHidden: (() => void) | null = null;

private loadGeneration = 0;
private static readonly LOCATIONS_CHUNK_SIZE = 1;
private static readonly LOCATIONS_PARALLEL = 3;

private formatTitle(v: Vehicle): string {
const d = v.definition;
Expand Down Expand Up @@ -125,11 +123,23 @@ export class FleetListView extends LitElement {
this.loading = false;
return;
}
// Cold load: render instantly from the last persisted snapshot
// while the /vehicles fetch below revalidates. Paint-only — the
// fresh response replaces everything shown here.
const tid = this.tenantId;
const persisted = await FleetCache.loadPersisted(tid);
if (persisted && tid === this.tenantId) {
this.vehicles = persisted.vehicles;
this.lastLocations = persisted.locations;
this.loading = false;
}
}

let rawVehicles: Vehicle[] = [];
try {
const res = await ApiService.getInstance().get<VehiclesResponse>('/vehicles');
const cards = (res.vehicles || []).map((v) => this.toCard(v));
rawVehicles = res.vehicles || [];
const cards = rawVehicles.map((v) => this.toCard(v));
cards.sort((a, b) => Number(!!b.isFavorite) - Number(!!a.isFavorite));
this.vehicles = cards;
this.loading = false;
Expand All @@ -139,33 +149,22 @@ export class FleetListView extends LitElement {
return;
}

this.lastLocations = {};
// Show DB-cached coordinates immediately, then fan out only for the
// vehicles whose location is stale (older than the freshness window).
// A fully-fresh fleet makes zero telemetry calls — same shared loader
// as the map view, so both views generate identical backend load.
this.lastLocations = seedLocationsFromDb(rawVehicles);
const gen = ++this.loadGeneration;
const ids = this.vehicles.map((v) => v.tokenId);
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += FleetListView.LOCATIONS_CHUNK_SIZE) {
chunks.push(ids.slice(i, i + FleetListView.LOCATIONS_CHUNK_SIZE));
}

const noPermSet = new Set<string>();
let nextChunk = 0;
const worker = async () => {
while (nextChunk < chunks.length) {
if (gen !== this.loadGeneration) return;
const batch = chunks[nextChunk++];
try {
const locRes = await TelemetryService.getInstance().fleetLocations(force, batch);
if (gen !== this.loadGeneration) return;
for (const id of locRes.noPermissions ?? []) noPermSet.add(id);
this.lastLocations = { ...this.lastLocations, ...locRes.locations };
} catch {
// keep going on batch failure
}
}
};
await Promise.all(
Array.from({ length: Math.min(FleetListView.LOCATIONS_PARALLEL, chunks.length) }, () => worker()),
);
await fetchFleetLocations({
vehicles: rawVehicles,
force,
isCurrent: () => gen === this.loadGeneration,
onNoPermissions: (ids) => ids.forEach((id) => noPermSet.add(id)),
onBatch: (locations) => {
this.lastLocations = { ...this.lastLocations, ...locations };
},
});
if (gen !== this.loadGeneration) return;

if (noPermSet.size > 0) {
Expand Down
Loading
Loading