From 81d5742490bc38253664bd352cb7b3282f3bf1d2 Mon Sep 17 00:00:00 2001 From: James Reategui Date: Thu, 9 Jul 2026 16:51:59 -0400 Subject: [PATCH 1/2] perf(web): IndexedDB paint cache, shared freshness loader in list view, drop global Live Tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the fleet views cheaper against DIMO APIs (~500-vehicle tenants) and faster to paint on cold load: - FleetCache now writes through to IndexedDB (idb-keyval, one snapshot per tenant, 24h max age). Map and list views paint instantly from the last snapshot on a hard reload, then always revalidate against /vehicles — paint-only, server wins; markers for vehicles no longer in the fleet are pruned after a persisted paint. - fleet-list-view drops its private always-pull-everything location fan-out for the shared seedLocationsFromDb + fetchFleetLocations, so it now respects the 5-min locationPulledAt freshness window instead of re-pulling every vehicle on each visit. - Remove the global "Live Tracking" button (top bar + map legend): it force- refreshed the entire fleet every 60s, bypassing both the freshness window and the backend 45s cache. Per-vehicle live tracking stays via the quick-view "Real-time · 20s" toggle. Live-verified against the 430-vehicle fleet: zero /telemetry/locations on reload within the window, partial refresh re-pulls only stale vehicles, force refresh still re-pulls all, expired snapshots ignored gracefully. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UxoaxTXSLqUwkYfewdwGBM --- docs/LOCATION_REFRESH_PLAN.md | 6 +- web/package-lock.json | 25 ++----- web/package.json | 1 + web/src/generated/locales/es.ts | 7 +- web/src/services/fleet-cache.ts | 36 ++++++++++ web/src/views/fleet-list-view.ts | 57 ++++++++-------- web/src/views/fleet-overview.ts | 113 +++++++------------------------ web/xliff/es.xlf | 12 ---- 8 files changed, 104 insertions(+), 153 deletions(-) diff --git a/docs/LOCATION_REFRESH_PLAN.md b/docs/LOCATION_REFRESH_PLAN.md index 68772f9..1ec5175 100644 --- a/docs/LOCATION_REFRESH_PLAN.md +++ b/docs/LOCATION_REFRESH_PLAN.md @@ -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. --- diff --git a/web/package-lock.json b/web/package-lock.json index 430fdf1..8e58ac4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,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" @@ -525,9 +526,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -545,9 +543,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -565,9 +560,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -585,9 +577,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -605,9 +594,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +611,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1826,6 +1809,12 @@ "node": ">=8" } }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/web/package.json b/web/package.json index 8301560..0d73149 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/generated/locales/es.ts b/web/src/generated/locales/es.ts index 4dbfb07..32a0447 100644 --- a/web/src/generated/locales/es.ts +++ b/web/src/generated/locales/es.ts @@ -5,8 +5,8 @@ import {html} from 'lit'; import {str} from '@lit/localize'; - /* eslint-disable no-irregular-whitespace */ - /* eslint-disable @typescript-eslint/no-explicit-any */ + + export const templates = { 'h3da2e750affa6331': html`VIN detectado ${0}`, @@ -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.`, @@ -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.`, @@ -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:`, diff --git a/web/src/services/fleet-cache.ts b/web/src/services/fleet-cache.ts index 6d95bbb..73fe131 100644 --- a/web/src/services/fleet-cache.ts +++ b/web/src/services/fleet-cache.ts @@ -1,3 +1,4 @@ +import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval'; import { VehicleCard } from '../types/vehicle.ts'; export interface FleetOverviewData { @@ -5,6 +6,16 @@ export interface FleetOverviewData { locations: Record; } +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 @@ -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; @@ -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 { + try { + const stored = await idbGet(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; }, }; diff --git a/web/src/views/fleet-list-view.ts b/web/src/views/fleet-list-view.ts index a5363a3..07d5f53 100644 --- a/web/src/views/fleet-list-view.ts +++ b/web/src/views/fleet-list-view.ts @@ -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'; @@ -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; @@ -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('/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; @@ -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(); - 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) { diff --git a/web/src/views/fleet-overview.ts b/web/src/views/fleet-overview.ts index cda5094..6f3e2b5 100644 --- a/web/src/views/fleet-overview.ts +++ b/web/src/views/fleet-overview.ts @@ -62,9 +62,6 @@ export class FleetOverviewView extends LitElement { @state() private denseCards = false; @state() private searchOpen = false; @state() private refreshing = false; - @state() private autoRefresh = false; - @state() private countdown = 60; - private countdownTimer: number | null = null; @state() private hiddenVehicles = new Set(); @state() private showHidden = false; private unsubscribeHidden: (() => void) | null = null; @@ -338,6 +335,7 @@ export class FleetOverviewView extends LitElement { * vehicle details and back doesn't re-trigger the loading state or refetch. */ private async loadVehicleData(force = false) { + let paintedFromPersist = false; if (!force) { const cached = FleetCache.get(this.tenantId); if (cached) { @@ -347,6 +345,20 @@ export class FleetOverviewView extends LitElement { this.placeMarkers(); return; } + // Cold load (hard reload / new tab): paint instantly from the last + // persisted snapshot while the /vehicles fetch below revalidates. + // Paint-only — execution always continues to the network, and the + // fresh response replaces everything painted 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; + paintedFromPersist = true; + this.placeMarkers(); + this.centerMap(); + } } let rawVehicles: Vehicle[] = []; @@ -373,10 +385,13 @@ export class FleetOverviewView extends LitElement { } // Sync map markers to the updated vehicle list immediately: remove any // marker for a vehicle that's no longer in the fleet (e.g. a shared - // vehicle that was revoked). New vehicles get markers as locations stream - // in below; existing vehicles keep their current markers until the final - // placeMarkers() call rebuilds everything at full fidelity. - if (force) { + // vehicle that was revoked). Needed after a force refresh and after a + // persisted-snapshot paint, both of which can have drawn markers for + // vehicles the fresh /vehicles response no longer contains. New + // vehicles get markers as locations stream in below; existing vehicles + // keep their current markers until the final placeMarkers() call + // rebuilds everything at full fidelity. + if (force || paintedFromPersist) { const currentIds = new Set(this.vehicles.map((v) => v.tokenId)); for (const [tokenId, marker] of this.markers) { if (!currentIds.has(tokenId)) { @@ -436,7 +451,6 @@ export class FleetOverviewView extends LitElement { private async refreshVehicles() { if (this.refreshing) return; - if (this.autoRefresh) this.countdown = 60; this.refreshing = true; this.errorMessage = null; try { @@ -446,26 +460,6 @@ export class FleetOverviewView extends LitElement { } } - private toggleAutoRefresh() { - if (this.autoRefresh) { - this.autoRefresh = false; - if (this.countdownTimer !== null) { - clearInterval(this.countdownTimer); - this.countdownTimer = null; - } - } else { - this.autoRefresh = true; - this.countdown = 60; - this.countdownTimer = window.setInterval(() => { - this.countdown--; - if (this.countdown <= 0) { - this.countdown = 60; - this.refreshVehicles(); - } - }, 1000); - } - } - async connectedCallback() { super.connectedCallback(); this.hiddenVehicles = hiddenVehiclesService.getHidden(this.tenantId); @@ -503,8 +497,10 @@ export class FleetOverviewView extends LitElement { this.updateMinZoom(); // Cached data may have already been loaded into `this.vehicles` / - // `this.lastLocations` before the map existed — place markers now. + // `this.lastLocations` before the map existed — place markers now and + // frame them (a persisted-snapshot paint can beat map creation). this.placeMarkers(); + if (this.markers.size > 0) this.centerMap(); } override disconnectedCallback() { @@ -512,10 +508,6 @@ export class FleetOverviewView extends LitElement { this.unsubscribeHidden = null; window.removeEventListener('theme-change', this.boundOnThemeChange); super.disconnectedCallback(); - if (this.countdownTimer !== null) { - clearInterval(this.countdownTimer); - this.countdownTimer = null; - } this.resizeObserver?.disconnect(); this.resizeObserver = null; this.markers.clear(); @@ -610,38 +602,6 @@ export class FleetOverviewView extends LitElement { border-bottom: 2px solid var(--primary); } header.top-bar .right { display: flex; align-items: center; gap: 16px; } - header.top-bar .live-tracking { - background: transparent; - color: var(--on-surface-variant); - border: 1px solid var(--outline-variant); - padding: 8px 16px; - border-radius: var(--radius-md); - font: var(--type-label-caps); - letter-spacing: 0.05em; - text-transform: uppercase; - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; - transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; - } - header.top-bar .live-tracking.active { - background: var(--primary); - color: var(--on-primary); - border-color: var(--primary); - } - header.top-bar .live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: currentColor; - animation: live-pulse 1.4s ease-in-out infinite; - flex-shrink: 0; - } - @keyframes live-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.35; transform: scale(0.65); } - } header.top-bar .icon-btn { color: var(--on-surface-variant); background: none; @@ -692,12 +652,6 @@ export class FleetOverviewView extends LitElement { border-color: var(--primary); } .map-controls button.active:hover { background: var(--primary-container); } - .countdown-text { - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; - letter-spacing: 0; - } .map-legend { position: absolute; @@ -1473,14 +1427,6 @@ export class FleetOverviewView extends LitElement {
-
@@ -1520,15 +1466,6 @@ export class FleetOverviewView extends LitElement { > refresh -