Add persistent car performance analytics
This commit is contained in:
+165
-12
@@ -1,12 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BatteryMedium, Gauge, Lightning, RoadHorizon, SteeringWheel, WarningCircle } from "@phosphor-icons/react";
|
||||
import { getCarTelemetry, type Availability, type CarTelemetry } from "./api";
|
||||
import {
|
||||
BatteryMedium,
|
||||
ChargingStation,
|
||||
ChartLineUp,
|
||||
Database,
|
||||
Gauge,
|
||||
MapTrifold,
|
||||
RoadHorizon,
|
||||
Speedometer,
|
||||
SteeringWheel,
|
||||
Timer,
|
||||
Trophy,
|
||||
WarningCircle
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
getCarHistory,
|
||||
getCarStatistics,
|
||||
getCarTelemetry,
|
||||
type Availability,
|
||||
type CarStatistics,
|
||||
type CarTelemetry,
|
||||
type TelemetryHistoryPoint,
|
||||
type TelemetryPeriod
|
||||
} from "./api";
|
||||
|
||||
const periods: Array<{ value: TelemetryPeriod; label: string }> = [
|
||||
{ value: "24h", label: "24H" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "1y", label: "1Y" },
|
||||
{ value: "all", label: "All" }
|
||||
];
|
||||
|
||||
export function CarPage() {
|
||||
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
|
||||
const [statistics, setStatistics] = useState<CarStatistics | null>(null);
|
||||
const [history, setHistory] = useState<TelemetryHistoryPoint[]>([]);
|
||||
const [period, setPeriod] = useState<TelemetryPeriod>("24h");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const refreshLive = useCallback(async () => {
|
||||
try {
|
||||
setTelemetry(await getCarTelemetry());
|
||||
setError("");
|
||||
@@ -15,11 +48,27 @@ export function CarPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
const [nextStatistics, nextHistory] = await Promise.all([getCarStatistics(), getCarHistory(period)]);
|
||||
setStatistics(nextStatistics);
|
||||
setHistory(nextHistory.points);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Car history could not be loaded");
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 1_000);
|
||||
void refreshLive();
|
||||
const interval = window.setInterval(() => void refreshLive(), 2_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refresh]);
|
||||
}, [refreshLive]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHistory();
|
||||
const interval = window.setInterval(() => void refreshHistory(), 15_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refreshHistory]);
|
||||
|
||||
const speed = telemetry?.speedKph.available ? telemetry.speedKph.value : null;
|
||||
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
|
||||
@@ -47,20 +96,124 @@ export function CarPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="energy-readout">
|
||||
<section className="energy-readout live-energy">
|
||||
<TelemetryValue icon={<BatteryMedium size={28} />} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" />
|
||||
<TelemetryValue icon={<RoadHorizon size={28} />} label="Estimated range" value={telemetry?.rangeKm} suffix="km" />
|
||||
<article className="telemetry-source"><Lightning size={28} /><div><strong>SAIC bridge needed</strong><p>Battery and range are not exposed in the captured Android property set. The verified AIDL service is the next data source.</p></div></article>
|
||||
<TelemetryValue icon={<RoadHorizon size={28} />} label="OEM range" value={telemetry?.rangeKm} suffix="km" />
|
||||
<TelemetryValue icon={<ChargingStation size={28} />} label="Battery voltage" value={telemetry?.batteryVoltage} suffix="V" decimals={1} />
|
||||
<TelemetryValue icon={<ChartLineUp size={28} />} label="Use since charge" value={telemetry?.totalConsumptionKwh} suffix="kWh" decimals={2} />
|
||||
</section>
|
||||
|
||||
<section className="record-grid" aria-label="All-time vehicle statistics">
|
||||
<RecordCard icon={<Trophy size={25} />} label="Peak speed" value={formatMetric(statistics?.records.peakSpeedKph, "km/h")} note={recordedDate(statistics?.records.peakSpeedAt)} />
|
||||
<RecordCard icon={<ChargingStation size={25} />} label="Peak charge estimate" value={formatMetric(statistics?.records.peakChargingPowerKw, "kW", 1)} note="Estimated from SOC gain over time" />
|
||||
<RecordCard icon={<MapTrifold size={25} />} label="Our range estimate" value={formatMetric(statistics?.lifetime.ownRangeEstimateKm, "km")} note={statistics?.lifetime.ownRangeEstimateKm ? "Based on observed distance per SOC" : "Learning after 10 km and 5% SOC"} />
|
||||
<RecordCard icon={<RoadHorizon size={25} />} label="Best OEM range" value={formatMetric(statistics?.records.maxObservedRangeKm, "km")} note={recordedDate(statistics?.records.maxObservedRangeAt)} />
|
||||
<RecordCard icon={<Speedometer size={25} />} label="Tracked distance" value={formatMetric(statistics?.lifetime.trackedDistanceKm, "km", 1)} note={`${formatMetric(statistics?.lifetime.averageMovingSpeedKph, "km/h")} moving average`} />
|
||||
<RecordCard icon={<Timer size={25} />} label="Observed drive time" value={formatDuration(statistics?.lifetime.drivingSeconds ?? 0)} note={`${statistics?.lifetime.chargeSessions ?? 0} charging sessions detected`} />
|
||||
</section>
|
||||
|
||||
<section className="history-panel">
|
||||
<div className="history-heading">
|
||||
<div><h2>Battery and performance history</h2><p>Indexed samples with long-term hourly rollups.</p></div>
|
||||
<div className="period-tabs" aria-label="History period">
|
||||
{periods.map((item) => <button key={item.value} className={period === item.value ? "active" : ""} onClick={() => setPeriod(item.value)}>{item.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-grid">
|
||||
<HistoryChart
|
||||
title="Energy position"
|
||||
points={history}
|
||||
first={{ key: "batteryPercent", label: "SOC", unit: "%", color: "var(--accent)" }}
|
||||
second={{ key: "rangeKm", label: "OEM range", unit: "km", color: "#64b8e8" }}
|
||||
/>
|
||||
<HistoryChart
|
||||
title="Observed peaks"
|
||||
points={history}
|
||||
first={{ key: "peakSpeedKph", label: "Speed", unit: "km/h", color: "var(--accent)" }}
|
||||
second={{ key: "chargingPowerKw", label: "Charge estimate", unit: "kW", color: "#64b8e8" }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="telemetry-storage">
|
||||
<Database size={26} />
|
||||
<div><strong>Bounded telemetry archive</strong><p>Raw samples: {statistics?.storage.rawSamples.toLocaleString() ?? "0"} of {statistics?.storage.rawSampleCap.toLocaleString() ?? "800,000"}. Hourly rollups: {statistics?.storage.hourlyRollups.toLocaleString() ?? "0"}.</p></div>
|
||||
<div><strong>{formatBytes(statistics?.storage.estimatedBytes ?? 0)}</strong><span>{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up</span></div>
|
||||
</section>
|
||||
|
||||
<footer className="telemetry-footer">
|
||||
<span>Source: live ADB system properties</span>
|
||||
<span>Sources: SAIC vehicle service through MG Utility and live ADB properties</span>
|
||||
<span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string }) {
|
||||
return <article className="energy-value">{icon}<span>{label}</span><strong>{value?.available ? `${Math.round(value.value)} ${suffix}` : "Unavailable"}</strong><small>{value && !value.available ? value.reason : "Live vehicle value"}</small></article>;
|
||||
type ChartKey = "batteryPercent" | "rangeKm" | "peakSpeedKph" | "chargingPowerKw";
|
||||
type ChartSeries = { key: ChartKey; label: string; unit: string; color: string };
|
||||
|
||||
function HistoryChart({ title, points, first, second }: { title: string; points: TelemetryHistoryPoint[]; first: ChartSeries; second: ChartSeries }) {
|
||||
const width = 900;
|
||||
const height = 230;
|
||||
const padding = 28;
|
||||
const timestamps = points.map((point) => point.timestamp);
|
||||
const start = Math.min(...timestamps);
|
||||
const end = Math.max(...timestamps);
|
||||
const coordinates = (series: ChartSeries) => {
|
||||
const values = points.map((point) => point[series.key]).filter((item): item is number => item !== null);
|
||||
if (values.length === 0) return { path: "", minimum: 0, maximum: 0 };
|
||||
const minimum = Math.min(...values);
|
||||
const maximum = Math.max(...values);
|
||||
const spread = Math.max(maximum - minimum, 1);
|
||||
const path = points
|
||||
.filter((point) => point[series.key] !== null)
|
||||
.map((point) => {
|
||||
const x = padding + ((point.timestamp - start) / Math.max(end - start, 1)) * (width - padding * 2);
|
||||
const y = height - padding - (((point[series.key] as number) - minimum) / spread) * (height - padding * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return { path, minimum, maximum };
|
||||
};
|
||||
const firstLine = coordinates(first);
|
||||
const secondLine = coordinates(second);
|
||||
|
||||
return <article className="history-chart">
|
||||
<div><h3>{title}</h3><div className="chart-legend"><span style={{ color: first.color }}>{first.label}</span><span style={{ color: second.color }}>{second.label}</span></div></div>
|
||||
{points.length < 2 ? <div className="chart-empty">Collecting enough samples to draw this period.</div> : <>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${first.label} and ${second.label} history`}>
|
||||
{[0.25, 0.5, 0.75].map((ratio) => <line key={ratio} x1={padding} y1={height * ratio} x2={width - padding} y2={height * ratio} className="chart-guide" />)}
|
||||
<polyline points={firstLine.path} fill="none" stroke={first.color} strokeWidth="4" vectorEffect="non-scaling-stroke" />
|
||||
<polyline points={secondLine.path} fill="none" stroke={second.color} strokeWidth="3" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<div className="chart-scale"><span>{firstLine.minimum.toFixed(0)}-{firstLine.maximum.toFixed(0)} {first.unit}</span><span>{secondLine.minimum.toFixed(0)}-{secondLine.maximum.toFixed(0)} {second.unit}</span></div>
|
||||
</>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function TelemetryValue({ icon, label, value: availability, suffix, decimals = 0 }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string; decimals?: number }) {
|
||||
return <article className="energy-value">{icon}<span>{label}</span><strong>{availability?.available ? `${availability.value.toFixed(decimals)} ${suffix}` : "Unavailable"}</strong><small>{availability && !availability.available ? availability.reason : "Live vehicle value"}</small></article>;
|
||||
}
|
||||
|
||||
function RecordCard({ icon, label, value, note }: { icon: React.ReactNode; label: string; value: string; note: string }) {
|
||||
return <article>{icon}<span>{label}</span><strong>{value}</strong><small>{note}</small></article>;
|
||||
}
|
||||
|
||||
function formatMetric(value: number | null | undefined, unit: string, decimals = 0): string {
|
||||
return value === null || value === undefined ? "Learning" : `${value.toFixed(decimals)} ${unit}`;
|
||||
}
|
||||
|
||||
function recordedDate(timestamp: number | null | undefined): string {
|
||||
return timestamp ? `Recorded ${new Date(timestamp).toLocaleDateString()}` : "No record yet";
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(bytes / 1024, 0).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB estimated`;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,64 @@ export type CarTelemetry = {
|
||||
wheelAngleDegrees: Availability<number>;
|
||||
batteryPercent: Availability<number>;
|
||||
rangeKm: Availability<number>;
|
||||
batteryVoltage: Availability<number>;
|
||||
totalConsumptionKwh: Availability<number>;
|
||||
chargingStatus: Availability<number>;
|
||||
};
|
||||
|
||||
export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
|
||||
|
||||
export type TelemetryHistoryPoint = {
|
||||
timestamp: number;
|
||||
speedKph: number | null;
|
||||
peakSpeedKph: number | null;
|
||||
batteryPercent: number | null;
|
||||
rangeKm: number | null;
|
||||
chargingPowerKw: number | null;
|
||||
};
|
||||
|
||||
export type CarStatistics = {
|
||||
latest: {
|
||||
recordedAt: number;
|
||||
speedKph: number | null;
|
||||
wheelAngleDegrees: number | null;
|
||||
batteryPercent: number | null;
|
||||
rangeKm: number | null;
|
||||
batteryVoltage: number | null;
|
||||
totalConsumptionKwh: number | null;
|
||||
chargingStatus: number | null;
|
||||
chargingPowerKw: number | null;
|
||||
} | null;
|
||||
records: {
|
||||
peakSpeedKph: number | null;
|
||||
peakSpeedAt: number | null;
|
||||
peakChargingPowerKw: number | null;
|
||||
peakChargingPowerAt: number | null;
|
||||
maxObservedRangeKm: number | null;
|
||||
maxObservedRangeAt: number | null;
|
||||
};
|
||||
lifetime: {
|
||||
firstSampleAt: number | null;
|
||||
lastSampleAt: number | null;
|
||||
sampleCount: number;
|
||||
trackedDistanceKm: number;
|
||||
drivingSeconds: number;
|
||||
chargingSeconds: number;
|
||||
chargeSessions: number;
|
||||
socUsedPercent: number;
|
||||
averageMovingSpeedKph: number | null;
|
||||
ownRangeEstimateKm: number | null;
|
||||
};
|
||||
storage: {
|
||||
rawSamples: number;
|
||||
hourlyRollups: number;
|
||||
oldestRawAt: number | null;
|
||||
oldestRollupAt: number | null;
|
||||
estimatedBytes: number;
|
||||
rawRetentionDays: number;
|
||||
rollupRetentionYears: number;
|
||||
rawSampleCap: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||
@@ -216,3 +274,11 @@ export async function deleteAdbShortcut(id: number, csrfToken: string): Promise<
|
||||
export async function getCarTelemetry(): Promise<CarTelemetry> {
|
||||
return parseResponse<CarTelemetry>(await fetch("/api/car/telemetry", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getCarHistory(period: TelemetryPeriod): Promise<{ period: TelemetryPeriod; points: TelemetryHistoryPoint[] }> {
|
||||
return parseResponse(await fetch(`/api/car/history?period=${period}`, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getCarStatistics(): Promise<CarStatistics> {
|
||||
return parseResponse<CarStatistics>(await fetch("/api/car/statistics", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
@@ -349,6 +349,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.telemetry-source strong { display: block; margin-bottom: 8px; }
|
||||
.telemetry-source p { max-width: 55ch; margin: 0; color: var(--muted); font-size: 0.8rem; line-height: 1.5; }
|
||||
.telemetry-footer { display: flex; justify-content: space-between; gap: 18px; color: var(--muted); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; }
|
||||
.live-energy { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.record-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.record-grid article { min-width: 0; min-height: 145px; display: grid; grid-template-columns: auto minmax(0, 1fr); align-content: center; gap: 7px 11px; padding: 23px; background: var(--surface); }
|
||||
.record-grid svg { color: var(--accent); }
|
||||
.record-grid span { align-self: center; color: var(--muted); font-size: 0.78rem; }
|
||||
.record-grid strong { grid-column: 1 / -1; margin-top: 10px; font: 750 clamp(1.35rem, 2vw, 2rem)/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.035em; }
|
||||
.record-grid small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 0.72rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.history-panel { min-width: 0; padding: 24px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.history-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||
.history-heading h2 { margin: 0 0 5px; font-size: 1.2rem; }
|
||||
.history-heading p { margin: 0; color: var(--muted); font-size: 0.78rem; }
|
||||
.period-tabs { display: flex; gap: 4px; padding: 4px; background: #10140f; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.period-tabs button { min-width: 48px; min-height: 38px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 0.72rem; font-weight: 750; cursor: pointer; }
|
||||
.period-tabs button:hover { color: var(--text); }
|
||||
.period-tabs button.active { color: var(--accent-ink); background: var(--accent); }
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.history-chart { min-width: 0; padding: 16px 17px 12px; background: #121610; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.history-chart > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 15px; }
|
||||
.history-chart h3 { margin: 0; font-size: 0.88rem; }
|
||||
.chart-legend { display: flex; gap: 13px; font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||
.chart-legend span::before { content: ""; width: 13px; height: 2px; display: inline-block; margin: 0 5px 3px 0; background: currentColor; }
|
||||
.history-chart svg { width: 100%; height: 220px; display: block; margin-top: 12px; overflow: visible; }
|
||||
.chart-guide { stroke: #30372d; stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.chart-scale { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: 0.66rem/1.3 ui-monospace, "Cascadia Code", monospace; }
|
||||
.chart-empty { min-height: 220px; display: grid !important; place-items: center; color: var(--muted); font-size: 0.78rem; text-align: center; }
|
||||
.telemetry-storage { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 15px; padding: 20px 23px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.telemetry-storage > svg { color: var(--accent); }
|
||||
.telemetry-storage > div { min-width: 0; }
|
||||
.telemetry-storage strong { display: block; margin-bottom: 5px; }
|
||||
.telemetry-storage p, .telemetry-storage span { margin: 0; color: var(--muted); font-size: 0.75rem; line-height: 1.4; }
|
||||
.telemetry-storage > div:last-child { text-align: right; }
|
||||
.telemetry-storage > div:last-child strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adb-command-grid { grid-template-columns: 1fr; }
|
||||
@@ -357,6 +389,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.gear-cluster strong { font-size: 4rem; }
|
||||
.energy-readout { grid-template-columns: 1fr 1fr; }
|
||||
.telemetry-source { grid-column: 1 / -1; }
|
||||
.record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -377,4 +411,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.energy-readout { grid-template-columns: 1fr; }
|
||||
.telemetry-source { grid-column: auto; }
|
||||
.telemetry-footer { flex-direction: column; }
|
||||
.record-grid { grid-template-columns: 1fr; }
|
||||
.history-heading { align-items: stretch; flex-direction: column; }
|
||||
.period-tabs { width: 100%; }
|
||||
.period-tabs button { min-width: 0; flex: 1; padding: 0 5px; }
|
||||
.history-panel { padding: 19px; }
|
||||
.history-chart svg { height: 180px; }
|
||||
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.telemetry-storage > div:last-child { grid-column: 1 / -1; text-align: left; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user