diff --git a/angular-client/src/api/lap-timer.api.ts b/angular-client/src/api/lap-timer.api.ts new file mode 100644 index 00000000..0b3c93bc --- /dev/null +++ b/angular-client/src/api/lap-timer.api.ts @@ -0,0 +1,17 @@ +import { urls } from './urls'; + +export const startLap = (): Promise => { + return fetch(urls.startLap(), { method: 'POST' }); +}; + +export const pauseLap = (): Promise => { + return fetch(urls.pauseLap(), { method: 'POST' }); +}; + +export const stopLap = (): Promise => { + return fetch(urls.stopLap(), { method: 'POST' }); +}; + +export const getLaps = (): Promise => { + return fetch(urls.getLaps()); +}; diff --git a/angular-client/src/api/urls.ts b/angular-client/src/api/urls.ts index cdff4943..9798cb4c 100644 --- a/angular-client/src/api/urls.ts +++ b/angular-client/src/api/urls.ts @@ -35,6 +35,12 @@ const updateVideos = () => `${getAllVideos()}/update`; const carCommandConfig = (key: string, values: number[]) => `${baseURL}/config/set/${key}?${values.map((value) => `data=${value}`).join('&')}`; +/* Lap Timer */ +const startLap = () => `${baseURL}/lap-timer/start`; +const pauseLap = () => `${baseURL}/lap-timer/pause`; +const stopLap = () => `${baseURL}/lap-timer/stop`; +const getLaps = () => `${baseURL}/lap-timer/laps`; + /* Rules */ const getRulesByClientId = (clientId: string) => `${baseURL}/rules/${clientId}`; const addRule = () => `${baseURL}/rules/add`; @@ -76,6 +82,11 @@ export const urls = { carCommandConfig, + startLap, + pauseLap, + stopLap, + getLaps, + getRulesByClientId, addRule, deleteRule, 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/half-gauge/half-gauge.component.ts b/angular-client/src/components/half-gauge/half-gauge.component.ts index 1d55d927..a55fb239 100644 --- a/angular-client/src/components/half-gauge/half-gauge.component.ts +++ b/angular-client/src/components/half-gauge/half-gauge.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { ApexNonAxisChartSeries, ApexPlotOptions, ApexChart, ApexFill, NgApexchartsModule } from 'ng-apexcharts'; import { NgStyle } from '@angular/common'; @@ -18,7 +18,7 @@ export type ChartOptions = { standalone: true, imports: [NgStyle, NgApexchartsModule] }) -export default class HalfGaugeComponent implements OnInit { +export default class HalfGaugeComponent implements OnInit, OnChanges { // eslint-disable-next-line @typescript-eslint/no-explicit-any public chartOptions!: Partial | any; @Input() current: number = 50; @@ -30,26 +30,52 @@ export default class HalfGaugeComponent implements OnInit { widthpx: string = '200px'; heightpx: string = '200px'; - paddingTop: string = '20px'; label: string = 'm/s'; percentage: number = 50; fontsize: string = '50px'; ngOnInit() { + this.rebuildChart(); + } + + ngOnChanges(changes: SimpleChanges) { + if (!this.chartOptions) return; // ngOnInit handles the first build + if (changes['size']) { + this.widthpx = this.size + 'px'; + this.heightpx = this.size * 0.5 + 'px'; + this.fontsize = this.size / 10 + 'px'; + } + if (changes['current'] || changes['min'] || changes['max']) { + this.percentage = ((this.current - this.min) / (this.max - this.min)) * 100; + this.label = formatGaugeValue(this.current) + this.unit; + // New object so ng-apexcharts diffs and re-renders. + this.chartOptions = { + ...this.chartOptions, + series: [this.percentage], + labels: [this.label] + }; + } else if (changes['unit']) { + this.label = formatGaugeValue(this.current) + this.unit; + this.chartOptions = { ...this.chartOptions, labels: [this.label] }; + } + if (changes['color']) { + this.chartOptions = { ...this.chartOptions, fill: { ...this.chartOptions.fill, colors: [this.color] } }; + } + } + + private rebuildChart(): void { this.widthpx = this.size + 'px'; this.heightpx = this.size * 0.5 + 'px'; - this.paddingTop = ''; - this.label = this.current + this.unit; + this.label = formatGaugeValue(this.current) + this.unit; this.percentage = ((this.current - this.min) / (this.max - this.min)) * 100; this.fontsize = this.size / 10 + 'px'; - // 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 + // radialBar takes percentages; raw value goes in the label. this.chartOptions = { series: [this.percentage], chart: { type: 'radialBar', - foreColor: '#eeeeee', // text color + foreColor: '#eeeeee', redrawOnParentResize: true, offsetY: -100 }, @@ -65,7 +91,7 @@ export default class HalfGaugeComponent implements OnInit { track: { background: '#1d1d1d', strokeWidth: '97%', - margin: 5, // margin is in pixels + margin: 5, dropShadow: { enabled: false, top: 2, @@ -97,3 +123,7 @@ export default class HalfGaugeComponent implements OnInit { }; } } + +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..a877048a --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css @@ -0,0 +1,31 @@ +.page-grid { + margin: 0 16px; +} + +.gauge-wrap { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + overflow: hidden; +} + +.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/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..0dd642aa --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html @@ -0,0 +1,48 @@ +
+ + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + +
+ {{ liveSoc() | number: '1.0-0' }} + % +
+
+
+ + + +
+ {{ liveMotorTemp() | number: '1.0-0' }} + °C +
+
+
+ + + + +
+
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..846dc678 --- /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 { DecimalPipe } from '@angular/common'; +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 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, + DecimalPipe, + ConfirmDialog, + Toast, + HalfGaugeComponent, + InfoBackgroundComponent, + 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..66915f4f --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.css @@ -0,0 +1,114 @@ +.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..9b6d9ffc --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.css @@ -0,0 +1,51 @@ +.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..761678ba --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.css @@ -0,0 +1,229 @@ +.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 8px; + flex: 0 0 auto; +} +.sessions-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.sessions-count { + font-family: var(--font-family); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.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-y: auto; + overflow-x: hidden; + 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; +} + +.sessions-table-wrap :where(.p-datatable) { + font-family: var(--font-family); + font-size: var(--font-size-sm); + color: var(--color-text-primary); +} +.sessions-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; +} +.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-state-icon { + color: var(--color-battery-high); + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: middle; +} + +.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-family: var(--font-family); + font-size: var(--font-size-sm); + 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-btn mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.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; + font-family: var(--font-family); + font-size: var(--font-size-sm); + text-align: left; +} +.session-menu-item:hover { + background: rgba(255, 255, 255, 0.04); +} +.session-menu-item.destructive { + color: var(--color-battery-low); +} +.session-menu-item mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} 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..e2914e17 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.html @@ -0,0 +1,117 @@ + +
+
+ + @if (timer.sessions().length === 1) { + 1 session + } @else { + {{ timer.sessions().length }} sessions + } + + + @if (timer.sessions().length > 0) { + + } + + +
+ + @if (timer.sessions().length === 0) { +
+ +
+ } @else { +
+ + + + + Name + Started + Laps + Best + Run + + + + + + + @if (activeStateIcon(s); as icon) { + {{ icon }} + } + + + @if (editingSessionId() === s.id) { + + } @else { + {{ s.name }} + } + + {{ s.sessionStartEpochMs | date: 'yyyy-MM-dd HH:mm' }} + {{ s.laps.length }} + + @if (bestLapMsForSession(s.id) !== null) { + {{ formatLapMs(bestLapMsForSession(s.id)!) }} + } @else { + — + } + + {{ s.runIdAtSessionStart ?? '—' }} + + + +
+ + + +
+
+ + +
+
+
+ } +
+
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..fbd8c6e7 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.css @@ -0,0 +1,75 @@ +.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/pages/notification-rules-page/notification-rules-page.component.ts b/angular-client/src/pages/notification-rules-page/notification-rules-page.component.ts index 78845b8a..1852fc33 100644 --- a/angular-client/src/pages/notification-rules-page/notification-rules-page.component.ts +++ b/angular-client/src/pages/notification-rules-page/notification-rules-page.component.ts @@ -21,6 +21,7 @@ import { UploadConfirmDialogComponent } from './upload-confirm-dialog/upload-con import { AddRuleDialogComponent } from './add-rule-dialog/add-rule-dialog.component'; import { RulesTableComponent } from './rules-table/rules-table.component'; import { NotificationListComponent } from 'src/components/notification-list/notification-list.component'; +import { downloadAsFile } from 'src/utils/file-download.utils'; const CLIENT_ID_KEY = 'notification_rules_client_id'; @@ -397,15 +398,8 @@ export default class NotificationRulesPageComponent implements OnInit { ); const csvContent = [header, ...rows].join('\n'); - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = url; - link.download = `notification-rules-${new Date().toISOString().slice(0, 10)}.csv`; - link.click(); - - URL.revokeObjectURL(url); + const filename = `notification-rules-${new Date().toISOString().slice(0, 10)}.csv`; + downloadAsFile(filename, csvContent, 'text/csv;charset=utf-8;'); this.messageService.add({ severity: 'success', summary: 'Downloaded', detail: `Exported ${rules.length} rule(s)` }); } catch { this.messageService.add({ severity: 'error', summary: 'Download Error', detail: 'Failed to download rules' }); 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..42af8355 --- /dev/null +++ b/angular-client/src/services/lap-timer.service.ts @@ -0,0 +1,474 @@ +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-download.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(); + }); + 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(); + + 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.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; + } +} + +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 { + try { + localStorage.removeItem(LAP_STORE_STORAGE_KEY); + } catch { + // localStorage unavailable (e.g. private mode); nothing to recover. + } + 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/file-download.utils.ts b/angular-client/src/utils/file-download.utils.ts new file mode 100644 index 00000000..af945dbc --- /dev/null +++ b/angular-client/src/utils/file-download.utils.ts @@ -0,0 +1,10 @@ +/** Browser download via transient anchor. */ +export function downloadAsFile(filename: string, content: string, mimeType = 'text/plain;charset=utf-8;'): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} 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' + ); +};