diff --git a/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts b/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts
index 6fb07ddb..37739a00 100644
--- a/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts
+++ b/angular-client/src/app/app-nav-bar/app-nav-bar.component.ts
@@ -207,6 +207,12 @@ export class AppNavBarComponent implements OnInit, OnDestroy {
label: 'Rules',
onClick: () => this.navigateTo(appRoutes.rulesRoute()),
icon: 'notifications'
+ },
+ {
+ id: appRoutes.lapTimerRoute(),
+ label: 'Lap Timer',
+ onClick: () => this.navigateTo(appRoutes.lapTimerRoute()),
+ icon: 'timer'
}
];
diff --git a/angular-client/src/app/app-routing.module.ts b/angular-client/src/app/app-routing.module.ts
index 31c35b39..87be37fa 100644
--- a/angular-client/src/app/app-routing.module.ts
+++ b/angular-client/src/app/app-routing.module.ts
@@ -10,6 +10,7 @@ import NotificationLogPageComponent from 'src/pages/notification-log-page/notifi
import FaultPageComponent from 'src/pages/fault-page/fault-page.component';
import GraphPageComponent from 'src/pages/graph-page/graph-page.component';
import LandingPageComponent from 'src/pages/landing-page/landing-page.component';
+import LapTimerPageComponent from 'src/pages/lap-timer-page/lap-timer-page.component';
import MapComponent from 'src/pages/map/map.component';
import NotificationRulesPageComponent from 'src/pages/notification-rules-page/notification-rules-page.component';
import { Segment } from 'src/utils/bms.utils';
@@ -27,6 +28,7 @@ const commandsRoute = () => `/commands`;
const rulesRoute = () => `/rules`;
const efusesRoute = () => `/efuses`;
const notificationLogRoute = () => `/notification-log`;
+const lapTimerRoute = () => `/lap-timer`;
export const appRoutes = {
landingRoute,
@@ -41,7 +43,8 @@ export const appRoutes = {
commandsRoute,
rulesRoute,
efusesRoute,
- notificationLogRoute
+ notificationLogRoute,
+ lapTimerRoute
};
// Routes should be defined carefully in accordance with the appRoutes
@@ -60,7 +63,8 @@ const routes: Routes = [
{ path: 'commands', component: CarCommandComponent },
{ path: 'rules', component: NotificationRulesPageComponent },
{ path: 'efuses', component: EfusesPageComponent },
- { path: 'notification-log', component: NotificationLogPageComponent }
+ { path: 'notification-log', component: NotificationLogPageComponent },
+ { path: 'lap-timer', component: LapTimerPageComponent }
];
@NgModule({
diff --git a/angular-client/src/components/gauge-stat/gauge-stat.component.css b/angular-client/src/components/gauge-stat/gauge-stat.component.css
new file mode 100644
index 00000000..92c01135
--- /dev/null
+++ b/angular-client/src/components/gauge-stat/gauge-stat.component.css
@@ -0,0 +1,18 @@
+.gauge-stat {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ gap: 4px;
+ padding: 8px 0;
+}
+.gauge-value {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: var(--font-size-xxl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+}
+.gauge-unit {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
diff --git a/angular-client/src/components/gauge-stat/gauge-stat.component.html b/angular-client/src/components/gauge-stat/gauge-stat.component.html
new file mode 100644
index 00000000..4136f0d2
--- /dev/null
+++ b/angular-client/src/components/gauge-stat/gauge-stat.component.html
@@ -0,0 +1,4 @@
+
+ {{ value() | number: digitsInfo() }}
+ {{ unit() }}
+
diff --git a/angular-client/src/components/gauge-stat/gauge-stat.component.ts b/angular-client/src/components/gauge-stat/gauge-stat.component.ts
new file mode 100644
index 00000000..2df36929
--- /dev/null
+++ b/angular-client/src/components/gauge-stat/gauge-stat.component.ts
@@ -0,0 +1,19 @@
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { DecimalPipe } from '@angular/common';
+
+@Component({
+ selector: 'gauge-stat',
+ templateUrl: './gauge-stat.component.html',
+ styleUrl: './gauge-stat.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [DecimalPipe]
+})
+export class GaugeStatComponent {
+ value = input.required();
+ unit = input('');
+ /** Optional color for the value; falls back to CSS default when empty. */
+ color = input('');
+ precision = input(0);
+
+ digitsInfo = computed(() => `1.0-${this.precision()}`);
+}
diff --git a/angular-client/src/components/half-gauge/half-gauge.component.css b/angular-client/src/components/half-gauge/half-gauge.component.css
index f5b1904c..4fecedfc 100644
--- a/angular-client/src/components/half-gauge/half-gauge.component.css
+++ b/angular-client/src/components/half-gauge/half-gauge.component.css
@@ -1,5 +1,32 @@
.gauge-container {
+ position: relative;
+ display: flex;
align-items: center;
- background-color: #2c2c2c;
- border-radius: 10px;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+.gauge-svg {
+ width: 100%;
+ height: 100%;
+}
+
+.gauge-track {
+ stroke: #1d1d1d;
+}
+
+.gauge-arc {
+ /* Smooth the arc between value updates without re-rendering the chart. */
+ transition: stroke-dashoffset 0.1s linear;
+}
+
+.gauge-value {
+ position: absolute;
+ bottom: 12%;
+ left: 50%;
+ transform: translateX(-50%);
+ font-weight: 300;
+ color: #fafafa;
+ pointer-events: none;
}
diff --git a/angular-client/src/components/half-gauge/half-gauge.component.html b/angular-client/src/components/half-gauge/half-gauge.component.html
index 4fb6f9d5..1b3d76b5 100644
--- a/angular-client/src/components/half-gauge/half-gauge.component.html
+++ b/angular-client/src/components/half-gauge/half-gauge.component.html
@@ -1,11 +1,16 @@
-
-
+
diff --git a/angular-client/src/components/half-gauge/half-gauge.component.ts b/angular-client/src/components/half-gauge/half-gauge.component.ts
index 1d55d927..1f90985f 100644
--- a/angular-client/src/components/half-gauge/half-gauge.component.ts
+++ b/angular-client/src/components/half-gauge/half-gauge.component.ts
@@ -1,99 +1,50 @@
-import { Component, Input, OnInit } from '@angular/core';
-
-import { ApexNonAxisChartSeries, ApexPlotOptions, ApexChart, ApexFill, NgApexchartsModule } from 'ng-apexcharts';
-import { NgStyle } from '@angular/common';
-
-export type ChartOptions = {
- series: ApexNonAxisChartSeries;
- chart: ApexChart;
- labels: string[];
- plotOptions: ApexPlotOptions;
- fill: ApexFill;
-};
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
@Component({
selector: 'half-gauge',
- templateUrl: 'half-gauge.component.html',
- styleUrls: ['half-gauge.component.css'],
- standalone: true,
- imports: [NgStyle, NgApexchartsModule]
+ templateUrl: './half-gauge.component.html',
+ styleUrls: ['./half-gauge.component.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
-export default class HalfGaugeComponent implements OnInit {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- public chartOptions!: Partial
| any;
- @Input() current: number = 50;
- @Input() min: number = 0;
- @Input() max: number = 100;
- @Input() unit: string = 'm/s';
- @Input() color: string = '#ff0000';
- @Input() size: number = 200;
+export default class HalfGaugeComponent {
+ current = input(50);
+ min = input(0);
+ max = input(100);
+ unit = input('m/s');
+ color = input('#ff0000');
+ size = input(200);
+
+ // Stroke thickness as a fraction of width, matching the old radialBar look.
+ private readonly strokeWidth = computed(() => this.size() * 0.1);
+ private readonly radius = computed(() => (this.size() - this.strokeWidth()) / 2);
+ // Baseline y of the semicircle (its flat edge sits at the bottom).
+ private readonly centerY = computed(() => this.radius() + this.strokeWidth() / 2);
+
+ protected readonly stroke = computed(() => this.strokeWidth());
+ // Half-circle footprint: width = size, height = size / 2 (matches the old gauge).
+ protected readonly heightPx = computed(() => this.size() / 2);
+ protected readonly fontSizePx = computed(() => this.size() / 10);
+ protected readonly viewBox = computed(() => `0 0 ${this.size()} ${this.heightPx()}`);
- widthpx: string = '200px';
- heightpx: string = '200px';
- paddingTop: string = '20px';
- label: string = 'm/s';
- percentage: number = 50;
- fontsize: string = '50px';
+ // Top semicircle from the left edge to the right edge.
+ protected readonly arcPath = computed(() => {
+ const s = this.strokeWidth() / 2;
+ const cy = this.centerY();
+ return `M ${s} ${cy} A ${this.radius()} ${this.radius()} 0 0 1 ${this.size() - s} ${cy}`;
+ });
+ protected readonly arcLength = computed(() => Math.PI * this.radius());
- ngOnInit() {
- this.widthpx = this.size + 'px';
- this.heightpx = this.size * 0.5 + 'px';
- this.paddingTop = '';
- this.label = this.current + this.unit;
- this.percentage = ((this.current - this.min) / (this.max - this.min)) * 100;
- this.fontsize = this.size / 10 + 'px';
+ protected readonly percentage = computed(() => {
+ const pct = ((this.current() - this.min()) / (this.max() - this.min())) * 100;
+ return Math.max(0, Math.min(100, pct));
+ });
+
+ // The only value that changes per tick: a single attribute on one .
+ protected readonly dashOffset = computed(() => this.arcLength() * (1 - this.percentage() / 100));
+
+ protected readonly label = computed(() => formatGaugeValue(this.current()) + this.unit());
+}
- // apex radial charts are hard coded to work with percentages, so converting to percentage to
- // accurately represent min and max in chart and then using actual value and unit as label
- this.chartOptions = {
- series: [this.percentage],
- chart: {
- type: 'radialBar',
- foreColor: '#eeeeee', // text color
- redrawOnParentResize: true,
- offsetY: -100
- },
- plotOptions: {
- radialBar: {
- startAngle: -90,
- endAngle: 90,
- offsetY: 100,
- hollow: {
- margin: 10,
- size: '60%'
- },
- track: {
- background: '#1d1d1d',
- strokeWidth: '97%',
- margin: 5, // margin is in pixels
- dropShadow: {
- enabled: false,
- top: 2,
- left: 0,
- opacity: 0,
- blur: 2
- }
- },
- dataLabels: {
- name: {
- show: true,
- color: '#fafafa',
- fontSize: this.fontsize,
- fontFamily: undefined,
- fontWeight: 300,
- offsetY: -5
- },
- value: {
- show: false
- }
- }
- }
- },
- fill: {
- type: 'solid',
- colors: [this.color]
- },
- labels: [this.label]
- };
- }
+function formatGaugeValue(n: number): string {
+ return (Math.round(n * 100) / 100).toFixed(2);
}
diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css
new file mode 100644
index 00000000..35b5128e
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css
@@ -0,0 +1,12 @@
+.page-grid {
+ margin: 0 16px;
+}
+
+.gauge-wrap {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html
new file mode 100644
index 00000000..c79b1abc
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts
new file mode 100644
index 00000000..62bc55a0
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts
@@ -0,0 +1,77 @@
+import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { MatGridList, MatGridTile } from '@angular/material/grid-list';
+import { ConfirmationService, MessageService } from 'primeng/api';
+import { ConfirmDialog } from 'primeng/confirmdialog';
+import { Toast } from 'primeng/toast';
+import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component';
+import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
+import { GaugeStatComponent } from 'src/components/gauge-stat/gauge-stat.component';
+import Storage from 'src/services/storage.service';
+import { topics } from 'src/utils/topic.utils';
+import SessionsPanelComponent from './sessions-panel/sessions-panel.component';
+import TimerHeroComponent from './timer-hero/timer-hero.component';
+import SessionSummaryComponent from './session-summary/session-summary.component';
+import LapsTableComponent from './laps-table/laps-table.component';
+
+@Component({
+ selector: 'lap-timer-page',
+ templateUrl: './lap-timer-page.component.html',
+ styleUrl: './lap-timer-page.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [ConfirmationService, MessageService],
+ imports: [
+ MatGridList,
+ MatGridTile,
+ ConfirmDialog,
+ Toast,
+ HalfGaugeComponent,
+ InfoBackgroundComponent,
+ GaugeStatComponent,
+ SessionsPanelComponent,
+ TimerHeroComponent,
+ SessionSummaryComponent,
+ LapsTableComponent
+ ]
+})
+export default class LapTimerPageComponent implements OnInit, OnDestroy {
+ private storage = inject(Storage);
+
+ readonly liveSpeed = signal(0);
+ readonly liveMotorTemp = signal(0);
+ readonly liveSoc = signal(0);
+
+ readonly speedGaugeColor = signal('#1ae824');
+
+ readonly socColor = computed(() => {
+ const soc = this.liveSoc();
+ if (soc >= 60) return 'var(--color-battery-high)';
+ if (soc >= 30) return 'var(--color-battery-med)';
+ return 'var(--color-battery-low)';
+ });
+
+ readonly motorTempColor = computed(() => {
+ const t = this.liveMotorTemp();
+ if (t < 60) return 'var(--color-text-primary)';
+ if (t < 80) return 'var(--color-battery-med)';
+ return 'var(--color-battery-low)';
+ });
+
+ private subs: Subscription[] = [];
+
+ ngOnInit(): void {
+ const styles = getComputedStyle(document.documentElement);
+ const high = styles.getPropertyValue('--color-battery-high').trim();
+ if (high) this.speedGaugeColor.set(high);
+
+ this.subs.push(
+ this.storage.get(topics.speed()).subscribe((v) => this.liveSpeed.set(parseFloat(v.values[0]) || 0)),
+ this.storage.get(topics.motorTemp()).subscribe((v) => this.liveMotorTemp.set(parseFloat(v.values[0]) || 0)),
+ this.storage.get(topics.stateOfCharge()).subscribe((v) => this.liveSoc.set(parseFloat(v.values[0]) || 0))
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.subs.forEach((s) => s.unsubscribe());
+ }
+}
diff --git a/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.css b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.css
new file mode 100644
index 00000000..80b0a6b4
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.css
@@ -0,0 +1,120 @@
+:host {
+ display: flex;
+ width: 100%;
+ height: 100%;
+}
+
+.monospace {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+}
+
+.lap-table-wrap {
+ flex: 1 1 0;
+ min-height: 0;
+ width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-divider) transparent;
+}
+.lap-table-wrap::-webkit-scrollbar {
+ display: block;
+ width: 6px;
+}
+.lap-table-wrap::-webkit-scrollbar-thumb {
+ background: var(--color-divider);
+ border-radius: 3px;
+}
+.lap-table-wrap::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.empty-laps {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+}
+
+.lap-table-wrap :where(.p-datatable) {
+ font-family: var(--font-family);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+}
+.lap-table-wrap :where(th) {
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ background: transparent;
+ border-bottom: 1px solid var(--color-divider);
+ padding: 8px 12px;
+ text-align: left;
+}
+.lap-table-wrap :where(td) {
+ border-bottom: 1px solid var(--color-divider);
+ padding: 8px 12px;
+ vertical-align: middle;
+}
+.lap-table-wrap :where(tr):hover td {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.lap-row-best > td:first-child {
+ border-left: 3px solid var(--color-battery-high);
+}
+.lap-row-worst > td:first-child {
+ border-left: 3px solid var(--color-battery-low);
+}
+
+/* !important: overrides PrimeNG's higher-specificity th/td defaults. */
+.lap-table-wrap th.col-num,
+.lap-table-wrap td.col-num {
+ width: 56px;
+ text-align: right !important;
+}
+.lap-table-wrap th.col-dur,
+.lap-table-wrap td.col-dur {
+ width: 100px;
+ text-align: right !important;
+}
+.lap-table-wrap th.col-delta,
+.lap-table-wrap td.col-delta {
+ width: 110px;
+ text-align: right !important;
+}
+.lap-table-wrap th.col-time,
+.lap-table-wrap td.col-time {
+ width: 130px;
+ text-align: right !important;
+}
+.lap-table-wrap th.col-run,
+.lap-table-wrap td.col-run {
+ width: 64px;
+ text-align: right !important;
+}
+.lap-table-wrap th.col-stat,
+.lap-table-wrap td.col-stat {
+ width: 100px;
+ text-align: right !important;
+}
+
+.delta-positive {
+ color: var(--color-battery-low);
+}
+
+.best-flag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-battery-high);
+ font-weight: 600;
+}
+.best-flag mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+}
diff --git a/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.html b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.html
new file mode 100644
index 00000000..3ee82a97
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.html
@@ -0,0 +1,74 @@
+
+
+ @if (timer.laps().length === 0) {
+
+ @if (timer.activeSession()) {
+
+ } @else {
+
+ }
+
+ } @else {
+
+
+
+ | # |
+ Duration |
+ Δ Best |
+ Time of Day |
+ Started At |
+ Run |
+ Avg Speed |
+ Energy |
+ Max Temp |
+
+
+
+
+ | {{ lap.number }} |
+ {{ formatLapMs(lap.durationMs) }} |
+
+ @if (isBestLap(lap)) {
+ flag —
+ } @else {
+ {{ formatLapDelta(lap) }}
+ }
+ |
+ {{ lap.endEpochMs | date: 'HH:mm:ss.SSS' }} |
+ {{ lap.startEpochMs | date: 'HH:mm:ss.SSS' }} |
+ {{ lap.runId ?? '—' }} |
+
+ @if (lap.stats.avgSpeed !== null) {
+ {{ lap.stats.avgSpeed | number: '1.0-0' }} mph
+ } @else {
+ —
+ }
+ |
+
+ @if (lap.stats.energyUsed !== null) {
+ {{ lap.stats.energyUsed | number: '1.2-2' }}%
+ } @else {
+ —
+ }
+ |
+
+ @if (lap.stats.maxMotorTemp !== null) {
+ {{ lap.stats.maxMotorTemp | number: '1.0-0' }}°C
+ } @else {
+ —
+ }
+ |
+
+
+
+ }
+
+
diff --git a/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.ts b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.ts
new file mode 100644
index 00000000..d242cf3d
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.ts
@@ -0,0 +1,38 @@
+import { ChangeDetectionStrategy, Component, computed, inject, Signal } from '@angular/core';
+import { DatePipe, DecimalPipe } from '@angular/common';
+import { MatIcon } from '@angular/material/icon';
+import { TableModule } from 'primeng/table';
+import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
+import TypographyComponent from 'src/components/typography/typography.component';
+import LapTimerService from 'src/services/lap-timer.service';
+import { formatDeltaMs, formatMs, Lap } from 'src/utils/lap-timer.types';
+
+@Component({
+ selector: 'laps-table',
+ templateUrl: './laps-table.component.html',
+ styleUrl: './laps-table.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [DatePipe, DecimalPipe, MatIcon, TableModule, InfoBackgroundComponent, TypographyComponent]
+})
+export default class LapsTableComponent {
+ readonly timer = inject(LapTimerService);
+
+ readonly lapsNewestFirst: Signal = computed(() => this.timer.laps().slice().reverse());
+
+ formatLapMs(ms: number): string {
+ return formatMs(ms);
+ }
+
+ formatLapDelta(lap: Lap): string {
+ return formatDeltaMs(this.timer.deltaFromBest(lap.durationMs));
+ }
+
+ isBestLap(lap: Lap): boolean {
+ return this.timer.bestLap()?.number === lap.number;
+ }
+
+ isWorstLap(lap: Lap): boolean {
+ if (this.timer.laps().length < 3) return false;
+ return this.timer.worstLap()?.number === lap.number;
+ }
+}
diff --git a/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.css b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.css
new file mode 100644
index 00000000..31bbea9a
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.css
@@ -0,0 +1,57 @@
+:host {
+ display: flex;
+ width: 100%;
+ height: 100%;
+}
+
+.session-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 4px 8px;
+ width: 100%;
+}
+
+.hero-label {
+ font-family: var(--font-family);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--color-text-secondary);
+}
+
+.session-laps-hero {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: 56px;
+ font-weight: 700;
+ color: var(--color-text-primary);
+ text-align: center;
+ line-height: 1;
+}
+
+.summary-divider {
+ border-top: 1px solid var(--color-divider);
+ margin: 8px 0;
+}
+
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+.stat-label {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+.stat-value {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+.best-text {
+ color: var(--color-battery-high);
+}
diff --git a/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.html b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.html
new file mode 100644
index 00000000..2142f1f0
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.html
@@ -0,0 +1,35 @@
+
+
+
{{ timer.lapCount() }}
+
LAPS
+
+
+ Best
+
+ @if (timer.bestLap(); as best) {
+ {{ formatLapMs(best.durationMs) }}
+ } @else {
+ —
+ }
+
+
+
+ Avg
+
+ @if (timer.lapCount() > 0) {
+ {{ formatLapMs(timer.averageLapTime()) }}
+ } @else {
+ —
+ }
+
+
+
+ Energy
+ {{ timer.totalEnergyUsed() | number: '1.2-2' }}%
+
+
+ Run
+ {{ timer.activeSession()?.runIdAtSessionStart ?? '—' }}
+
+
+
diff --git a/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.ts b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.ts
new file mode 100644
index 00000000..7dc7b9a0
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.ts
@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { DecimalPipe } from '@angular/common';
+import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
+import LapTimerService from 'src/services/lap-timer.service';
+import { formatMs } from 'src/utils/lap-timer.types';
+
+@Component({
+ selector: 'session-summary',
+ templateUrl: './session-summary.component.html',
+ styleUrl: './session-summary.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [DecimalPipe, InfoBackgroundComponent]
+})
+export default class SessionSummaryComponent {
+ readonly timer = inject(LapTimerService);
+
+ formatLapMs(ms: number): string {
+ return formatMs(ms);
+ }
+}
diff --git a/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.css b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.css
new file mode 100644
index 00000000..311f4813
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.css
@@ -0,0 +1,230 @@
+:host {
+ display: flex;
+ width: 100%;
+ height: 100%;
+}
+
+.monospace {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+}
+
+.sessions-panel {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.sessions-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 8px 8px;
+ flex: 0 0 auto;
+}
+.sessions-toolbar-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Uppercase label style shared by the count and table headers. */
+.sessions-count,
+.sessions-table-wrap :where(th) {
+ font-size: var(--font-size-xs);
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ color: var(--color-text-secondary);
+}
+.sessions-count {
+ font-family: var(--font-family);
+}
+
+.sessions-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.sessions-table-wrap {
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: hidden auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-divider) transparent;
+}
+.sessions-table-wrap::-webkit-scrollbar {
+ display: block;
+ width: 6px;
+}
+.sessions-table-wrap::-webkit-scrollbar-thumb {
+ background: var(--color-divider);
+ border-radius: 3px;
+}
+.sessions-table-wrap::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* Body font shared by the table, rename input, and menu items. */
+.sessions-table-wrap :where(.p-datatable),
+.session-rename-input,
+.session-menu-item {
+ font-family: var(--font-family);
+ font-size: var(--font-size-sm);
+}
+.sessions-table-wrap :where(.p-datatable) {
+ color: var(--color-text-primary);
+}
+.sessions-table-wrap :where(th) {
+ font-weight: 600;
+ background: transparent;
+ border-bottom: 1px solid var(--color-divider);
+ padding: 8px 12px;
+ text-align: left;
+}
+.sessions-table-wrap :where(td) {
+ border-bottom: 1px solid var(--color-divider);
+ padding: 6px 12px;
+ vertical-align: middle;
+}
+.sessions-table-wrap :where(.session-row):hover td {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.session-row {
+ cursor: pointer;
+}
+.session-row-active > td:first-child {
+ border-left: 3px solid var(--color-battery-high);
+}
+.session-row-active > td {
+ background: rgba(255, 255, 255, 0.04);
+}
+.session-row-active:hover > td {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+/* !important: overrides PrimeNG's higher-specificity th/td defaults. */
+.sessions-table-wrap th.col-state,
+.sessions-table-wrap td.col-state {
+ width: 32px;
+ text-align: center !important;
+ padding-left: 8px !important;
+ padding-right: 0 !important;
+}
+.sessions-table-wrap th.col-name,
+.sessions-table-wrap td.col-name {
+ min-width: 0;
+ text-align: left !important;
+}
+.sessions-table-wrap th.col-started,
+.sessions-table-wrap td.col-started {
+ width: 140px;
+ white-space: nowrap;
+ text-align: left !important;
+}
+.sessions-table-wrap th.col-laps,
+.sessions-table-wrap td.col-laps {
+ width: 60px;
+ text-align: right !important;
+}
+.sessions-table-wrap th.col-best,
+.sessions-table-wrap td.col-best {
+ width: 90px;
+ text-align: right !important;
+}
+.sessions-table-wrap td.col-best {
+ color: var(--color-battery-high);
+}
+.sessions-table-wrap th.col-run,
+.sessions-table-wrap td.col-run {
+ width: 60px;
+ text-align: right !important;
+}
+.sessions-table-wrap th.col-actions,
+.sessions-table-wrap td.col-actions {
+ width: 40px;
+ text-align: right !important;
+}
+
+.session-name {
+ display: inline-block;
+ max-width: 100%;
+ font-weight: 600;
+ color: var(--color-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
+}
+
+.session-rename-input {
+ width: 100%;
+ min-width: 0;
+ background: transparent;
+ border: 1px solid var(--color-divider);
+ border-radius: 4px;
+ color: var(--color-text-primary);
+ font-weight: 600;
+ padding: 2px 6px;
+ outline: none;
+ box-sizing: border-box;
+}
+.session-rename-input:focus {
+ border-color: var(--color-battery-high);
+}
+
+.session-menu-btn {
+ background: transparent;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 2px;
+ display: inline-flex;
+ align-items: center;
+ border-radius: 4px;
+}
+.session-menu-btn:hover {
+ color: var(--color-text-primary);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.session-menu-list {
+ display: flex;
+ flex-direction: column;
+ min-width: 160px;
+}
+.session-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: transparent;
+ border: none;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ text-align: left;
+}
+.session-menu-item:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+.session-menu-item.destructive {
+ color: var(--color-battery-low);
+}
+
+/* Shared 18px icon sizing for the state icon and menu icons. */
+.session-state-icon,
+.session-menu-btn mat-icon,
+.session-menu-item mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+}
+.session-state-icon {
+ color: var(--color-battery-high);
+ vertical-align: middle;
+}
diff --git a/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.html b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.html
new file mode 100644
index 00000000..46ab120a
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.html
@@ -0,0 +1,118 @@
+
+
+
+
+ @if (timer.sessions().length === 0) {
+
+
+
+ } @else {
+
+ }
+
+
diff --git a/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.ts b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.ts
new file mode 100644
index 00000000..b2aef10e
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.ts
@@ -0,0 +1,142 @@
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { DatePipe } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIcon } from '@angular/material/icon';
+import { ConfirmationService, MessageService } from 'primeng/api';
+import { Popover } from 'primeng/popover';
+import { TableModule } from 'primeng/table';
+import { ButtonComponent } from 'src/components/argos-button/argos-button.component';
+import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
+import TypographyComponent from 'src/components/typography/typography.component';
+import LapTimerService from 'src/services/lap-timer.service';
+import { formatMs, LapSession } from 'src/utils/lap-timer.types';
+
+@Component({
+ selector: 'sessions-panel',
+ templateUrl: './sessions-panel.component.html',
+ styleUrl: './sessions-panel.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ DatePipe,
+ FormsModule,
+ MatIcon,
+ TableModule,
+ Popover,
+ ButtonComponent,
+ InfoBackgroundComponent,
+ TypographyComponent
+ ]
+})
+export default class SessionsPanelComponent {
+ readonly timer = inject(LapTimerService);
+ private confirmationService = inject(ConfirmationService);
+ private messageService = inject(MessageService);
+
+ readonly editingSessionId = signal(null);
+ readonly editingName = signal('');
+
+ onClearAllSessions = () => {
+ const count = this.timer.sessions().length;
+ if (count === 0) return;
+ this.confirmationService.confirm({
+ message: `Delete all ${count} ${count === 1 ? 'session' : 'sessions'} and their laps? This cannot be undone.`,
+ header: 'Clear All Sessions',
+ acceptLabel: 'Clear All',
+ rejectLabel: 'Cancel',
+ accept: () => {
+ this.timer.clearAllSessions();
+ this.toast('success', 'All sessions cleared', '');
+ }
+ });
+ };
+
+ onNewSession = () => {
+ if (!this.timer.activeSession() || this.timer.isIdle()) {
+ this.timer.createSession();
+ const s = this.timer.activeSession();
+ this.toast('success', 'Session created', s ? `"${s.name}"` : '');
+ return;
+ }
+ this.confirmationService.confirm({
+ message: 'Pause the current session and start a new one?',
+ header: 'New Session',
+ acceptLabel: 'New Session',
+ rejectLabel: 'Cancel',
+ accept: () => {
+ this.timer.createSession();
+ const s = this.timer.activeSession();
+ this.toast('success', 'Session created', s ? `"${s.name}"` : '');
+ }
+ });
+ };
+
+ onSelectSession = (id: string) => {
+ if (this.timer.activeSession()?.id === id) return;
+ this.timer.selectSession(id);
+ };
+
+ onDeleteSession = (session: LapSession, popover?: Popover) => {
+ popover?.hide();
+ if (session.laps.length === 0) {
+ this.timer.deleteSession(session.id);
+ this.toast('success', 'Session deleted', `"${session.name}"`);
+ return;
+ }
+ this.confirmationService.confirm({
+ message: `Delete "${session.name}" and its ${session.laps.length} laps?`,
+ header: 'Delete Session',
+ acceptLabel: 'Delete',
+ rejectLabel: 'Cancel',
+ accept: () => {
+ this.timer.deleteSession(session.id);
+ this.toast('success', 'Session deleted', `"${session.name}"`);
+ }
+ });
+ };
+
+ onDownload = (sessionId: string, popover?: Popover) => {
+ popover?.hide();
+ const filename = this.timer.downloadCsv(sessionId);
+ if (filename) this.toast('success', 'Downloaded', filename);
+ };
+
+ startEdit(session: LapSession, popover?: Popover): void {
+ popover?.hide();
+ this.editingSessionId.set(session.id);
+ this.editingName.set(session.name);
+ }
+
+ commitEdit(session: LapSession): void {
+ if (this.editingSessionId() !== session.id) return;
+ const next = this.editingName().trim();
+ if (next && next !== session.name) {
+ this.timer.renameSession(session.id, next);
+ }
+ this.cancelEdit();
+ }
+
+ cancelEdit(): void {
+ this.editingSessionId.set(null);
+ this.editingName.set('');
+ }
+
+ formatLapMs(ms: number): string {
+ return formatMs(ms);
+ }
+
+ bestLapMsForSession(sessionId: string): number | null {
+ return this.timer.getBestLapMs(sessionId);
+ }
+
+ /** Null for historical rows. */
+ activeStateIcon(session: LapSession): string | null {
+ if (this.timer.activeSession()?.id !== session.id) return null;
+ if (session.isRunning) return 'play_arrow';
+ if (session.isPaused) return 'pause';
+ return null;
+ }
+
+ private toast(severity: 'success' | 'warn' | 'error' | 'info', summary: string, detail: string): void {
+ this.messageService.add({ severity, summary, detail, life: 2000 });
+ }
+}
diff --git a/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.css b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.css
new file mode 100644
index 00000000..98552714
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.css
@@ -0,0 +1,81 @@
+:host {
+ display: flex;
+ width: 100%;
+ height: 100%;
+}
+
+.timer-hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 0;
+ width: 100%;
+}
+
+.hero-label {
+ font-family: var(--font-family);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--color-text-secondary);
+}
+
+.hero-total {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: 64px;
+ font-weight: 700;
+ color: var(--color-text-primary);
+ line-height: 1;
+}
+
+.hero-divider {
+ width: 60%;
+ border-top: 1px solid var(--color-divider);
+ margin: 4px 0;
+}
+
+.hero-current-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: baseline;
+ gap: 12px;
+ width: 80%;
+}
+
+.hero-current-time {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ color: var(--color-text-subtitle);
+ text-align: center;
+}
+
+.hero-current-delta {
+ font-family: var(--font-family);
+ font-variant-numeric: tabular-nums;
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ text-align: right;
+}
+
+.delta-positive {
+ color: var(--color-battery-low);
+}
+.delta-negative {
+ color: var(--color-battery-high);
+}
+.delta-neutral {
+ color: var(--color-text-secondary);
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 8px;
+}
diff --git a/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.html b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.html
new file mode 100644
index 00000000..f24faffb
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.html
@@ -0,0 +1,74 @@
+
+
+
TOTAL
+
{{ timer.formattedTotal() }}
+
+
+
LAP {{ timer.lapCount() + 1 }}
+
{{ timer.formattedCurrentLap() }}
+
{{ currentLapDeltaText() }}
+
+
+ @if (!timer.activeSession()) {
+
+ } @else if (timer.isIdle()) {
+
+
+ } @else if (timer.isRunning()) {
+
+
+
+ } @else if (timer.isPaused()) {
+
+
+
+
+ }
+
+
+
diff --git a/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.ts b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.ts
new file mode 100644
index 00000000..e0e09a5b
--- /dev/null
+++ b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.ts
@@ -0,0 +1,65 @@
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ConfirmationService, MessageService } from 'primeng/api';
+import { ButtonComponent } from 'src/components/argos-button/argos-button.component';
+import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component';
+import LapTimerService from 'src/services/lap-timer.service';
+import { formatDeltaMs } from 'src/utils/lap-timer.types';
+
+@Component({
+ selector: 'timer-hero',
+ templateUrl: './timer-hero.component.html',
+ styleUrl: './timer-hero.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ButtonComponent, InfoBackgroundComponent]
+})
+export default class TimerHeroComponent {
+ readonly timer = inject(LapTimerService);
+ private confirmationService = inject(ConfirmationService);
+ private messageService = inject(MessageService);
+
+ readonly currentLapDeltaText = computed(() => formatDeltaMs(this.timer.currentLapDeltaToBestMs()));
+ readonly currentLapDeltaClass = computed(() => {
+ const d = this.timer.currentLapDeltaToBestMs();
+ if (d === null) return 'delta-neutral';
+ return d < 0 ? 'delta-negative' : d > 0 ? 'delta-positive' : 'delta-neutral';
+ });
+
+ onStart = () => this.timer.start();
+ onPause = () => this.timer.pause();
+ onResume = () => this.timer.resume();
+ onLap = () => this.timer.lap();
+ onStop = () => this.timer.stop();
+
+ onReset = () => {
+ if (this.timer.laps().length === 0 && this.timer.currentLapTimeMs() === 0) {
+ this.timer.reset();
+ return;
+ }
+ this.confirmationService.confirm({
+ message: 'Discard all recorded laps in this session?',
+ header: 'Reset Active Session',
+ acceptLabel: 'Reset',
+ rejectLabel: 'Cancel',
+ accept: () => this.timer.reset()
+ });
+ };
+
+ onEndActiveSession = () => {
+ this.timer.endActiveSession();
+ this.toast('success', 'Session ended', 'It remains in the history.');
+ };
+
+ onDeleteActiveButton = () => {
+ const active = this.timer.activeSession();
+ if (active) this.timer.deleteSession(active.id);
+ };
+
+ onDownloadActiveButton = () => {
+ const filename = this.timer.downloadCsv();
+ if (filename) this.toast('success', 'Downloaded', filename);
+ };
+
+ private toast(severity: 'success' | 'warn' | 'error' | 'info', summary: string, detail: string): void {
+ this.messageService.add({ severity, summary, detail, life: 2000 });
+ }
+}
diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts
new file mode 100644
index 00000000..a2c5ec08
--- /dev/null
+++ b/angular-client/src/services/lap-timer.service.ts
@@ -0,0 +1,477 @@
+import { computed, inject, Injectable, signal, Signal } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { v4 as uuidv4 } from 'uuid';
+import { downloadAsFile } from 'src/utils/file.utils';
+import { topics } from 'src/utils/topic.utils';
+import {
+ defaultSessionName,
+ emptyLapStore,
+ escapeCsvCell,
+ formatDeltaMs,
+ formatMs,
+ isLapStore,
+ Lap,
+ LapSession,
+ LapStats,
+ LapStore,
+ LAP_STORE_STORAGE_KEY,
+ slugifySessionName
+} from 'src/utils/lap-timer.types';
+import Storage from './storage.service';
+
+export type LapState = 'idle' | 'running' | 'paused';
+
+const TICK_INTERVAL_MS = 100;
+
+@Injectable({ providedIn: 'root' })
+export default class LapTimerService {
+ private storage = inject(Storage);
+
+ private readonly store = signal(hydrate());
+
+ /** Drives time-derived computeds. */
+ private readonly tickSignal = signal(0);
+ private tickInterval: ReturnType | null = null;
+
+ private speedSamples: number[] = [];
+ private lastSoc: number | null = null;
+ private lapSocStart: number | null = null;
+ private lapMaxMotorTemp: number | null = null;
+ private telemetrySubs: Subscription[] = [];
+
+ readonly sessions: Signal = computed(() => this.store().sessions);
+ readonly activeSession: Signal = computed(() => {
+ const s = this.store();
+ return s.sessions.find((x) => x.id === s.activeSessionId) ?? null;
+ });
+
+ readonly state = computed(() => {
+ const s = this.activeSession();
+ if (!s) return 'idle';
+ if (s.isRunning) return 'running';
+ if (s.isPaused) return 'paused';
+ return 'idle';
+ });
+ readonly isRunning = computed(() => this.state() === 'running');
+ readonly isPaused = computed(() => this.state() === 'paused');
+ readonly isIdle = computed(() => this.state() === 'idle');
+ readonly laps: Signal = computed(() => this.activeSession()?.laps ?? []);
+ readonly lapCount = computed(() => this.laps().length);
+
+ readonly currentLapTimeMs = computed(() => {
+ this.tickSignal();
+ const s = this.activeSession();
+ if (!s) return 0;
+ if (s.isRunning && s.currentLapStartEpochMs !== null) {
+ return s.currentLapAccumulatedMs + (Date.now() - s.currentLapStartEpochMs);
+ }
+ return s.currentLapAccumulatedMs;
+ });
+
+ readonly totalTimeMs = computed(() => {
+ const s = this.activeSession();
+ if (!s) return 0;
+ const sumLaps = s.laps.reduce((acc, l) => acc + l.durationMs, 0);
+ return sumLaps + this.currentLapTimeMs();
+ });
+
+ readonly formattedCurrentLap = computed(() => formatMs(this.currentLapTimeMs()));
+ readonly formattedTotal = computed(() => formatMs(this.totalTimeMs()));
+
+ readonly bestLap = computed(() => {
+ const ls = this.laps();
+ if (ls.length === 0) return null;
+ return ls.reduce((best, lap) => (lap.durationMs < best.durationMs ? lap : best));
+ });
+
+ readonly worstLap = computed(() => {
+ const ls = this.laps();
+ if (ls.length < 2) return null;
+ return ls.reduce((worst, lap) => (lap.durationMs > worst.durationMs ? lap : worst));
+ });
+
+ readonly averageLapTime = computed(() => {
+ const ls = this.laps();
+ if (ls.length === 0) return 0;
+ return ls.reduce((sum, lap) => sum + lap.durationMs, 0) / ls.length;
+ });
+
+ readonly totalEnergyUsed = computed(() => {
+ return this.laps().reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0);
+ });
+
+ readonly currentLapDeltaToBestMs = computed(() => {
+ const best = this.bestLap();
+ if (!best) return null;
+ return this.currentLapTimeMs() - best.durationMs;
+ });
+
+ constructor() {
+ if (this.activeSession()?.isRunning) {
+ this.subscribeTelemetry();
+ this.startTickLoop();
+ }
+ }
+
+ deltaFromBest(lapDurationMs: number): number | null {
+ const best = this.bestLap();
+ if (!best) return null;
+ return lapDurationMs - best.durationMs;
+ }
+
+ getBestLapMs(sessionId?: string): number | null {
+ const s = sessionId ? this.findSession(sessionId) : this.activeSession();
+ if (!s || s.laps.length === 0) return null;
+ return s.laps.reduce((min, l) => (l.durationMs < min ? l.durationMs : min), Infinity);
+ }
+
+ /** Auto-creates a session if none is active. */
+ start(): void {
+ if (!this.activeSession()) {
+ this.createSession();
+ }
+ const session = this.activeSession();
+ if (!session || session.isRunning) return;
+ this.mutateActive((s) => {
+ s.isRunning = true;
+ s.isPaused = false;
+ s.currentLapStartEpochMs = Date.now();
+ });
+ if (this.telemetrySubs.length === 0) this.subscribeTelemetry();
+ this.startTickLoop();
+ }
+
+ pause(): void {
+ const s = this.activeSession();
+ if (!s || !s.isRunning) return;
+ this.mutateActive((next) => {
+ const slice = next.currentLapStartEpochMs !== null ? Date.now() - next.currentLapStartEpochMs : 0;
+ next.currentLapAccumulatedMs += slice;
+ next.currentLapStartEpochMs = null;
+ next.isRunning = false;
+ next.isPaused = true;
+ });
+ this.stopTickLoop();
+ // Telemetry stays subscribed across pause/resume.
+ }
+
+ resume(): void {
+ if (!this.isPaused()) return;
+ this.mutateActive((next) => {
+ next.isRunning = true;
+ next.isPaused = false;
+ next.currentLapStartEpochMs = Date.now();
+ });
+ if (this.telemetrySubs.length === 0) this.subscribeTelemetry();
+ this.startTickLoop();
+ }
+
+ /** Each lap captures runId at record time. */
+ lap(): void {
+ const session = this.activeSession();
+ if (!session || !session.isRunning || session.currentLapStartEpochMs === null) return;
+ const endEpochMs = Date.now();
+ const durationMs = session.currentLapAccumulatedMs + (endEpochMs - session.currentLapStartEpochMs);
+ if (durationMs === 0) return;
+
+ const stats = this.snapshotStats();
+ const lastLap = session.laps[session.laps.length - 1];
+ const startEpochMs = lastLap ? lastLap.endEpochMs : session.sessionStartEpochMs;
+ const newLap: Lap = {
+ number: session.laps.length + 1,
+ startEpochMs,
+ endEpochMs,
+ durationMs,
+ runId: this.storage.getCurrentRunId().getValue() ?? null,
+ stats
+ };
+
+ this.mutateActive((next) => {
+ next.laps = [...next.laps, newLap];
+ next.currentLapAccumulatedMs = 0;
+ next.currentLapStartEpochMs = endEpochMs;
+ });
+ this.resetLapAccumulators();
+ }
+
+ stop(): void {
+ if (this.isRunning()) {
+ this.lap();
+ this.pause();
+ }
+ }
+
+ reset(): void {
+ if (!this.activeSession()) return;
+ this.mutateActive((next) => {
+ next.laps = [];
+ next.currentLapAccumulatedMs = 0;
+ next.currentLapStartEpochMs = null;
+ next.isRunning = false;
+ next.isPaused = false;
+ });
+ this.stopTickLoop();
+ this.resetLapAccumulators();
+ }
+
+ createSession(name?: string): string {
+ if (this.isRunning()) this.pause();
+ this.unsubscribeTelemetry();
+ this.resetSocBaseline();
+
+ const startEpochMs = Date.now();
+ const runId = this.storage.getCurrentRunId().getValue() ?? null;
+ const newSession: LapSession = {
+ id: uuidv4(),
+ name: name?.trim() || defaultSessionName(startEpochMs, runId),
+ sessionStartEpochMs: startEpochMs,
+ runIdAtSessionStart: runId,
+ laps: [],
+ isRunning: false,
+ isPaused: false,
+ currentLapStartEpochMs: null,
+ currentLapAccumulatedMs: 0
+ };
+
+ this.mutateStore((store) => {
+ store.sessions = [newSession, ...store.sessions];
+ store.activeSessionId = newSession.id;
+ });
+ return newSession.id;
+ }
+
+ selectSession(id: string): void {
+ if (this.store().activeSessionId === id) return;
+ if (this.isRunning()) this.pause();
+ this.unsubscribeTelemetry();
+ this.resetSocBaseline();
+ this.stopTickLoop();
+ this.mutateStore((store) => {
+ if (store.sessions.some((s) => s.id === id)) {
+ store.activeSessionId = id;
+ }
+ });
+ if (this.activeSession()?.isRunning) {
+ this.subscribeTelemetry();
+ this.startTickLoop();
+ }
+ }
+
+ renameSession(id: string, name: string): void {
+ const trimmed = name?.trim();
+ if (!trimmed) return;
+ this.mutateStore((store) => {
+ store.sessions = store.sessions.map((s) => (s.id === id ? { ...s, name: trimmed } : s));
+ });
+ }
+
+ deleteSession(id: string): void {
+ const wasActive = this.store().activeSessionId === id;
+ if (wasActive) {
+ this.stopTickLoop();
+ this.unsubscribeTelemetry();
+ }
+ this.mutateStore((store) => {
+ store.sessions = store.sessions.filter((s) => s.id !== id);
+ if (wasActive) store.activeSessionId = null;
+ });
+ }
+
+ endActiveSession(): void {
+ if (!this.activeSession()) return;
+ if (this.isRunning()) this.pause();
+ this.unsubscribeTelemetry();
+ this.stopTickLoop();
+ this.mutateStore((store) => {
+ store.activeSessionId = null;
+ });
+ }
+
+ clearAllSessions(): void {
+ this.stopTickLoop();
+ this.unsubscribeTelemetry();
+ this.mutateStore((store) => {
+ store.sessions = [];
+ store.activeSessionId = null;
+ });
+ }
+
+ /** Split from downloadCsv() for testability. */
+ buildCsv(sessionId?: string): { filename: string; body: string } | null {
+ const s = sessionId ? this.findSession(sessionId) : this.activeSession();
+ if (!s) return null;
+
+ const bestLapMs = s.laps.length === 0 ? null : Math.min(...s.laps.map((l) => l.durationMs));
+
+ const columns = ['Lap', 'Duration', '+/- Best', 'Time of Day', 'Run', 'Avg Speed (mph)', 'Energy (%)', 'Max Temp (°C)'];
+
+ const rows = s.laps.map((l) => {
+ const deltaBestMs = bestLapMs === null ? null : l.durationMs - bestLapMs;
+ const isBest = bestLapMs !== null && l.durationMs === bestLapMs;
+ return [
+ l.number,
+ formatMs(l.durationMs),
+ isBest ? '' : deltaBestMs === null ? '' : formatDeltaMs(deltaBestMs),
+ new Date(l.endEpochMs).toISOString().slice(11, 23),
+ l.runId ?? '',
+ l.stats.avgSpeed !== null ? l.stats.avgSpeed.toFixed(1) : '',
+ l.stats.energyUsed !== null ? l.stats.energyUsed.toFixed(2) : '',
+ l.stats.maxMotorTemp !== null ? l.stats.maxMotorTemp.toFixed(0) : ''
+ ]
+ .map(escapeCsvCell)
+ .join(',');
+ });
+
+ const body = [columns.join(','), ...rows].join('\r\n') + '\r\n';
+ const sessionStartIso = new Date(s.sessionStartEpochMs).toISOString();
+ const datePart = sessionStartIso.slice(0, 19).replace(/:/g, '-');
+ const filename = `argos-laps-${slugifySessionName(s.name)}-${s.runIdAtSessionStart ?? 'norun'}-${datePart}.csv`;
+ return { filename, body };
+ }
+
+ downloadCsv(sessionId?: string): string | null {
+ const built = this.buildCsv(sessionId);
+ if (!built) return null;
+ downloadAsFile(built.filename, built.body, 'text/csv;charset=utf-8;');
+ return built.filename;
+ }
+
+ private mutateStore(mutator: (s: LapStore) => void): void {
+ const next = cloneStore(this.store());
+ mutator(next);
+ this.store.set(next);
+ this.persist(next);
+ }
+
+ private mutateActive(mutator: (s: LapSession) => void): void {
+ const activeId = this.store().activeSessionId;
+ if (activeId === null) return;
+ this.mutateStore((store) => {
+ store.sessions = store.sessions.map((s) => {
+ if (s.id !== activeId) return s;
+ const draft = { ...s, laps: [...s.laps] };
+ mutator(draft);
+ return draft;
+ });
+ });
+ }
+
+ private persist(store: LapStore): void {
+ try {
+ localStorage.setItem(LAP_STORE_STORAGE_KEY, JSON.stringify(store));
+ } catch (e) {
+ console.warn('LapTimerService: localStorage write failed', e);
+ }
+ }
+
+ private findSession(id: string): LapSession | null {
+ return this.store().sessions.find((s) => s.id === id) ?? null;
+ }
+
+ private startTickLoop(): void {
+ if (this.tickInterval !== null) return;
+ this.tickInterval = setInterval(() => {
+ this.tickSignal.update((n) => (n + 1) | 0);
+ }, TICK_INTERVAL_MS);
+ }
+
+ private stopTickLoop(): void {
+ if (this.tickInterval !== null) {
+ clearInterval(this.tickInterval);
+ this.tickInterval = null;
+ }
+ // Final bump so derived computeds settle.
+ this.tickSignal.update((n) => (n + 1) | 0);
+ }
+
+ private subscribeTelemetry(): void {
+ this.resetLapAccumulators();
+
+ this.telemetrySubs.push(
+ this.storage.get(topics.speed()).subscribe((value) => {
+ if (!this.isRunning()) return;
+ const speed = parseFloat(value.values[0]);
+ if (!isNaN(speed)) this.speedSamples.push(speed);
+ })
+ );
+ this.telemetrySubs.push(
+ this.storage.get(topics.stateOfCharge()).subscribe((value) => {
+ if (!this.isRunning()) return;
+ const soc = parseFloat(value.values[0]);
+ if (!isNaN(soc)) {
+ if (this.lapSocStart === null) this.lapSocStart = soc;
+ this.lastSoc = soc;
+ }
+ })
+ );
+ this.telemetrySubs.push(
+ this.storage.get(topics.motorTemp()).subscribe((value) => {
+ if (!this.isRunning()) return;
+ const temp = parseFloat(value.values[0]);
+ if (!isNaN(temp)) {
+ this.lapMaxMotorTemp = this.lapMaxMotorTemp === null ? temp : Math.max(this.lapMaxMotorTemp, temp);
+ }
+ })
+ );
+ }
+
+ private unsubscribeTelemetry(): void {
+ this.telemetrySubs.forEach((sub) => sub.unsubscribe());
+ this.telemetrySubs = [];
+ }
+
+ private snapshotStats(): LapStats {
+ const samples = this.speedSamples;
+ const avgSpeed = samples.length > 0 ? samples.reduce((a, b) => a + b, 0) / samples.length : null;
+ const maxSpeed = samples.length > 0 ? Math.max(...samples) : null;
+ const energyUsed = this.lapSocStart !== null && this.lastSoc !== null ? this.lapSocStart - this.lastSoc : null;
+ return {
+ avgSpeed,
+ maxSpeed,
+ socStart: this.lapSocStart,
+ socEnd: this.lastSoc,
+ energyUsed,
+ maxMotorTemp: this.lapMaxMotorTemp
+ };
+ }
+
+ private resetLapAccumulators(): void {
+ this.speedSamples = [];
+ // Carry latest SOC into next lap's start.
+ this.lapSocStart = this.lastSoc;
+ this.lapMaxMotorTemp = null;
+ }
+
+ // Prevent SOC carry-over between sessions.
+ private resetSocBaseline(): void {
+ this.lastSoc = null;
+ this.lapSocStart = null;
+ }
+}
+
+function hydrate(): LapStore {
+ try {
+ const raw = localStorage.getItem(LAP_STORE_STORAGE_KEY);
+ if (!raw) return emptyLapStore();
+ const parsed: unknown = JSON.parse(raw);
+ if (!isLapStore(parsed)) {
+ localStorage.removeItem(LAP_STORE_STORAGE_KEY);
+ return emptyLapStore();
+ }
+ // Coerce dangling activeSessionId to null.
+ if (parsed.activeSessionId !== null && !parsed.sessions.some((s) => s.id === parsed.activeSessionId)) {
+ parsed.activeSessionId = null;
+ }
+ return parsed;
+ } catch {
+ return emptyLapStore();
+ }
+}
+
+function cloneStore(s: LapStore): LapStore {
+ return {
+ schemaVersion: s.schemaVersion,
+ activeSessionId: s.activeSessionId,
+ sessions: s.sessions.map((sess) => ({ ...sess, laps: [...sess.laps] }))
+ };
+}
diff --git a/angular-client/src/utils/lap-timer.types.ts b/angular-client/src/utils/lap-timer.types.ts
new file mode 100644
index 00000000..9fa628e2
--- /dev/null
+++ b/angular-client/src/utils/lap-timer.types.ts
@@ -0,0 +1,131 @@
+export const LAP_STORE_SCHEMA_VERSION = 1;
+export const LAP_STORE_STORAGE_KEY = 'argos_lap_store_v1';
+
+export interface LapStats {
+ avgSpeed: number | null;
+ maxSpeed: number | null;
+ socStart: number | null;
+ socEnd: number | null;
+ energyUsed: number | null;
+ maxMotorTemp: number | null;
+}
+
+export interface Lap {
+ number: number;
+ startEpochMs: number;
+ endEpochMs: number;
+ durationMs: number;
+ runId: number | null;
+ stats: LapStats;
+}
+
+export interface LapSession {
+ id: string;
+ name: string;
+ sessionStartEpochMs: number;
+ runIdAtSessionStart: number | null;
+ laps: Lap[];
+ isRunning: boolean;
+ isPaused: boolean;
+ currentLapStartEpochMs: number | null;
+ currentLapAccumulatedMs: number;
+}
+
+export interface LapStore {
+ schemaVersion: number;
+ activeSessionId: string | null;
+ sessions: LapSession[];
+}
+
+export const emptyLapStore = (): LapStore => ({
+ schemaVersion: LAP_STORE_SCHEMA_VERSION,
+ activeSessionId: null,
+ sessions: []
+});
+
+const pad2 = (n: number) => n.toString().padStart(2, '0');
+
+export const formatMs = (ms: number): string => {
+ if (!isFinite(ms) || ms < 0) ms = 0;
+ const totalSeconds = Math.floor(ms / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const centiseconds = Math.floor((ms % 1000) / 10);
+ const msPart = `${pad2(seconds)}.${pad2(centiseconds)}`;
+ return hours > 0 ? `${hours}:${pad2(minutes)}:${msPart}` : `${pad2(minutes)}:${msPart}`;
+};
+
+export const formatDeltaMs = (deltaMs: number | null): string => {
+ if (deltaMs === null || deltaMs === undefined || isNaN(deltaMs)) return '—';
+ if (deltaMs === 0) return `±${formatMs(0)}`;
+ const sign = deltaMs > 0 ? '+' : '-';
+ return `${sign}${formatMs(Math.abs(deltaMs))}`;
+};
+
+export const defaultSessionName = (startEpochMs: number, runId: number | null): string => {
+ const d = new Date(startEpochMs);
+ const datePart = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ const timePart = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+ const base = `Session — ${datePart} ${timePart}`;
+ return runId !== null ? `${base} — Run ${runId}` : base;
+};
+
+export const slugifySessionName = (name: string): string => {
+ const slug = (name || '')
+ .trim()
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ return slug.slice(0, 40) || 'session';
+};
+
+export const escapeCsvCell = (value: string | number | boolean | null | undefined): string => {
+ if (value === null || value === undefined) return '';
+ const str = String(value);
+ if (str === '') return '';
+ if (/[",\r\n]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+};
+
+export const isLapStore = (value: unknown): value is LapStore => {
+ if (!value || typeof value !== 'object') return false;
+ const v = value as Partial;
+ if (v.schemaVersion !== LAP_STORE_SCHEMA_VERSION) return false;
+ if (!Array.isArray(v.sessions)) return false;
+ if (v.activeSessionId !== null && typeof v.activeSessionId !== 'string') return false;
+ return v.sessions.every(isLapSession);
+};
+
+const isLapSession = (s: unknown): s is LapSession => {
+ if (!s || typeof s !== 'object') return false;
+ const v = s as Partial;
+ return (
+ typeof v.id === 'string' &&
+ typeof v.name === 'string' &&
+ typeof v.sessionStartEpochMs === 'number' &&
+ (v.runIdAtSessionStart === null || typeof v.runIdAtSessionStart === 'number') &&
+ Array.isArray(v.laps) &&
+ typeof v.isRunning === 'boolean' &&
+ typeof v.isPaused === 'boolean' &&
+ (v.currentLapStartEpochMs === null || typeof v.currentLapStartEpochMs === 'number') &&
+ typeof v.currentLapAccumulatedMs === 'number' &&
+ v.laps.every(isLap)
+ );
+};
+
+const isLap = (l: unknown): l is Lap => {
+ if (!l || typeof l !== 'object') return false;
+ const v = l as Partial;
+ return (
+ typeof v.number === 'number' &&
+ typeof v.startEpochMs === 'number' &&
+ typeof v.endEpochMs === 'number' &&
+ typeof v.durationMs === 'number' &&
+ (v.runId === null || typeof v.runId === 'number') &&
+ !!v.stats &&
+ typeof v.stats === 'object'
+ );
+};