← 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;
}