← back to Charge And Explore
backend/src/core/history.ts
65 lines
// Vehicle-keyed charge-level history (multi-car support, TK-10227).
// Every stored point lives under its vehicle's VIN key, so two cars on one
// account never interleave samples. Pure functions — no I/O — so the keying /
// dedupe / retention rules are unit-testable. The server owns persistence.
//
// Backwards compatibility: the on-disk file (data/tesla-history.json) has been
// a { vin: points[] } map since day one, so pre-multi-car history loads
// unchanged and remains readable as the default (first) vehicle's history.
// Nothing here ever deletes or rewrites another vehicle's points.
export interface HistoryPoint {
t: number; // epoch ms
soc: number; // battery %
range: number | null; // rated miles
charging: boolean;
kw?: number | null; // charger_power (recorded only while charging)
ea?: number | null; // charge_energy_added kWh (only while charging)
o?: number | null; // odometer miles (parked-view proof of no drive)
s?: boolean | null; // sentry mode
}
// Structural subset of the server's TeslaLastKnown — anything with these
// fields can be recorded.
export interface HistorySample {
vin: string;
soc: number | null;
rangeMiles: number | null;
chargingState: string | null;
stats?: {
chargerPowerKw?: number | null;
energyAddedKWh?: number | null;
odometerMiles?: number | null;
sentry?: boolean | null;
} | null;
}
export const HISTORY_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
export const HISTORY_MAX_POINTS = 5000; // per vehicle
// Append a sample to ITS OWN vehicle's timeline. Returns true when a point was
// recorded (caller marks dirty + persists). Spacing: ≥2 min while charging (so
// the kW curve keeps shape without page-poll flooding), ≥5 min while parked
// unless the SOC moved. Retention: 90 days / 5000 points, PER VEHICLE — one
// car's pruning never touches another car's points.
export function appendHistoryPoint(
history: Map<string, HistoryPoint[]>,
lk: HistorySample,
now: number = Date.now(),
): boolean {
const { vin, soc, rangeMiles: range, chargingState } = lk;
if (!vin || typeof soc !== "number") return false;
const pts = history.get(vin) ?? [];
const last = pts[pts.length - 1];
const charging = chargingState === "Charging";
if (last && now - last.t < (charging ? 2 : 5) * 60 * 1000 && (charging || last.soc === soc)) return false;
const p: HistoryPoint = { t: now, soc, range, charging };
if (charging) { p.kw = lk.stats?.chargerPowerKw ?? null; p.ea = lk.stats?.energyAddedKWh ?? null; }
if (typeof lk.stats?.odometerMiles === "number") p.o = lk.stats.odometerMiles;
if (typeof lk.stats?.sentry === "boolean") p.s = lk.stats.sentry;
pts.push(p);
const cutoff = now - HISTORY_RETENTION_MS;
history.set(vin, pts.filter((q) => q.t >= cutoff).slice(-HISTORY_MAX_POINTS));
return true;
}