From 21452c896bb37ab45fe6cba4b1be05f1878e71ba Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 23 Mar 2026 21:34:14 -0400 Subject: [PATCH 1/7] #547 - add lap timer UI controls page Add iPhone stopwatch-style lap timer with start/pause/stop/lap controls, real-time timer display, and scrollable lap history. Stub backend API endpoints for future integration. --- angular-client/src/api/lap-timer.api.ts | 17 +++ angular-client/src/api/urls.ts | 12 ++ .../app/app-nav-bar/app-nav-bar.component.ts | 6 + angular-client/src/app/app-routing.module.ts | 8 +- .../lap-timer-page.component.css | 131 ++++++++++++++++++ .../lap-timer-page.component.html | 61 ++++++++ .../lap-timer-page.component.ts | 40 ++++++ .../src/services/lap-timer.service.ts | 108 +++++++++++++++ 8 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 angular-client/src/api/lap-timer.api.ts create mode 100644 angular-client/src/pages/lap-timer-page/lap-timer-page.component.css create mode 100644 angular-client/src/pages/lap-timer-page/lap-timer-page.component.html create mode 100644 angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts create mode 100644 angular-client/src/services/lap-timer.service.ts 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 4c827fe9..41284340 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`; + /* Authentication */ const authenticate = () => `${baseURL}/authenticate`; @@ -67,6 +73,12 @@ export const urls = { updateVideos, carCommandConfig, + + startLap, + pauseLap, + stopLap, + getLaps, + authenticate, scyllaSettings, 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 3f6edae5..f5a02a57 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 @@ -189,6 +189,12 @@ export class AppNavBarComponent implements OnInit, OnDestroy { label: 'Commands', onClick: () => this.navigateTo(appRoutes.commandsRoute()), icon: 'electrical_services' + }, + { + 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 6ef5e9f0..6ba938ad 100644 --- a/angular-client/src/app/app-routing.module.ts +++ b/angular-client/src/app/app-routing.module.ts @@ -9,6 +9,7 @@ import EfusesPageComponent from 'src/pages/efuses-page/efuses-page.component'; 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 { Segment } from 'src/utils/bms.utils'; @@ -23,6 +24,7 @@ const faultsRoute = () => `/faults`; const faultsGraphRoute = () => `/faults/fault-graph`; const commandsRoute = () => `/commands`; const efusesRoute = () => `/efuses`; +const lapTimerRoute = () => `/lap-timer`; export const appRoutes = { landingRoute, @@ -35,7 +37,8 @@ export const appRoutes = { faultsRoute, faultsGraphRoute, commandsRoute, - efusesRoute + efusesRoute, + lapTimerRoute }; // Routes should be defined carefully in accordance with the appRoutes @@ -52,7 +55,8 @@ const routes: Routes = [ { path: 'faults/fault-graph', component: GraphPageComponent }, { path: 'camera', component: CameraPageComponent }, { path: 'commands', component: CarCommandComponent }, - { path: 'efuses', component: EfusesPageComponent } + { path: 'efuses', component: EfusesPageComponent }, + { path: 'lap-timer', component: LapTimerPageComponent } ]; @NgModule({ 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..e4ae5c19 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.css @@ -0,0 +1,131 @@ +.page-container { + display: flex; + justify-content: center; + padding: 24px 16px; + height: 100%; +} + +:host { + display: block; + width: 100%; + max-width: 480px; + margin: 0 auto; +} + +.timer-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 24px; +} + +.timer-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.time-value { + font-family: 'Roboto', monospace; + font-size: 3.5rem; + font-weight: bold; + color: #fbf7f5; + text-shadow: 0 0 8px rgba(217, 217, 214, 0.3); + font-variant-numeric: tabular-nums; + letter-spacing: 2px; +} + +.current-lap-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 4px; +} + +.current-lap-time { + font-family: 'Roboto', monospace; + font-size: 1.4rem; + font-weight: bold; + color: #cacaca; + font-variant-numeric: tabular-nums; +} + +.controls { + display: flex; + justify-content: center; + padding: 8px 0; +} + +.control-btn { + border: none; + border-radius: 50px; + padding: 12px 32px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + user-select: none; + min-width: 100px; + transition: background-color 0.2s ease, opacity 0.2s ease; +} + +.control-btn:hover { + opacity: 0.85; +} + +.control-btn:active { + opacity: 0.7; +} + +.start-btn { + background-color: #1db954; + color: #fff; +} + +.pause-btn { + background-color: #f0a030; + color: #fff; +} + +.stop-btn { + background-color: #f04346; + color: #fff; +} + +.lap-btn { + background-color: #3a3a3a; + color: #efefef; + border: 1px solid #555; +} + +.reset-btn { + background-color: #3a3a3a; + color: #efefef; + border: 1px solid #555; +} + +.lap-list-container { + width: 100%; + max-width: 360px; +} + +.lap-list-header { + padding: 8px 4px; + border-bottom: 1px solid #444; +} + +.lap-list { + max-height: 280px; + overflow-y: auto; +} + +.lap-row { + padding: 8px 4px; + border-bottom: 1px solid #333; +} + +.reset-container { + padding-top: 4px; +} 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..b8ed9057 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html @@ -0,0 +1,61 @@ +
+ +
+ +
+ +
{{ timer.formattedTotal() }}
+
+ +
{{ timer.formattedCurrentLap() }}
+
+
+ + +
+ @if (timer.isIdle()) { + + } @else if (timer.isRunning()) { + + + + + } @else if (timer.isPaused()) { + + + + + } +
+ + + @if (timer.laps().length > 0 || timer.isRunning() || timer.isPaused()) { +
+
+ + + + +
+
+ @for (lap of timer.laps().slice().reverse(); track lap.number) { +
+ + + + +
+ } +
+
+ } + + + @if (timer.isIdle() && timer.laps().length > 0) { +
+ +
+ } +
+
+
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..628a35d1 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.ts @@ -0,0 +1,40 @@ +import { Component, inject } from '@angular/core'; +import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; +import TypographyComponent from 'src/components/typography/typography.component'; +import HStackComponent from 'src/components/hstack/hstack.component'; +import LapTimerService from 'src/services/lap-timer.service'; + +@Component({ + selector: 'lap-timer-page', + templateUrl: './lap-timer-page.component.html', + styleUrl: './lap-timer-page.component.css', + standalone: true, + imports: [InfoBackgroundComponent, TypographyComponent, HStackComponent] +}) +export default class LapTimerPageComponent { + readonly timer = inject(LapTimerService); + + onStartResume(): void { + if (this.timer.isIdle()) { + this.timer.start(); + } else if (this.timer.isPaused()) { + this.timer.resume(); + } + } + + onPause(): void { + this.timer.pause(); + } + + onLap(): void { + this.timer.lap(); + } + + onStop(): void { + this.timer.stop(); + } + + onReset(): void { + this.timer.reset(); + } +} 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..17091f8c --- /dev/null +++ b/angular-client/src/services/lap-timer.service.ts @@ -0,0 +1,108 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { startLap as apiStartLap, pauseLap as apiPauseLap, stopLap as apiStopLap } from 'src/api/lap-timer.api'; + +export type LapState = 'idle' | 'running' | 'paused'; + +export interface LapRecord { + number: number; + duration: number; +} + +@Injectable({ providedIn: 'root' }) +export default class LapTimerService { + readonly state = signal('idle'); + readonly currentLapTime = signal(0); + readonly totalTime = signal(0); + readonly laps = signal([]); + + private intervalId: ReturnType | null = null; + private lastTickTime = 0; + + readonly isRunning = computed(() => this.state() === 'running'); + readonly isPaused = computed(() => this.state() === 'paused'); + readonly isIdle = computed(() => this.state() === 'idle'); + readonly lapCount = computed(() => this.laps().length); + + readonly formattedCurrentLap = computed(() => this.formatTime(this.currentLapTime())); + readonly formattedTotal = computed(() => this.formatTime(this.totalTime())); + + start(): void { + if (this.state() === 'idle') { + // Fresh start + this.laps.set([]); + this.currentLapTime.set(0); + this.totalTime.set(0); + } + this.state.set('running'); + this.startTicking(); + apiStartLap().catch(() => {}); + } + + pause(): void { + if (this.state() !== 'running') return; + this.state.set('paused'); + this.stopTicking(); + apiPauseLap().catch(() => {}); + } + + resume(): void { + if (this.state() !== 'paused') return; + this.state.set('running'); + this.startTicking(); + apiStartLap().catch(() => {}); + } + + lap(): void { + if (this.state() !== 'running') return; + const lapDuration = this.currentLapTime(); + if (lapDuration === 0) return; + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: lapDuration }]); + this.currentLapTime.set(0); + } + + stop(): void { + // Record final lap if there's time on it + const remaining = this.currentLapTime(); + if (remaining > 0) { + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: remaining }]); + } + this.stopTicking(); + this.state.set('idle'); + this.currentLapTime.set(0); + apiStopLap().catch(() => {}); + } + + reset(): void { + this.stopTicking(); + this.state.set('idle'); + this.currentLapTime.set(0); + this.totalTime.set(0); + this.laps.set([]); + } + + formatTime(timeMs: number): string { + const totalSeconds = Math.floor(timeMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((timeMs % 1000) / 10); + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + } + + private startTicking(): void { + this.lastTickTime = performance.now(); + this.intervalId = setInterval(() => { + const now = performance.now(); + const delta = now - this.lastTickTime; + this.lastTickTime = now; + this.currentLapTime.update((t) => t + delta); + this.totalTime.update((t) => t + delta); + }, 10); + } + + private stopTicking(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} From 5ba5ebe746ae4d207e023aebea99dfb7e05ba22b Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 14:20:44 -0400 Subject: [PATCH 2/7] #547 migrate DataTypeEnum to topic.utils --- .../lap-timer-page.component.ts | 65 +++++-- .../src/services/lap-timer.service.ts | 171 ++++++++++++++++-- 2 files changed, 207 insertions(+), 29 deletions(-) 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 index 628a35d1..86184d66 100644 --- 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 @@ -1,40 +1,69 @@ -import { Component, inject } from '@angular/core'; +import { Component, 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 { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; import TypographyComponent from 'src/components/typography/typography.component'; import HStackComponent from 'src/components/hstack/hstack.component'; +import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component'; import LapTimerService from 'src/services/lap-timer.service'; +import Storage from 'src/services/storage.service'; +import { topics } from 'src/utils/topic.utils'; @Component({ selector: 'lap-timer-page', templateUrl: './lap-timer-page.component.html', styleUrl: './lap-timer-page.component.css', standalone: true, - imports: [InfoBackgroundComponent, TypographyComponent, HStackComponent] + imports: [ + MatGridList, MatGridTile, + InfoBackgroundComponent, TypographyComponent, + HStackComponent, HalfGaugeComponent, DecimalPipe + ] }) -export default class LapTimerPageComponent { +export default class LapTimerPageComponent implements OnInit, OnDestroy { readonly timer = inject(LapTimerService); + private storage = inject(Storage); - onStartResume(): void { - if (this.timer.isIdle()) { - this.timer.start(); - } else if (this.timer.isPaused()) { - this.timer.resume(); - } - } + readonly expandedLap = signal(null); + readonly liveSpeed = signal(0); + readonly liveMotorTemp = signal(0); + readonly liveSoc = signal(0); + + private subs: Subscription[] = []; - onPause(): void { - this.timer.pause(); + ngOnInit(): void { + 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); + }) + ); } - onLap(): void { - this.timer.lap(); + ngOnDestroy(): void { + this.subs.forEach((s) => s.unsubscribe()); } - onStop(): void { - this.timer.stop(); + toggleLapDetail(lapNumber: number): void { + this.expandedLap.update((current) => (current === lapNumber ? null : lapNumber)); } - onReset(): void { - this.timer.reset(); + onStartResume(): void { + if (this.timer.isIdle()) { + this.timer.start(); + } else if (this.timer.isPaused()) { + this.timer.resume(); + } } + + onPause(): void { this.timer.pause(); } + onLap(): void { this.timer.lap(); } + onStop(): void { this.timer.stop(); } + onReset(): void { this.timer.reset(); } } diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts index 17091f8c..dc49b48a 100644 --- a/angular-client/src/services/lap-timer.service.ts +++ b/angular-client/src/services/lap-timer.service.ts @@ -1,23 +1,54 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { Subscription } from 'rxjs'; import { startLap as apiStartLap, pauseLap as apiPauseLap, stopLap as apiStopLap } from 'src/api/lap-timer.api'; +import { topics } from 'src/utils/topic.utils'; +import Storage from './storage.service'; export type LapState = 'idle' | 'running' | 'paused'; +export interface LapStats { + avgSpeed: number | null; + maxSpeed: number | null; + socStart: number | null; + socEnd: number | null; + energyUsed: number | null; // SOC delta as percentage points + maxMotorTemp: number | null; +} + export interface LapRecord { number: number; duration: number; + stats: LapStats; } +const EMPTY_STATS: LapStats = { + avgSpeed: null, + maxSpeed: null, + socStart: null, + socEnd: null, + energyUsed: null, + maxMotorTemp: null, +}; + @Injectable({ providedIn: 'root' }) export default class LapTimerService { + private storage = inject(Storage); + readonly state = signal('idle'); readonly currentLapTime = signal(0); readonly totalTime = signal(0); readonly laps = signal([]); - private intervalId: ReturnType | null = null; + private rafId: number | null = null; private lastTickTime = 0; + // Telemetry accumulators for current lap + private speedSamples: number[] = []; + private lastSoc: number | null = null; + private lapSocStart: number | null = null; + private lapMaxMotorTemp: number | null = null; + private telemetrySubs: Subscription[] = []; + readonly isRunning = computed(() => this.state() === 'running'); readonly isPaused = computed(() => this.state() === 'paused'); readonly isIdle = computed(() => this.state() === 'idle'); @@ -26,15 +57,56 @@ export default class LapTimerService { readonly formattedCurrentLap = computed(() => this.formatTime(this.currentLapTime())); readonly formattedTotal = computed(() => this.formatTime(this.totalTime())); + readonly bestLap = computed(() => { + const laps = this.laps(); + if (laps.length === 0) return null; + return laps.reduce((best, lap) => (lap.duration < best.duration ? lap : best)); + }); + + readonly worstLap = computed(() => { + const laps = this.laps(); + if (laps.length < 2) return null; + return laps.reduce((worst, lap) => (lap.duration > worst.duration ? lap : worst)); + }); + + readonly averageLapTime = computed(() => { + const laps = this.laps(); + if (laps.length === 0) return 0; + return laps.reduce((sum, lap) => sum + lap.duration, 0) / laps.length; + }); + + // Session-level computed stats + readonly totalEnergyUsed = computed(() => { + const laps = this.laps(); + return laps.reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0); + }); + + readonly bestLapSpeed = computed(() => { + const laps = this.laps(); + const speeds = laps.map((l) => l.stats.avgSpeed).filter((s): s is number => s !== null); + return speeds.length > 0 ? Math.max(...speeds) : null; + }); + + deltaFromBest(lapDuration: number): number | null { + const best = this.bestLap(); + if (!best) return null; + return lapDuration - best.duration; + } + + formatDelta(deltaMs: number): string { + const sign = deltaMs >= 0 ? '+' : '-'; + return `${sign}${this.formatTime(Math.abs(deltaMs))}`; + } + start(): void { if (this.state() === 'idle') { - // Fresh start this.laps.set([]); this.currentLapTime.set(0); this.totalTime.set(0); } this.state.set('running'); this.startTicking(); + this.subscribeTelemetry(); apiStartLap().catch(() => {}); } @@ -56,17 +128,20 @@ export default class LapTimerService { if (this.state() !== 'running') return; const lapDuration = this.currentLapTime(); if (lapDuration === 0) return; - this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: lapDuration }]); + const stats = this.snapshotStats(); + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: lapDuration, stats }]); this.currentLapTime.set(0); + this.resetLapAccumulators(); } stop(): void { - // Record final lap if there's time on it const remaining = this.currentLapTime(); if (remaining > 0) { - this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: remaining }]); + const stats = this.snapshotStats(); + this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: remaining, stats }]); } this.stopTicking(); + this.unsubscribeTelemetry(); this.state.set('idle'); this.currentLapTime.set(0); apiStopLap().catch(() => {}); @@ -74,6 +149,7 @@ export default class LapTimerService { reset(): void { this.stopTicking(); + this.unsubscribeTelemetry(); this.state.set('idle'); this.currentLapTime.set(0); this.totalTime.set(0); @@ -88,21 +164,94 @@ export default class LapTimerService { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; } + // --- Telemetry tracking --- + + private subscribeTelemetry(): void { + this.resetLapAccumulators(); + + this.telemetrySubs.push( + this.storage.get(topics.speed()).subscribe((value) => { + if (this.state() !== 'running') 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.state() !== 'running') 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.state() !== 'running') 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 avgSpeed = this.speedSamples.length > 0 + ? this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length + : null; + const maxSpeed = this.speedSamples.length > 0 + ? Math.max(...this.speedSamples) + : 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 = []; + this.lapSocStart = this.lastSoc; // carry over current SOC as next lap's start + this.lapMaxMotorTemp = null; + } + + // --- Timer internals --- + private startTicking(): void { this.lastTickTime = performance.now(); - this.intervalId = setInterval(() => { + const tick = () => { const now = performance.now(); const delta = now - this.lastTickTime; this.lastTickTime = now; this.currentLapTime.update((t) => t + delta); this.totalTime.update((t) => t + delta); - }, 10); + this.rafId = requestAnimationFrame(tick); + }; + this.rafId = requestAnimationFrame(tick); } private stopTicking(): void { - if (this.intervalId !== null) { - clearInterval(this.intervalId); - this.intervalId = null; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; } } } From 06d42107d297cb9411ffb701b15501dd26ecf89f Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 14:21:54 -0400 Subject: [PATCH 3/7] #547 remove unused code and dead declarations --- .../lap-timer-page/lap-timer-page.component.ts | 5 ----- angular-client/src/services/lap-timer.service.ts | 15 --------------- 2 files changed, 20 deletions(-) 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 index 86184d66..0fb681ff 100644 --- 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 @@ -25,7 +25,6 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { readonly timer = inject(LapTimerService); private storage = inject(Storage); - readonly expandedLap = signal(null); readonly liveSpeed = signal(0); readonly liveMotorTemp = signal(0); readonly liveSoc = signal(0); @@ -50,10 +49,6 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { this.subs.forEach((s) => s.unsubscribe()); } - toggleLapDetail(lapNumber: number): void { - this.expandedLap.update((current) => (current === lapNumber ? null : lapNumber)); - } - onStartResume(): void { if (this.timer.isIdle()) { this.timer.start(); diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts index dc49b48a..a809eb69 100644 --- a/angular-client/src/services/lap-timer.service.ts +++ b/angular-client/src/services/lap-timer.service.ts @@ -21,15 +21,6 @@ export interface LapRecord { stats: LapStats; } -const EMPTY_STATS: LapStats = { - avgSpeed: null, - maxSpeed: null, - socStart: null, - socEnd: null, - energyUsed: null, - maxMotorTemp: null, -}; - @Injectable({ providedIn: 'root' }) export default class LapTimerService { private storage = inject(Storage); @@ -81,12 +72,6 @@ export default class LapTimerService { return laps.reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0); }); - readonly bestLapSpeed = computed(() => { - const laps = this.laps(); - const speeds = laps.map((l) => l.stats.avgSpeed).filter((s): s is number => s !== null); - return speeds.length > 0 ? Math.max(...speeds) : null; - }); - deltaFromBest(lapDuration: number): number | null { const best = this.bestLap(); if (!best) return null; From 8593aa1c8c972cc10e8b0814d561f8581bd65be4 Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 14:22:34 -0400 Subject: [PATCH 4/7] #547 apply prettier formatting --- .../lap-timer-page.component.css | 192 ++++++++------- .../lap-timer-page.component.html | 228 ++++++++++++++---- .../lap-timer-page.component.ts | 26 +- .../src/services/lap-timer.service.ts | 15 +- 4 files changed, 310 insertions(+), 151 deletions(-) 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 index e4ae5c19..0c288f99 100644 --- 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 @@ -1,131 +1,161 @@ -.page-container { - display: flex; - justify-content: center; - padding: 24px 16px; - height: 100%; +.page-grid { + margin: 0 16px; } - -:host { - display: block; - width: 100%; - max-width: 480px; - margin: 0 auto; -} - .timer-content { display: flex; flex-direction: column; align-items: center; - padding: 20px 0; - gap: 24px; + gap: 12px; + padding: 8px 0; } - -.timer-display { +.timer-display, +.current-lap-row { display: flex; flex-direction: column; align-items: center; - gap: 8px; } - -.time-value { +.timer-display { + gap: 4px; +} +.current-lap-row { + gap: 2px; +} +.time-value, +.current-lap-time, +.avg-lap-time, +.stat-value, +.lap-delta { font-family: 'Roboto', monospace; - font-size: 3.5rem; - font-weight: bold; - color: #fbf7f5; - text-shadow: 0 0 8px rgba(217, 217, 214, 0.3); font-variant-numeric: tabular-nums; - letter-spacing: 2px; } - -.current-lap-row { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - margin-top: 4px; +.time-value { + font-size: 2.8rem; + font-weight: bold; + color: #fbf7f5; } - .current-lap-time { - font-family: 'Roboto', monospace; - font-size: 1.4rem; + font-size: 1.1rem; font-weight: bold; color: #cacaca; - font-variant-numeric: tabular-nums; } - .controls { display: flex; justify-content: center; - padding: 8px 0; } - .control-btn { border: none; border-radius: 50px; - padding: 12px 32px; - font-size: 16px; + padding: 10px 28px; + font-size: 15px; font-weight: bold; cursor: pointer; - user-select: none; - min-width: 100px; - transition: background-color 0.2s ease, opacity 0.2s ease; -} - -.control-btn:hover { - opacity: 0.85; + min-width: 90px; } - -.control-btn:active { - opacity: 0.7; +.start-btn, +.pause-btn, +.stop-btn { + color: #fff; } - .start-btn { - background-color: #1db954; - color: #fff; + background: #1db954; } - .pause-btn { - background-color: #f0a030; - color: #fff; + background: #f0a030; } - .stop-btn { - background-color: #f04346; - color: #fff; + background: #f04346; } - -.lap-btn { - background-color: #3a3a3a; - color: #efefef; - border: 1px solid #555; -} - +.lap-btn, .reset-btn { - background-color: #3a3a3a; + background: #3a3a3a; color: #efefef; border: 1px solid #555; } - -.lap-list-container { +.session-stats, +.lap-list-full { + display: flex; + flex-direction: column; +} +.session-stats { + gap: 8px; + padding: 4px 8px; +} +.live-big { + font-family: 'Roboto', monospace; + font-size: 2rem; + font-weight: bold; + color: #fbf7f5; + font-variant-numeric: tabular-nums; +} +.live-unit { + font-size: 0.85rem; + color: #999; + margin-left: 4px; +} +.lap-list-full { width: 100%; - max-width: 360px; } - .lap-list-header { - padding: 8px 4px; + padding: 6px 8px; border-bottom: 1px solid #444; } - .lap-list { - max-height: 280px; + flex: 1; overflow-y: auto; } - -.lap-row { - padding: 8px 4px; +.lap-entry { border-bottom: 1px solid #333; } - -.reset-container { - padding-top: 4px; +.lap-row { + padding: 8px; +} +.best-lap { + border-left: 3px solid #1db954; + padding-left: 8px; +} +.worst-lap { + border-left: 3px solid #f04346; + padding-left: 8px; +} +.stat-row { + display: flex; + justify-content: space-between; +} +.stat-label, +.stat-value { + font-size: 13px; +} +.stat-label { + color: #888; +} +.stat-value { + color: #ccc; +} +.best-text { + color: #1db954; +} +.lap-time-col { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} +.lap-delta { + font-size: 11px; +} +.delta-positive { + color: #f04346; +} +.delta-negative { + color: #1db954; +} +.avg-lap { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; +} +.avg-lap-time { + font-size: 1rem; + color: #999; } 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 index b8ed9057..7dddc333 100644 --- 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 @@ -1,61 +1,183 @@ -
- -
- -
- -
{{ timer.formattedTotal() }}
-
- -
{{ timer.formattedCurrentLap() }}
+
+ + + + +
+
+ +
{{ timer.formattedTotal() }}
+
+ +
{{ timer.formattedCurrentLap() }}
+
+
+
+ @if (timer.isIdle()) { + + } @else if (timer.isRunning()) { + + + + + } @else if (timer.isPaused()) { + + + + + } +
+ @if (timer.isIdle() && timer.laps().length > 0) { + + }
-
+ + - -
- @if (timer.isIdle()) { - - } @else if (timer.isRunning()) { - - - - - } @else if (timer.isPaused()) { - - - - - } -
- - - @if (timer.laps().length > 0 || timer.isRunning() || timer.isPaused()) { -
-
- - - - + + + +
+
+ {{ timer.lapCount() }}
-
- @for (lap of timer.laps().slice().reverse(); track lap.number) { -
- - - - -
- } +
Laps
+
+ Best{{ + timer.bestLap() ? timer.formatTime(timer.bestLap()!.duration) : '—' + }} +
+
+ Avg{{ timer.lapCount() > 0 ? timer.formatTime(timer.averageLapTime()) : '—' }} +
+
+ Energy{{ timer.totalEnergyUsed() | number: '1.1-1' }}%
- } + + - - @if (timer.isIdle() && timer.laps().length > 0) { -
- + + + + + + + + + + +
+ {{ liveSoc() | number: '1.0-0' }} + % +
+
+
+ + + +
+ {{ liveMotorTemp() | number: '1.0-0' }}° + C +
+
+
+ + + + +
+ @if (timer.laps().length > 0 || timer.isRunning() || timer.isPaused()) { +
+ + + + + + + + + +
+
+ @for (lap of timer.laps().slice().reverse(); track lap.number) { +
+
+ + + +
+ + @if (timer.deltaFromBest(lap.duration); as delta) { + @if (delta !== 0) { + {{ timer.formatDelta(delta) }} + } + } +
+ {{ + lap.stats.avgSpeed !== null ? (lap.stats.avgSpeed | number: '1.0-0') + ' mph' : '—' + }} + {{ + lap.stats.energyUsed !== null ? (lap.stats.energyUsed | number: '1.2-2') + '%' : '—' + }} + {{ + lap.stats.maxMotorTemp !== null ? (lap.stats.maxMotorTemp | number: '1.0-0') + '°C' : '—' + }} +
+
+
+
+ } +
+ @if (timer.laps().length > 1) { +
+ + {{ timer.formatTime(timer.averageLapTime()) }} +
+ } + } @else { +
+ +
+ }
- } -
- + + +
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 index 0fb681ff..e25925e8 100644 --- 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 @@ -16,9 +16,13 @@ import { topics } from 'src/utils/topic.utils'; styleUrl: './lap-timer-page.component.css', standalone: true, imports: [ - MatGridList, MatGridTile, - InfoBackgroundComponent, TypographyComponent, - HStackComponent, HalfGaugeComponent, DecimalPipe + MatGridList, + MatGridTile, + InfoBackgroundComponent, + TypographyComponent, + HStackComponent, + HalfGaugeComponent, + DecimalPipe ] }) export default class LapTimerPageComponent implements OnInit, OnDestroy { @@ -57,8 +61,16 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { } } - onPause(): void { this.timer.pause(); } - onLap(): void { this.timer.lap(); } - onStop(): void { this.timer.stop(); } - onReset(): void { this.timer.reset(); } + onPause(): void { + this.timer.pause(); + } + onLap(): void { + this.timer.lap(); + } + onStop(): void { + this.timer.stop(); + } + onReset(): void { + this.timer.reset(); + } } diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts index a809eb69..cade61e6 100644 --- a/angular-client/src/services/lap-timer.service.ts +++ b/angular-client/src/services/lap-timer.service.ts @@ -192,15 +192,10 @@ export default class LapTimerService { } private snapshotStats(): LapStats { - const avgSpeed = this.speedSamples.length > 0 - ? this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length - : null; - const maxSpeed = this.speedSamples.length > 0 - ? Math.max(...this.speedSamples) - : null; - const energyUsed = this.lapSocStart !== null && this.lastSoc !== null - ? this.lapSocStart - this.lastSoc - : null; + const avgSpeed = + this.speedSamples.length > 0 ? this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length : null; + const maxSpeed = this.speedSamples.length > 0 ? Math.max(...this.speedSamples) : null; + const energyUsed = this.lapSocStart !== null && this.lastSoc !== null ? this.lapSocStart - this.lastSoc : null; return { avgSpeed, @@ -208,7 +203,7 @@ export default class LapTimerService { socStart: this.lapSocStart, socEnd: this.lastSoc, energyUsed, - maxMotorTemp: this.lapMaxMotorTemp, + maxMotorTemp: this.lapMaxMotorTemp }; } From 6f565a3f139c8952beead4bd7055274138308813 Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 14:32:56 -0400 Subject: [PATCH 5/7] #547 add OnPush change detection and remove explicit standalone --- .../src/pages/lap-timer-page/lap-timer-page.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index e25925e8..cc87ad83 100644 --- 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 @@ -1,4 +1,4 @@ -import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { Subscription } from 'rxjs'; import { MatGridList, MatGridTile } from '@angular/material/grid-list'; @@ -14,7 +14,7 @@ import { topics } from 'src/utils/topic.utils'; selector: 'lap-timer-page', templateUrl: './lap-timer-page.component.html', styleUrl: './lap-timer-page.component.css', - standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatGridList, MatGridTile, From 53296ab5d13dee3cda1a1b47c9b92ae34b08356d Mon Sep 17 00:00:00 2001 From: Jeffrey <54014102+TheJeffreyKuo@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:30 -0400 Subject: [PATCH 6/7] lap timer ui cleanup and local persistence --- angular-client/angular.json | 1 + .../half-gauge/half-gauge.component.ts | 48 +- .../graph-page/graph-page.component.spec.ts | 4 +- .../lap-timer-page.component.css | 526 ++++++++++++++---- .../lap-timer-page.component.html | 425 +++++++++----- .../lap-timer-page.component.spec.ts | 53 ++ .../lap-timer-page.component.ts | 244 +++++++- .../notification-rules-page.component.ts | 12 +- .../src/services/lap-timer.service.spec.ts | 434 +++++++++++++++ .../src/services/lap-timer.service.ts | 483 ++++++++++++---- angular-client/src/utils/bms.utils.ts | 47 +- .../src/utils/file-download.utils.ts | 10 + angular-client/src/utils/lap-timer.types.ts | 131 +++++ angular-client/tsconfig.spec.json | 2 +- 14 files changed, 1990 insertions(+), 430 deletions(-) create mode 100644 angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts create mode 100644 angular-client/src/services/lap-timer.service.spec.ts create mode 100644 angular-client/src/utils/file-download.utils.ts create mode 100644 angular-client/src/utils/lap-timer.types.ts diff --git a/angular-client/angular.json b/angular-client/angular.json index 071f69f5..965f1442 100644 --- a/angular-client/angular.json +++ b/angular-client/angular.json @@ -81,6 +81,7 @@ "builder": "@angular-devkit/build-angular:karma", "options": { "tsConfig": "tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"], "assets": ["src/favicon.ico", "src/assets"], "styles": ["@angular/material/prebuilt-themes/rose-red.css", "src/styles.css"], "scripts": [] 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/graph-page/graph-page.component.spec.ts b/angular-client/src/pages/graph-page/graph-page.component.spec.ts index 0ecc2cba..9b223817 100644 --- a/angular-client/src/pages/graph-page/graph-page.component.spec.ts +++ b/angular-client/src/pages/graph-page/graph-page.component.spec.ts @@ -32,7 +32,7 @@ describe('GraphPageComponent — URL/topic-selection sync', () => { const apiServiceMock: Pick = { query: () => { queryCalls += 1; - const response: QueryResponse = + const response = queryCalls === 1 ? { isLoading: new BehaviorSubject(true), @@ -46,7 +46,7 @@ describe('GraphPageComponent — URL/topic-selection sync', () => { isError: new BehaviorSubject(false), error: new BehaviorSubject(null) }; - return response as QueryResponse; + return response as unknown as QueryResponse; } }; 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 index 0c288f99..1523dc5e 100644 --- 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 @@ -1,161 +1,451 @@ .page-grid { margin: 0 16px; } -.timer-content { + +.monospace { + font-family: var(--font-family); + font-variant-numeric: tabular-nums; +} + +/* ---------- Sessions panel ---------- */ + +.sessions-panel { display: flex; flex-direction: column; - align-items: center; - gap: 12px; - padding: 8px 0; + width: 100%; + flex: 1 1 auto; + min-height: 0; } -.timer-display, -.current-lap-row { + +.sessions-toolbar { display: flex; - flex-direction: column; align-items: center; + justify-content: space-between; + padding: 4px 8px 8px 8px; + flex: 0 0 auto; } -.timer-display { - gap: 4px; -} -.current-lap-row { - gap: 2px; -} -.time-value, -.current-lap-time, -.avg-lap-time, -.stat-value, -.lap-delta { - font-family: 'Roboto', monospace; - font-variant-numeric: tabular-nums; -} -.time-value { - font-size: 2.8rem; - font-weight: bold; - color: #fbf7f5; +.sessions-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 8px; } -.current-lap-time { - font-size: 1.1rem; - font-weight: bold; - color: #cacaca; + +.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; } -.controls { + +.sessions-empty { + flex: 1; display: flex; + align-items: center; justify-content: center; + padding: 16px; } -.control-btn { - border: none; - border-radius: 50px; - padding: 10px 28px; - font-size: 15px; - font-weight: bold; + +.sessions-table-wrap, +.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; +} +.sessions-table-wrap::-webkit-scrollbar, +.lap-table-wrap::-webkit-scrollbar { + display: block; + width: 6px; +} +.sessions-table-wrap::-webkit-scrollbar-thumb, +.lap-table-wrap::-webkit-scrollbar-thumb { + background: var(--color-divider); + border-radius: 3px; +} +.sessions-table-wrap::-webkit-scrollbar-track, +.lap-table-wrap::-webkit-scrollbar-track { + background: transparent; +} + +.sessions-table-wrap :where(.p-datatable), +.lap-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), +.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; +} +.sessions-table-wrap :where(td), +.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, +.sessions-table-wrap :where(.session-row):hover td { + background: rgba(255, 255, 255, 0.03); +} +.sessions-table-wrap :where(td) { + padding-top: 6px; + padding-bottom: 6px; +} + +.session-row { cursor: pointer; - min-width: 90px; -} -.start-btn, -.pause-btn, -.stop-btn { - color: #fff; -} -.start-btn { - background: #1db954; } -.pause-btn { - background: #f0a030; +.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; } -.stop-btn { - background: #f04346; +.session-menu-btn:hover { + color: var(--color-text-primary); + background: rgba(255, 255, 255, 0.06); } -.lap-btn, -.reset-btn { - background: #3a3a3a; - color: #efefef; - border: 1px solid #555; +.session-menu-btn mat-icon { + font-size: 18px; + width: 18px; + height: 18px; } -.session-stats, -.lap-list-full { + +.session-menu-list { display: flex; flex-direction: column; + min-width: 160px; } -.session-stats { +.session-menu-item { + display: flex; + align-items: center; gap: 8px; - padding: 4px 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; +} + +/* ---------- Timer hero ---------- */ + +.timer-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 8px 0; + width: 100%; } -.live-big { - font-family: 'Roboto', monospace; - font-size: 2rem; - font-weight: bold; - color: #fbf7f5; + +.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%; } -.live-unit { - font-size: 0.85rem; - color: #999; - margin-left: 4px; + +.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; } -.lap-list-full { + +/* ---------- Session summary ---------- */ + +.session-summary { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 8px; width: 100%; } -.lap-list-header { - padding: 6px 8px; - border-bottom: 1px solid #444; -} -.lap-list { - flex: 1; - overflow-y: auto; -} -.lap-entry { - border-bottom: 1px solid #333; -} -.lap-row { - padding: 8px; -} -.best-lap { - border-left: 3px solid #1db954; - padding-left: 8px; -} -.worst-lap { - border-left: 3px solid #f04346; - padding-left: 8px; -} + +.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; -} -.stat-label, -.stat-value { - font-size: 13px; + align-items: baseline; } .stat-label { - color: #888; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); } .stat-value { - color: #ccc; -} -.best-text { - color: #1db954; -} -.lap-time-col { + 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); } + +/* ---------- Gauges ---------- */ + +.gauge-wrap { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 1px; -} -.lap-delta { - font-size: 11px; -} -.delta-positive { - color: #f04346; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + overflow: hidden; } -.delta-negative { - color: #1db954; + +.gauge-stat { + display: flex; + align-items: baseline; + justify-content: center; + gap: 4px; + padding: 8px 0; } -.avg-lap { +.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); +} + +/* ---------- Lap table ---------- */ + +.empty-laps { + flex: 1; display: flex; align-items: center; - gap: 6px; - padding: 6px 8px; + justify-content: center; + padding: 32px; +} + +.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); +} + +/* Same !important reason as sessions table above. */ +.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; +} + +.best-flag { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-battery-high); + font-weight: 600; } -.avg-lap-time { - font-size: 1rem; - color: #999; +.best-flag mat-icon { + font-size: 16px; + width: 16px; + height: 16px; } 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 index 7dddc333..4ecabdd7 100644 --- 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 @@ -1,180 +1,341 @@
+ + + - - - -
-
- -
{{ timer.formattedTotal() }}
-
+ + +
+
+ + @if (timer.sessions().length === 1) { + 1 session + } @else { + {{ timer.sessions().length }} sessions + } + + + @if (timer.sessions().length > 0) { + + } + + +
+ + @if (timer.sessions().length === 0) { +
-
{{ timer.formattedCurrentLap() }}
+ } @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 ?? '—' }} + + + +
+ + + +
+
+ + +
+
+
+ } +
+
+
+ + + +
+
TOTAL
+
{{ timer.formattedTotal() }}
+
+
+
LAP {{ timer.lapCount() + 1 }}
+
{{ timer.formattedCurrentLap() }}
+
{{ currentLapDeltaText() }}
-
- @if (timer.isIdle()) { - +
+ @if (!timer.activeSession()) { + + } @else if (timer.isIdle()) { + + } @else if (timer.isRunning()) { - - - - + + + } @else if (timer.isPaused()) { - - - - + + + + }
- @if (timer.isIdle() && timer.laps().length > 0) { - - }
- - + -
-
- {{ timer.lapCount() }} +
+
{{ timer.lapCount() }}
+
LAPS
+
+
+ Best + + @if (timer.bestLap(); as best) { + {{ formatLapMs(best.durationMs) }} + } @else { + — + } +
-
Laps
- Best{{ - timer.bestLap() ? timer.formatTime(timer.bestLap()!.duration) : '—' - }} + Avg + + @if (timer.lapCount() > 0) { + {{ formatLapMs(timer.averageLapTime()) }} + } @else { + — + } +
- Avg{{ timer.lapCount() > 0 ? timer.formatTime(timer.averageLapTime()) : '—' }} + Energy + {{ timer.totalEnergyUsed() | number: '1.2-2' }}%
- Energy{{ timer.totalEnergyUsed() | number: '1.1-1' }}% + Run + {{ timer.activeSession()?.runIdAtSessionStart ?? '—' }}
- - +
+ +
- - + -
- {{ liveSoc() | number: '1.0-0' }} - % +
+ {{ liveSoc() | number: '1.0-0' }} + %
- + -
- {{ liveMotorTemp() | number: '1.0-0' }}° - C +
+ {{ liveMotorTemp() | number: '1.0-0' }} + °C
- - - -
- @if (timer.laps().length > 0 || timer.isRunning() || timer.isPaused()) { -
- - - - - - - - - -
-
- @for (lap of timer.laps().slice().reverse(); track lap.number) { -
-
- - - -
- - @if (timer.deltaFromBest(lap.duration); as delta) { - @if (delta !== 0) { - {{ timer.formatDelta(delta) }} - } - } -
- {{ - lap.stats.avgSpeed !== null ? (lap.stats.avgSpeed | number: '1.0-0') + ' mph' : '—' - }} - {{ - lap.stats.energyUsed !== null ? (lap.stats.energyUsed | number: '1.2-2') + '%' : '—' - }} - {{ - lap.stats.maxMotorTemp !== null ? (lap.stats.maxMotorTemp | number: '1.0-0') + '°C' : '—' - }} -
-
-
-
+ + +
+ @if (timer.laps().length === 0) { +
+ @if (timer.activeSession()) { + + } @else { + }
- @if (timer.laps().length > 1) { -
- - {{ timer.formatTime(timer.averageLapTime()) }} -
- } } @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/lap-timer-page.component.spec.ts b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts new file mode 100644 index 00000000..78ddef12 --- /dev/null +++ b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject, Subject } from 'rxjs'; +import Storage from 'src/services/storage.service'; +import { DataValue } from 'src/utils/socket.utils'; +import { LAP_STORE_STORAGE_KEY } from 'src/utils/lap-timer.types'; +import LapTimerPageComponent from './lap-timer-page.component'; + +class FakeStorage { + private currentRunId = new BehaviorSubject(undefined); + private streams = new Map>(); + get(key: string): Subject { + let s = this.streams.get(key); + if (!s) { + s = new Subject(); + this.streams.set(key, s); + } + return s; + } + addValue(key: string, v: DataValue) { + this.get(key).next(v); + } + getCurrentRunId() { + return this.currentRunId; + } + setCurrentRunId(runId?: number) { + this.currentRunId.next(runId); + } +} + +describe('LapTimerPageComponent', () => { + let fixture: ComponentFixture; + let component: LapTimerPageComponent; + + beforeEach(async () => { + localStorage.removeItem(LAP_STORE_STORAGE_KEY); + await TestBed.configureTestingModule({ + imports: [LapTimerPageComponent], + providers: [{ provide: Storage, useValue: new FakeStorage() }] + }).compileComponents(); + + fixture = TestBed.createComponent(LapTimerPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + localStorage.removeItem(LAP_STORE_STORAGE_KEY); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 index cc87ad83..11024fc4 100644 --- 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 @@ -1,51 +1,104 @@ -import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; -import { DecimalPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnDestroy, + OnInit, + signal, + Signal +} from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { MatGridList, MatGridTile } from '@angular/material/grid-list'; +import { MatIcon } from '@angular/material/icon'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ConfirmDialog } from 'primeng/confirmdialog'; +import { Toast } from 'primeng/toast'; +import { TableModule } from 'primeng/table'; +import { Popover } from 'primeng/popover'; +import { ButtonComponent } from 'src/components/argos-button/argos-button.component'; +import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component'; import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; import TypographyComponent from 'src/components/typography/typography.component'; -import HStackComponent from 'src/components/hstack/hstack.component'; -import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.component'; import LapTimerService from 'src/services/lap-timer.service'; import Storage from 'src/services/storage.service'; import { topics } from 'src/utils/topic.utils'; +import { formatDeltaMs, formatMs, Lap, LapSession } from 'src/utils/lap-timer.types'; @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, - InfoBackgroundComponent, - TypographyComponent, - HStackComponent, + MatIcon, + DatePipe, + DecimalPipe, + FormsModule, + TableModule, + ConfirmDialog, + Toast, + Popover, + ButtonComponent, HalfGaugeComponent, - DecimalPipe + InfoBackgroundComponent, + TypographyComponent ] }) export default class LapTimerPageComponent implements OnInit, OnDestroy { readonly timer = inject(LapTimerService); private storage = inject(Storage); + private confirmationService = inject(ConfirmationService); + private messageService = inject(MessageService); readonly liveSpeed = signal(0); readonly liveMotorTemp = signal(0); readonly liveSoc = signal(0); + readonly editingSessionId = signal(null); + readonly editingName = signal(''); + + readonly speedGaugeColor = signal('#1ae824'); + + readonly lapsNewestFirst: Signal = computed(() => this.timer.laps().slice().reverse()); + + 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)'; + }); + + 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'; + }); + 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); - }) + 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)) ); } @@ -53,24 +106,155 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { this.subs.forEach((s) => s.unsubscribe()); } - onStartResume(): void { - if (this.timer.isIdle()) { - this.timer.start(); - } else if (this.timer.isPaused()) { - this.timer.resume(); + 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.onDeleteSession(active); + }; + + onDownloadActiveButton = () => this.onDownload(); + + 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); + else this.toast('warn', 'Nothing to download', 'No active session.'); + }; + + 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(); } - onPause(): void { - this.timer.pause(); + cancelEdit(): void { + this.editingSessionId.set(null); + this.editingName.set(''); } - onLap(): void { - this.timer.lap(); + + formatLapMs(ms: number): string { + return formatMs(ms); } - onStop(): void { - this.timer.stop(); + + formatLapDelta(lap: Lap): string { + return formatDeltaMs(this.timer.deltaFromBest(lap.durationMs)); } - onReset(): void { - this.timer.reset(); + + 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; + } + + /** 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; + } + + bestLapMsForSession(sessionId: string): number | null { + return this.timer.getBestLapMs(sessionId); + } + + 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.spec.ts b/angular-client/src/services/lap-timer.service.spec.ts new file mode 100644 index 00000000..083bfaa2 --- /dev/null +++ b/angular-client/src/services/lap-timer.service.spec.ts @@ -0,0 +1,434 @@ +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { LAP_STORE_STORAGE_KEY } from 'src/utils/lap-timer.types'; +import { DataValue } from 'src/utils/socket.utils'; +import Storage from './storage.service'; +import LapTimerService, { TIME_PROVIDER } from './lap-timer.service'; + +// Hardcoded to dodge `bms.utils` ↔ `topic.utils` cycle in karma; must match topic.utils. +const TOPIC_SPEED = 'VCU/CarState/speed'; +const TOPIC_MOTOR_TEMP = 'DTI/Temps/Motor_Temperature'; +const TOPIC_SOC = 'BMS/Pack/SOC'; + +class FakeStorage { + private currentRunId = new BehaviorSubject(undefined); + private streams = new Map>(); + + get(key: string): Subject { + let s = this.streams.get(key); + if (!s) { + s = new Subject(); + this.streams.set(key, s); + } + return s; + } + + addValue(key: string, value: DataValue): void { + this.get(key).next(value); + } + + getCurrentRunId(): BehaviorSubject { + return this.currentRunId; + } + + setCurrentRunId(runId?: number): void { + this.currentRunId.next(runId); + } +} + +const dataValue = (n: number, unit = ''): DataValue => ({ + values: [String(n)], + time: '0', + unit +}); + +describe('LapTimerService', () => { + let now = 0; + let fakeStorage: FakeStorage; + + function makeService(): LapTimerService { + return TestBed.inject(LapTimerService); + } + + function reset(initialNow = 0): LapTimerService { + now = initialNow; + fakeStorage = new FakeStorage(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + LapTimerService, + { provide: Storage, useValue: fakeStorage }, + { provide: TIME_PROVIDER, useValue: () => now } + ] + }); + return makeService(); + } + + beforeEach(() => { + localStorage.removeItem(LAP_STORE_STORAGE_KEY); + }); + + afterEach(() => { + localStorage.removeItem(LAP_STORE_STORAGE_KEY); + }); + + it('should be created with an empty store', () => { + const svc = reset(); + expect(svc).toBeTruthy(); + expect(svc.sessions().length).toBe(0); + expect(svc.activeSession()).toBeNull(); + expect(svc.state()).toBe('idle'); + }); + + it('start() with no active session implicitly creates one and tags runIdAtSessionStart', () => { + const svc = reset(); + fakeStorage.setCurrentRunId(7); + now = 1000; + svc.start(); + const s = svc.activeSession(); + expect(s).not.toBeNull(); + expect(s!.runIdAtSessionStart).toBe(7); + expect(s!.isRunning).toBeTrue(); + expect(s!.laps).toEqual([]); + }); + + it('lap() captures duration, per-lap runId, startEpochMs == previous lap endEpochMs', () => { + const svc = reset(); + fakeStorage.setCurrentRunId(3); + now = 1000; + svc.start(); + now = 6000; + svc.lap(); + now = 11000; + svc.lap(); + + const laps = svc.laps(); + expect(laps.length).toBe(2); + expect(laps[0].durationMs).toBe(5000); + expect(laps[0].startEpochMs).toBe(1000); + expect(laps[0].endEpochMs).toBe(6000); + expect(laps[0].runId).toBe(3); + expect(laps[1].startEpochMs).toBe(6000); + expect(laps[1].endEpochMs).toBe(11000); + expect(laps[1].durationMs).toBe(5000); + }); + + it('pause() then resume() does not count paused interval', () => { + const svc = reset(); + now = 0; + svc.start(); + now = 3000; + svc.pause(); + now = 10000; + svc.resume(); + now = 12000; + svc.lap(); + + const lap = svc.laps()[0]; + expect(lap.durationMs).toBe(5000); + }); + + it('stop() flushes a partial in-progress lap and pauses the session', () => { + const svc = reset(); + now = 0; + svc.start(); + now = 4000; + svc.stop(); + + expect(svc.laps().length).toBe(1); + expect(svc.laps()[0].durationMs).toBe(4000); + expect(svc.isRunning()).toBeFalse(); + expect(svc.isPaused()).toBeTrue(); + expect(svc.activeSession()).not.toBeNull(); + }); + + it('reset() clears active session laps and elapsed but keeps the session entity', () => { + const svc = reset(); + now = 0; + svc.start(); + now = 5000; + svc.lap(); + + svc.reset(); + + const s = svc.activeSession(); + expect(s).not.toBeNull(); + expect(s!.laps).toEqual([]); + expect(s!.isRunning).toBeFalse(); + expect(svc.totalTimeMs()).toBe(0); + }); + + it('createSession() while another is running pauses the prior session and makes the new one active', () => { + const svc = reset(); + now = 0; + svc.start(); + const firstId = svc.activeSession()!.id; + now = 3000; + svc.lap(); + const secondId = svc.createSession('Practice 2'); + + expect(secondId).not.toBe(firstId); + expect(svc.activeSession()!.id).toBe(secondId); + expect(svc.activeSession()!.name).toBe('Practice 2'); + + const prior = svc.sessions().find((s) => s.id === firstId)!; + expect(prior.isRunning).toBeFalse(); + expect(prior.isPaused).toBeTrue(); + expect(prior.laps.length).toBe(1); + }); + + it('selectSession() switches the active pointer; the prior session retains state', () => { + const svc = reset(); + now = 0; + svc.start(); + const a = svc.activeSession()!.id; + now = 5000; + svc.lap(); + const b = svc.createSession(); + now = 6000; + svc.start(); + now = 9000; + svc.lap(); + + svc.selectSession(a); + expect(svc.activeSession()!.id).toBe(a); + expect(svc.laps().length).toBe(1); + + svc.selectSession(b); + expect(svc.activeSession()!.id).toBe(b); + expect(svc.laps().length).toBe(1); + }); + + it('renameSession() updates the name; empty/whitespace is rejected', () => { + const svc = reset(); + svc.createSession('Original'); + const id = svc.activeSession()!.id; + svc.renameSession(id, ' Renamed '); + expect(svc.activeSession()!.name).toBe('Renamed'); + + svc.renameSession(id, ' '); + expect(svc.activeSession()!.name).toBe('Renamed'); + }); + + it('deleteSession() removes the session; if it was active, activeSessionId becomes null', () => { + const svc = reset(); + svc.createSession('A'); + const a = svc.activeSession()!.id; + svc.createSession('B'); + const b = svc.activeSession()!.id; + + svc.deleteSession(a); + expect(svc.sessions().some((s) => s.id === a)).toBeFalse(); + expect(svc.activeSession()!.id).toBe(b); + + svc.deleteSession(b); + expect(svc.sessions().length).toBe(0); + expect(svc.activeSession()).toBeNull(); + }); + + it('clearAllSessions() wipes all sessions and the active pointer', () => { + const svc = reset(); + now = 0; + svc.start(); + now = 5000; svc.lap(); + svc.createSession(); + svc.createSession(); + expect(svc.sessions().length).toBe(3); + + svc.clearAllSessions(); + expect(svc.sessions().length).toBe(0); + expect(svc.activeSession()).toBeNull(); + + fakeStorage = new FakeStorage(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + LapTimerService, + { provide: Storage, useValue: fakeStorage }, + { provide: TIME_PROVIDER, useValue: () => now } + ] + }); + const svc2 = TestBed.inject(LapTimerService); + expect(svc2.sessions().length).toBe(0); + }); + + it('endActiveSession() pauses and nulls activeSessionId; session remains in sessions[]', () => { + const svc = reset(); + now = 0; + svc.start(); + const id = svc.activeSession()!.id; + now = 4000; + svc.endActiveSession(); + + expect(svc.activeSession()).toBeNull(); + const s = svc.sessions().find((x) => x.id === id); + expect(s).toBeTruthy(); + expect(s!.isRunning).toBeFalse(); + }); + + it('localStorage round-trip — second service instance hydrates state', () => { + const svc1 = reset(); + fakeStorage.setCurrentRunId(42); + now = 1000; + svc1.start(); + now = 3000; + svc1.lap(); + const sessionId = svc1.activeSession()!.id; + + fakeStorage = new FakeStorage(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + LapTimerService, + { provide: Storage, useValue: fakeStorage }, + { provide: TIME_PROVIDER, useValue: () => now } + ] + }); + const svc2 = TestBed.inject(LapTimerService); + + expect(svc2.activeSession()!.id).toBe(sessionId); + expect(svc2.laps()[0].runId).toBe(42); + expect(svc2.laps()[0].durationMs).toBe(2000); + expect(svc2.activeSession()!.isRunning).toBeTrue(); + + now = 5000; + expect(svc2.currentLapTimeMs()).toBe(2000); + }); + + it('corrupt JSON in localStorage is dropped silently; empty store on next load', () => { + localStorage.setItem(LAP_STORE_STORAGE_KEY, '{not json'); + const svc = reset(); + expect(svc.sessions().length).toBe(0); + expect(localStorage.getItem(LAP_STORE_STORAGE_KEY)).toBeNull(); + }); + + it('wrong schemaVersion is dropped silently', () => { + localStorage.setItem( + LAP_STORE_STORAGE_KEY, + JSON.stringify({ schemaVersion: 99, activeSessionId: null, sessions: [] }) + ); + const svc = reset(); + expect(svc.sessions().length).toBe(0); + }); + + it('activeSessionId pointing to a nonexistent session hydrates as null', () => { + localStorage.setItem( + LAP_STORE_STORAGE_KEY, + JSON.stringify({ schemaVersion: 1, activeSessionId: 'ghost', sessions: [] }) + ); + const svc = reset(); + expect(svc.activeSession()).toBeNull(); + }); + + it('lap() is a no-op when no active session, when not running, or when zero-duration', () => { + const svc = reset(); + + svc.lap(); // no active session + expect(svc.laps().length).toBe(0); + + svc.createSession(); + svc.lap(); // active but not running + expect(svc.laps().length).toBe(0); + + now = 0; + svc.start(); + svc.lap(); // running but zero duration + expect(svc.laps().length).toBe(0); + + now = 1; + svc.lap(); + expect(svc.laps().length).toBe(1); + }); + + it('each lap captures runId at lap-record time, not session start', () => { + const svc = reset(); + fakeStorage.setCurrentRunId(1); + now = 0; + svc.start(); + fakeStorage.setCurrentRunId(2); + now = 1000; + svc.lap(); + fakeStorage.setCurrentRunId(3); + now = 2000; + svc.lap(); + + expect(svc.activeSession()!.runIdAtSessionStart).toBe(1); + expect(svc.laps()[0].runId).toBe(2); + expect(svc.laps()[1].runId).toBe(3); + }); + + it('bestLap / worstLap / averageLapTime / deltaFromBest reflect the active session laps', () => { + const svc = reset(); + now = 0; + svc.start(); + now = 5000; svc.lap(); // 5000 + now = 13000; svc.lap(); // 8000 + now = 16000; svc.lap(); // 3000 + + expect(svc.bestLap()!.durationMs).toBe(3000); + expect(svc.worstLap()!.durationMs).toBe(8000); + expect(svc.averageLapTime()).toBeCloseTo((5000 + 8000 + 3000) / 3); + expect(svc.deltaFromBest(5000)).toBe(2000); + expect(svc.deltaFromBest(3000)).toBe(0); + }); + + it('LapStats are populated from telemetry: avgSpeed, maxSpeed, energyUsed, maxMotorTemp', () => { + const svc = reset(); + now = 0; + svc.start(); + + fakeStorage.addValue(TOPIC_SPEED, dataValue(40)); + fakeStorage.addValue(TOPIC_SPEED, dataValue(60)); + fakeStorage.addValue(TOPIC_SOC, dataValue(80)); + fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(60)); + fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(70)); + fakeStorage.addValue(TOPIC_SOC, dataValue(78)); + + now = 5000; + svc.lap(); + const stats = svc.laps()[0].stats; + expect(stats.avgSpeed).toBeCloseTo(50); + expect(stats.maxSpeed).toBe(60); + expect(stats.socStart).toBe(80); + expect(stats.socEnd).toBe(78); + expect(stats.energyUsed).toBe(2); + expect(stats.maxMotorTemp).toBe(70); + }); + + it('buildCsv produces a header row + one row per lap with formatted values', () => { + const svc = reset(); + fakeStorage.setCurrentRunId(7); + now = 1745849385000; // arbitrary epoch + svc.start(); + fakeStorage.addValue(TOPIC_SPEED, dataValue(50)); + fakeStorage.addValue(TOPIC_SOC, dataValue(90)); + fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(65)); + fakeStorage.addValue(TOPIC_SOC, dataValue(88)); + now += 60000; + svc.lap(); + fakeStorage.addValue(TOPIC_SOC, dataValue(86)); + fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(70)); + now += 58000; + svc.lap(); + + const built = svc.buildCsv(); + expect(built).not.toBeNull(); + const lines = built!.body.trim().split('\r\n'); + + expect(lines[0]).toBe('Lap,Duration,+/- Best,Time of Day,Run,Avg Speed (mph),Energy (%),Max Temp (°C)'); + expect(lines.length).toBe(3); + + const lap1 = lines[1].split(','); + const lap2 = lines[2].split(','); + expect(lap1[0]).toBe('1'); + expect(lap1[1]).toBe('01:00.00'); + expect(lap1[2]).toBe('+00:02.00'); + expect(lap1[4]).toBe('7'); + expect(lap2[0]).toBe('2'); + expect(lap2[1]).toBe('00:58.00'); + expect(lap2[2]).toBe(''); + + expect(built!.filename).toContain('argos-laps-'); + expect(built!.filename.endsWith('.csv')).toBeTrue(); + }); +}); diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts index cade61e6..b20de5fc 100644 --- a/angular-client/src/services/lap-timer.service.ts +++ b/angular-client/src/services/lap-timer.service.ts @@ -1,172 +1,406 @@ -import { computed, inject, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, InjectionToken, signal, Signal } from '@angular/core'; import { Subscription } from 'rxjs'; -import { startLap as apiStartLap, pauseLap as apiPauseLap, stopLap as apiStopLap } from 'src/api/lap-timer.api'; +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'; -export interface LapStats { - avgSpeed: number | null; - maxSpeed: number | null; - socStart: number | null; - socEnd: number | null; - energyUsed: number | null; // SOC delta as percentage points - maxMotorTemp: number | null; -} +/** Test override. */ +export const TIME_PROVIDER = new InjectionToken<() => number>('LAP_TIMER_TIME_PROVIDER', { + providedIn: 'root', + factory: () => () => Date.now() +}); -export interface LapRecord { - number: number; - duration: number; - stats: LapStats; -} +const TICK_INTERVAL_MS = 100; @Injectable({ providedIn: 'root' }) export default class LapTimerService { private storage = inject(Storage); + private now = inject(TIME_PROVIDER); - readonly state = signal('idle'); - readonly currentLapTime = signal(0); - readonly totalTime = signal(0); - readonly laps = signal([]); + private readonly store = signal(hydrate()); - private rafId: number | null = null; - private lastTickTime = 0; + /** Drives time-derived computeds. */ + private readonly tickSignal = signal(0); + private tickInterval: ReturnType | null = null; - // Telemetry accumulators for current lap 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 formattedCurrentLap = computed(() => this.formatTime(this.currentLapTime())); - readonly formattedTotal = computed(() => this.formatTime(this.totalTime())); + readonly currentLapTimeMs = computed(() => { + this.tickSignal(); + const s = this.activeSession(); + if (!s) return 0; + if (s.isRunning && s.currentLapStartEpochMs !== null) { + return s.currentLapAccumulatedMs + (this.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 laps = this.laps(); - if (laps.length === 0) return null; - return laps.reduce((best, lap) => (lap.duration < best.duration ? lap : best)); + 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 laps = this.laps(); - if (laps.length < 2) return null; - return laps.reduce((worst, lap) => (lap.duration > worst.duration ? lap : worst)); + 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 laps = this.laps(); - if (laps.length === 0) return 0; - return laps.reduce((sum, lap) => sum + lap.duration, 0) / laps.length; + const ls = this.laps(); + if (ls.length === 0) return 0; + return ls.reduce((sum, lap) => sum + lap.durationMs, 0) / ls.length; }); - // Session-level computed stats readonly totalEnergyUsed = computed(() => { - const laps = this.laps(); - return laps.reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0); + return this.laps().reduce((sum, lap) => sum + (lap.stats.energyUsed ?? 0), 0); }); - deltaFromBest(lapDuration: number): number | null { + readonly currentLapDeltaToBestMs = computed(() => { const best = this.bestLap(); if (!best) return null; - return lapDuration - best.duration; + return this.currentLapTimeMs() - best.durationMs; + }); + + constructor() { + if (this.activeSession()?.isRunning) { + this.subscribeTelemetry(); + this.startTickLoop(); + } } - formatDelta(deltaMs: number): string { - const sign = deltaMs >= 0 ? '+' : '-'; - return `${sign}${this.formatTime(Math.abs(deltaMs))}`; + 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.state() === 'idle') { - this.laps.set([]); - this.currentLapTime.set(0); - this.totalTime.set(0); + if (!this.activeSession()) { + this.createSession(); } - this.state.set('running'); - this.startTicking(); + const session = this.activeSession(); + if (!session || session.isRunning) return; + this.mutateActive((s) => { + s.isRunning = true; + s.isPaused = false; + s.currentLapStartEpochMs = this.now(); + }); this.subscribeTelemetry(); - apiStartLap().catch(() => {}); + this.startTickLoop(); } pause(): void { - if (this.state() !== 'running') return; - this.state.set('paused'); - this.stopTicking(); - apiPauseLap().catch(() => {}); + const s = this.activeSession(); + if (!s || !s.isRunning) return; + this.mutateActive((next) => { + const slice = next.currentLapStartEpochMs !== null ? this.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.state() !== 'paused') return; - this.state.set('running'); - this.startTicking(); - apiStartLap().catch(() => {}); + if (!this.isPaused()) return; + this.mutateActive((next) => { + next.isRunning = true; + next.isPaused = false; + next.currentLapStartEpochMs = this.now(); + }); + if (this.telemetrySubs.length === 0) this.subscribeTelemetry(); + this.startTickLoop(); } + /** Each lap captures runId at record time. */ lap(): void { - if (this.state() !== 'running') return; - const lapDuration = this.currentLapTime(); - if (lapDuration === 0) return; + const session = this.activeSession(); + if (!session || !session.isRunning || session.currentLapStartEpochMs === null) return; + const endEpochMs = this.now(); + const durationMs = session.currentLapAccumulatedMs + (endEpochMs - session.currentLapStartEpochMs); + if (durationMs === 0) return; + const stats = this.snapshotStats(); - this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: lapDuration, stats }]); - this.currentLapTime.set(0); + 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 { - const remaining = this.currentLapTime(); - if (remaining > 0) { - const stats = this.snapshotStats(); - this.laps.update((prev) => [...prev, { number: prev.length + 1, duration: remaining, stats }]); + if (this.isRunning()) { + this.lap(); + this.pause(); } - this.stopTicking(); - this.unsubscribeTelemetry(); - this.state.set('idle'); - this.currentLapTime.set(0); - apiStopLap().catch(() => {}); } reset(): void { - this.stopTicking(); + 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 = this.now(); + const runId = this.storage.getCurrentRunId().getValue() ?? null; + const newSession: LapSession = { + id: generateId(), + 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.state.set('idle'); - this.currentLapTime.set(0); - this.totalTime.set(0); - this.laps.set([]); + 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; } - formatTime(timeMs: number): string { - const totalSeconds = Math.floor(timeMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - const centiseconds = Math.floor((timeMs % 1000) / 10); - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + private startTickLoop(): void { + if (this.tickInterval !== null) return; + this.tickInterval = setInterval(() => { + this.tickSignal.update((n) => (n + 1) | 0); + }, TICK_INTERVAL_MS); } - // --- Telemetry tracking --- + 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.state() !== 'running') return; + if (!this.isRunning()) return; const speed = parseFloat(value.values[0]); - if (!isNaN(speed)) { - this.speedSamples.push(speed); - } + if (!isNaN(speed)) this.speedSamples.push(speed); }) ); - this.telemetrySubs.push( this.storage.get(topics.stateOfCharge()).subscribe((value) => { - if (this.state() !== 'running') return; + if (!this.isRunning()) return; const soc = parseFloat(value.values[0]); if (!isNaN(soc)) { if (this.lapSocStart === null) this.lapSocStart = soc; @@ -174,10 +408,9 @@ export default class LapTimerService { } }) ); - this.telemetrySubs.push( this.storage.get(topics.motorTemp()).subscribe((value) => { - if (this.state() !== 'running') return; + if (!this.isRunning()) return; const temp = parseFloat(value.values[0]); if (!isNaN(temp)) { this.lapMaxMotorTemp = this.lapMaxMotorTemp === null ? temp : Math.max(this.lapMaxMotorTemp, temp); @@ -192,11 +425,10 @@ export default class LapTimerService { } private snapshotStats(): LapStats { - const avgSpeed = - this.speedSamples.length > 0 ? this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length : null; - const maxSpeed = this.speedSamples.length > 0 ? Math.max(...this.speedSamples) : null; + 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, @@ -209,29 +441,48 @@ export default class LapTimerService { private resetLapAccumulators(): void { this.speedSamples = []; - this.lapSocStart = this.lastSoc; // carry over current SOC as next lap's start + // Carry latest SOC into next lap's start. + this.lapSocStart = this.lastSoc; this.lapMaxMotorTemp = null; } +} - // --- Timer internals --- - - private startTicking(): void { - this.lastTickTime = performance.now(); - const tick = () => { - const now = performance.now(); - const delta = now - this.lastTickTime; - this.lastTickTime = now; - this.currentLapTime.update((t) => t + delta); - this.totalTime.update((t) => t + delta); - this.rafId = requestAnimationFrame(tick); - }; - this.rafId = requestAnimationFrame(tick); - } - - private stopTicking(): void { - if (this.rafId !== null) { - cancelAnimationFrame(this.rafId); - this.rafId = 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 { + /* ignore */ } + return emptyLapStore(); } } + +function cloneStore(s: LapStore): LapStore { + return { + schemaVersion: s.schemaVersion, + activeSessionId: s.activeSessionId, + sessions: s.sessions.map((sess) => ({ ...sess, laps: [...sess.laps] })) + }; +} + +function generateId(): string { + // Fallback for runners pre-dating crypto.randomUUID. + const c: Crypto | undefined = typeof crypto !== 'undefined' ? crypto : undefined; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + const r = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); + return `${r()}${r()}-${r()}-${r()}-${r()}-${r()}${r()}${r()}`; +} diff --git a/angular-client/src/utils/bms.utils.ts b/angular-client/src/utils/bms.utils.ts index 7aedc4f2..50e2a4f3 100644 --- a/angular-client/src/utils/bms.utils.ts +++ b/angular-client/src/utils/bms.utils.ts @@ -37,19 +37,40 @@ export type SegmentInfo = { totalVoltageKey: string; }; -/** Dynamically generated map of segment index → SegmentInfo topic keys. */ -export const segmentInfoMap: Record = Object.fromEntries( - allSegments.map((seg) => [ - seg, - { - segmentTempKey: topics.segmentTemp(seg), - alphaChipTempKey: topics.dieTemp(seg, Chip.Alpha), - betaChipTempKey: topics.dieTemp(seg, Chip.Beta), - voltageKey: topics.segmentVoltage(seg), - totalVoltageKey: topics.segmentTotalVoltage(seg) - } - ]) -); +/** Lazy: avoids `bms.utils` ↔ `topic.utils` cycle in karma. */ +let _segmentInfoMap: Record | null = null; +const buildSegmentInfoMap = (): Record => + Object.fromEntries( + allSegments.map((seg) => [ + seg, + { + segmentTempKey: topics.segmentTemp(seg), + alphaChipTempKey: topics.dieTemp(seg, Chip.Alpha), + betaChipTempKey: topics.dieTemp(seg, Chip.Beta), + voltageKey: topics.segmentVoltage(seg), + totalVoltageKey: topics.segmentTotalVoltage(seg) + } + ]) + ); + +export const segmentInfoMap: Record = new Proxy({} as Record, { + get(_target, prop) { + if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); + return _segmentInfoMap[prop as unknown as Segment]; + }, + has(_target, prop) { + if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); + return prop in _segmentInfoMap; + }, + ownKeys() { + if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); + return Reflect.ownKeys(_segmentInfoMap); + }, + getOwnPropertyDescriptor(_target, prop) { + if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); + return Object.getOwnPropertyDescriptor(_segmentInfoMap, prop); + } +}); export const getConnectionDotStatusColor = (voltage: number): string => { if (voltage <= 375) { 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' + ); +}; diff --git a/angular-client/tsconfig.spec.json b/angular-client/tsconfig.spec.json index 47e3dd75..c2f398b4 100644 --- a/angular-client/tsconfig.spec.json +++ b/angular-client/tsconfig.spec.json @@ -3,7 +3,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine"] + "types": ["jasmine", "node"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] } From 7781263d57bff70a78c74db744bb1e7e7ab6d086 Mon Sep 17 00:00:00 2001 From: Jeffrey <54014102+TheJeffreyKuo@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:02:39 -0400 Subject: [PATCH 7/7] clean up --- angular-client/angular.json | 1 - .../graph-page/graph-page.component.spec.ts | 4 +- .../lap-timer-page.component.css | 420 ----------------- .../lap-timer-page.component.html | 304 +----------- .../lap-timer-page.component.spec.ts | 53 --- .../lap-timer-page.component.ts | 203 +------- .../laps-table/laps-table.component.css | 114 +++++ .../laps-table/laps-table.component.html | 74 +++ .../laps-table/laps-table.component.ts | 38 ++ .../session-summary.component.css | 51 ++ .../session-summary.component.html | 35 ++ .../session-summary.component.ts | 20 + .../sessions-panel.component.css | 229 +++++++++ .../sessions-panel.component.html | 117 +++++ .../sessions-panel.component.ts | 142 ++++++ .../timer-hero/timer-hero.component.css | 75 +++ .../timer-hero/timer-hero.component.html | 74 +++ .../timer-hero/timer-hero.component.ts | 65 +++ .../src/services/lap-timer.service.spec.ts | 434 ------------------ .../src/services/lap-timer.service.ts | 34 +- angular-client/src/utils/bms.utils.ts | 47 +- angular-client/tsconfig.spec.json | 2 +- 22 files changed, 1074 insertions(+), 1462 deletions(-) delete mode 100644 angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts create mode 100644 angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.css create mode 100644 angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.html create mode 100644 angular-client/src/pages/lap-timer-page/laps-table/laps-table.component.ts create mode 100644 angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.css create mode 100644 angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.html create mode 100644 angular-client/src/pages/lap-timer-page/session-summary/session-summary.component.ts create mode 100644 angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.css create mode 100644 angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.html create mode 100644 angular-client/src/pages/lap-timer-page/sessions-panel/sessions-panel.component.ts create mode 100644 angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.css create mode 100644 angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.html create mode 100644 angular-client/src/pages/lap-timer-page/timer-hero/timer-hero.component.ts delete mode 100644 angular-client/src/services/lap-timer.service.spec.ts diff --git a/angular-client/angular.json b/angular-client/angular.json index 965f1442..071f69f5 100644 --- a/angular-client/angular.json +++ b/angular-client/angular.json @@ -81,7 +81,6 @@ "builder": "@angular-devkit/build-angular:karma", "options": { "tsConfig": "tsconfig.spec.json", - "polyfills": ["zone.js", "zone.js/testing"], "assets": ["src/favicon.ico", "src/assets"], "styles": ["@angular/material/prebuilt-themes/rose-red.css", "src/styles.css"], "scripts": [] diff --git a/angular-client/src/pages/graph-page/graph-page.component.spec.ts b/angular-client/src/pages/graph-page/graph-page.component.spec.ts index 9b223817..0ecc2cba 100644 --- a/angular-client/src/pages/graph-page/graph-page.component.spec.ts +++ b/angular-client/src/pages/graph-page/graph-page.component.spec.ts @@ -32,7 +32,7 @@ describe('GraphPageComponent — URL/topic-selection sync', () => { const apiServiceMock: Pick = { query: () => { queryCalls += 1; - const response = + const response: QueryResponse = queryCalls === 1 ? { isLoading: new BehaviorSubject(true), @@ -46,7 +46,7 @@ describe('GraphPageComponent — URL/topic-selection sync', () => { isError: new BehaviorSubject(false), error: new BehaviorSubject(null) }; - return response as unknown as QueryResponse; + return response as QueryResponse; } }; 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 index 1523dc5e..a877048a 100644 --- 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 @@ -2,364 +2,6 @@ margin: 0 16px; } -.monospace { - font-family: var(--font-family); - font-variant-numeric: tabular-nums; -} - -/* ---------- Sessions panel ---------- */ - -.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, -.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; -} -.sessions-table-wrap::-webkit-scrollbar, -.lap-table-wrap::-webkit-scrollbar { - display: block; - width: 6px; -} -.sessions-table-wrap::-webkit-scrollbar-thumb, -.lap-table-wrap::-webkit-scrollbar-thumb { - background: var(--color-divider); - border-radius: 3px; -} -.sessions-table-wrap::-webkit-scrollbar-track, -.lap-table-wrap::-webkit-scrollbar-track { - background: transparent; -} - -.sessions-table-wrap :where(.p-datatable), -.lap-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), -.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; -} -.sessions-table-wrap :where(td), -.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, -.sessions-table-wrap :where(.session-row):hover td { - background: rgba(255, 255, 255, 0.03); -} -.sessions-table-wrap :where(td) { - padding-top: 6px; - padding-bottom: 6px; -} - -.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; -} - -/* ---------- Timer hero ---------- */ - -.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; -} - -/* ---------- Session summary ---------- */ - -.session-summary { - display: flex; - flex-direction: column; - gap: 8px; - padding: 4px 8px; - width: 100%; -} - -.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); } - -/* ---------- Gauges ---------- */ - .gauge-wrap { display: flex; justify-content: center; @@ -387,65 +29,3 @@ font-size: var(--font-size-sm); color: var(--color-text-secondary); } - -/* ---------- Lap table ---------- */ - -.empty-laps { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 32px; -} - -.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); -} - -/* Same !important reason as sessions table above. */ -.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; -} - -.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/lap-timer-page.component.html b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.html index 4ecabdd7..0dd642aa 100644 --- 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 @@ -4,238 +4,15 @@ - -
-
- - @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 ?? '—' }} - - - -
- - - -
-
- - -
-
-
- } -
-
+
- -
-
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()) { - - - - - } -
-
-
+
- -
-
{{ 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 ?? '—' }} -
-
-
+
@@ -265,80 +42,7 @@ - -
- @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/lap-timer-page.component.spec.ts b/angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts deleted file mode 100644 index 78ddef12..00000000 --- a/angular-client/src/pages/lap-timer-page/lap-timer-page.component.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BehaviorSubject, Subject } from 'rxjs'; -import Storage from 'src/services/storage.service'; -import { DataValue } from 'src/utils/socket.utils'; -import { LAP_STORE_STORAGE_KEY } from 'src/utils/lap-timer.types'; -import LapTimerPageComponent from './lap-timer-page.component'; - -class FakeStorage { - private currentRunId = new BehaviorSubject(undefined); - private streams = new Map>(); - get(key: string): Subject { - let s = this.streams.get(key); - if (!s) { - s = new Subject(); - this.streams.set(key, s); - } - return s; - } - addValue(key: string, v: DataValue) { - this.get(key).next(v); - } - getCurrentRunId() { - return this.currentRunId; - } - setCurrentRunId(runId?: number) { - this.currentRunId.next(runId); - } -} - -describe('LapTimerPageComponent', () => { - let fixture: ComponentFixture; - let component: LapTimerPageComponent; - - beforeEach(async () => { - localStorage.removeItem(LAP_STORE_STORAGE_KEY); - await TestBed.configureTestingModule({ - imports: [LapTimerPageComponent], - providers: [{ provide: Storage, useValue: new FakeStorage() }] - }).compileComponents(); - - fixture = TestBed.createComponent(LapTimerPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - localStorage.removeItem(LAP_STORE_STORAGE_KEY); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); 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 index 11024fc4..846dc678 100644 --- 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 @@ -1,31 +1,18 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - OnDestroy, - OnInit, - signal, - Signal -} from '@angular/core'; -import { DatePipe, DecimalPipe } from '@angular/common'; -import { FormsModule } from '@angular/forms'; +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 { MatIcon } from '@angular/material/icon'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmDialog } from 'primeng/confirmdialog'; import { Toast } from 'primeng/toast'; -import { TableModule } from 'primeng/table'; -import { Popover } from 'primeng/popover'; -import { ButtonComponent } from 'src/components/argos-button/argos-button.component'; import HalfGaugeComponent from 'src/components/half-gauge/half-gauge.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 Storage from 'src/services/storage.service'; import { topics } from 'src/utils/topic.utils'; -import { formatDeltaMs, formatMs, Lap, LapSession } from 'src/utils/lap-timer.types'; +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', @@ -36,37 +23,26 @@ import { formatDeltaMs, formatMs, Lap, LapSession } from 'src/utils/lap-timer.ty imports: [ MatGridList, MatGridTile, - MatIcon, - DatePipe, DecimalPipe, - FormsModule, - TableModule, ConfirmDialog, Toast, - Popover, - ButtonComponent, HalfGaugeComponent, InfoBackgroundComponent, - TypographyComponent + SessionsPanelComponent, + TimerHeroComponent, + SessionSummaryComponent, + LapsTableComponent ] }) export default class LapTimerPageComponent implements OnInit, OnDestroy { - readonly timer = inject(LapTimerService); private storage = inject(Storage); - private confirmationService = inject(ConfirmationService); - private messageService = inject(MessageService); readonly liveSpeed = signal(0); readonly liveMotorTemp = signal(0); readonly liveSoc = signal(0); - readonly editingSessionId = signal(null); - readonly editingName = signal(''); - readonly speedGaugeColor = signal('#1ae824'); - readonly lapsNewestFirst: Signal = computed(() => this.timer.laps().slice().reverse()); - readonly socColor = computed(() => { const soc = this.liveSoc(); if (soc >= 60) return 'var(--color-battery-high)'; @@ -81,13 +57,6 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { return 'var(--color-battery-low)'; }); - 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'; - }); - private subs: Subscription[] = []; ngOnInit(): void { @@ -105,156 +74,4 @@ export default class LapTimerPageComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subs.forEach((s) => s.unsubscribe()); } - - 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.onDeleteSession(active); - }; - - onDownloadActiveButton = () => this.onDownload(); - - 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); - else this.toast('warn', 'Nothing to download', 'No active session.'); - }; - - 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); - } - - 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; - } - - /** 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; - } - - bestLapMsForSession(sessionId: string): number | null { - return this.timer.getBestLapMs(sessionId); - } - - 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/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/services/lap-timer.service.spec.ts b/angular-client/src/services/lap-timer.service.spec.ts deleted file mode 100644 index 083bfaa2..00000000 --- a/angular-client/src/services/lap-timer.service.spec.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { LAP_STORE_STORAGE_KEY } from 'src/utils/lap-timer.types'; -import { DataValue } from 'src/utils/socket.utils'; -import Storage from './storage.service'; -import LapTimerService, { TIME_PROVIDER } from './lap-timer.service'; - -// Hardcoded to dodge `bms.utils` ↔ `topic.utils` cycle in karma; must match topic.utils. -const TOPIC_SPEED = 'VCU/CarState/speed'; -const TOPIC_MOTOR_TEMP = 'DTI/Temps/Motor_Temperature'; -const TOPIC_SOC = 'BMS/Pack/SOC'; - -class FakeStorage { - private currentRunId = new BehaviorSubject(undefined); - private streams = new Map>(); - - get(key: string): Subject { - let s = this.streams.get(key); - if (!s) { - s = new Subject(); - this.streams.set(key, s); - } - return s; - } - - addValue(key: string, value: DataValue): void { - this.get(key).next(value); - } - - getCurrentRunId(): BehaviorSubject { - return this.currentRunId; - } - - setCurrentRunId(runId?: number): void { - this.currentRunId.next(runId); - } -} - -const dataValue = (n: number, unit = ''): DataValue => ({ - values: [String(n)], - time: '0', - unit -}); - -describe('LapTimerService', () => { - let now = 0; - let fakeStorage: FakeStorage; - - function makeService(): LapTimerService { - return TestBed.inject(LapTimerService); - } - - function reset(initialNow = 0): LapTimerService { - now = initialNow; - fakeStorage = new FakeStorage(); - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [ - LapTimerService, - { provide: Storage, useValue: fakeStorage }, - { provide: TIME_PROVIDER, useValue: () => now } - ] - }); - return makeService(); - } - - beforeEach(() => { - localStorage.removeItem(LAP_STORE_STORAGE_KEY); - }); - - afterEach(() => { - localStorage.removeItem(LAP_STORE_STORAGE_KEY); - }); - - it('should be created with an empty store', () => { - const svc = reset(); - expect(svc).toBeTruthy(); - expect(svc.sessions().length).toBe(0); - expect(svc.activeSession()).toBeNull(); - expect(svc.state()).toBe('idle'); - }); - - it('start() with no active session implicitly creates one and tags runIdAtSessionStart', () => { - const svc = reset(); - fakeStorage.setCurrentRunId(7); - now = 1000; - svc.start(); - const s = svc.activeSession(); - expect(s).not.toBeNull(); - expect(s!.runIdAtSessionStart).toBe(7); - expect(s!.isRunning).toBeTrue(); - expect(s!.laps).toEqual([]); - }); - - it('lap() captures duration, per-lap runId, startEpochMs == previous lap endEpochMs', () => { - const svc = reset(); - fakeStorage.setCurrentRunId(3); - now = 1000; - svc.start(); - now = 6000; - svc.lap(); - now = 11000; - svc.lap(); - - const laps = svc.laps(); - expect(laps.length).toBe(2); - expect(laps[0].durationMs).toBe(5000); - expect(laps[0].startEpochMs).toBe(1000); - expect(laps[0].endEpochMs).toBe(6000); - expect(laps[0].runId).toBe(3); - expect(laps[1].startEpochMs).toBe(6000); - expect(laps[1].endEpochMs).toBe(11000); - expect(laps[1].durationMs).toBe(5000); - }); - - it('pause() then resume() does not count paused interval', () => { - const svc = reset(); - now = 0; - svc.start(); - now = 3000; - svc.pause(); - now = 10000; - svc.resume(); - now = 12000; - svc.lap(); - - const lap = svc.laps()[0]; - expect(lap.durationMs).toBe(5000); - }); - - it('stop() flushes a partial in-progress lap and pauses the session', () => { - const svc = reset(); - now = 0; - svc.start(); - now = 4000; - svc.stop(); - - expect(svc.laps().length).toBe(1); - expect(svc.laps()[0].durationMs).toBe(4000); - expect(svc.isRunning()).toBeFalse(); - expect(svc.isPaused()).toBeTrue(); - expect(svc.activeSession()).not.toBeNull(); - }); - - it('reset() clears active session laps and elapsed but keeps the session entity', () => { - const svc = reset(); - now = 0; - svc.start(); - now = 5000; - svc.lap(); - - svc.reset(); - - const s = svc.activeSession(); - expect(s).not.toBeNull(); - expect(s!.laps).toEqual([]); - expect(s!.isRunning).toBeFalse(); - expect(svc.totalTimeMs()).toBe(0); - }); - - it('createSession() while another is running pauses the prior session and makes the new one active', () => { - const svc = reset(); - now = 0; - svc.start(); - const firstId = svc.activeSession()!.id; - now = 3000; - svc.lap(); - const secondId = svc.createSession('Practice 2'); - - expect(secondId).not.toBe(firstId); - expect(svc.activeSession()!.id).toBe(secondId); - expect(svc.activeSession()!.name).toBe('Practice 2'); - - const prior = svc.sessions().find((s) => s.id === firstId)!; - expect(prior.isRunning).toBeFalse(); - expect(prior.isPaused).toBeTrue(); - expect(prior.laps.length).toBe(1); - }); - - it('selectSession() switches the active pointer; the prior session retains state', () => { - const svc = reset(); - now = 0; - svc.start(); - const a = svc.activeSession()!.id; - now = 5000; - svc.lap(); - const b = svc.createSession(); - now = 6000; - svc.start(); - now = 9000; - svc.lap(); - - svc.selectSession(a); - expect(svc.activeSession()!.id).toBe(a); - expect(svc.laps().length).toBe(1); - - svc.selectSession(b); - expect(svc.activeSession()!.id).toBe(b); - expect(svc.laps().length).toBe(1); - }); - - it('renameSession() updates the name; empty/whitespace is rejected', () => { - const svc = reset(); - svc.createSession('Original'); - const id = svc.activeSession()!.id; - svc.renameSession(id, ' Renamed '); - expect(svc.activeSession()!.name).toBe('Renamed'); - - svc.renameSession(id, ' '); - expect(svc.activeSession()!.name).toBe('Renamed'); - }); - - it('deleteSession() removes the session; if it was active, activeSessionId becomes null', () => { - const svc = reset(); - svc.createSession('A'); - const a = svc.activeSession()!.id; - svc.createSession('B'); - const b = svc.activeSession()!.id; - - svc.deleteSession(a); - expect(svc.sessions().some((s) => s.id === a)).toBeFalse(); - expect(svc.activeSession()!.id).toBe(b); - - svc.deleteSession(b); - expect(svc.sessions().length).toBe(0); - expect(svc.activeSession()).toBeNull(); - }); - - it('clearAllSessions() wipes all sessions and the active pointer', () => { - const svc = reset(); - now = 0; - svc.start(); - now = 5000; svc.lap(); - svc.createSession(); - svc.createSession(); - expect(svc.sessions().length).toBe(3); - - svc.clearAllSessions(); - expect(svc.sessions().length).toBe(0); - expect(svc.activeSession()).toBeNull(); - - fakeStorage = new FakeStorage(); - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [ - LapTimerService, - { provide: Storage, useValue: fakeStorage }, - { provide: TIME_PROVIDER, useValue: () => now } - ] - }); - const svc2 = TestBed.inject(LapTimerService); - expect(svc2.sessions().length).toBe(0); - }); - - it('endActiveSession() pauses and nulls activeSessionId; session remains in sessions[]', () => { - const svc = reset(); - now = 0; - svc.start(); - const id = svc.activeSession()!.id; - now = 4000; - svc.endActiveSession(); - - expect(svc.activeSession()).toBeNull(); - const s = svc.sessions().find((x) => x.id === id); - expect(s).toBeTruthy(); - expect(s!.isRunning).toBeFalse(); - }); - - it('localStorage round-trip — second service instance hydrates state', () => { - const svc1 = reset(); - fakeStorage.setCurrentRunId(42); - now = 1000; - svc1.start(); - now = 3000; - svc1.lap(); - const sessionId = svc1.activeSession()!.id; - - fakeStorage = new FakeStorage(); - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [ - LapTimerService, - { provide: Storage, useValue: fakeStorage }, - { provide: TIME_PROVIDER, useValue: () => now } - ] - }); - const svc2 = TestBed.inject(LapTimerService); - - expect(svc2.activeSession()!.id).toBe(sessionId); - expect(svc2.laps()[0].runId).toBe(42); - expect(svc2.laps()[0].durationMs).toBe(2000); - expect(svc2.activeSession()!.isRunning).toBeTrue(); - - now = 5000; - expect(svc2.currentLapTimeMs()).toBe(2000); - }); - - it('corrupt JSON in localStorage is dropped silently; empty store on next load', () => { - localStorage.setItem(LAP_STORE_STORAGE_KEY, '{not json'); - const svc = reset(); - expect(svc.sessions().length).toBe(0); - expect(localStorage.getItem(LAP_STORE_STORAGE_KEY)).toBeNull(); - }); - - it('wrong schemaVersion is dropped silently', () => { - localStorage.setItem( - LAP_STORE_STORAGE_KEY, - JSON.stringify({ schemaVersion: 99, activeSessionId: null, sessions: [] }) - ); - const svc = reset(); - expect(svc.sessions().length).toBe(0); - }); - - it('activeSessionId pointing to a nonexistent session hydrates as null', () => { - localStorage.setItem( - LAP_STORE_STORAGE_KEY, - JSON.stringify({ schemaVersion: 1, activeSessionId: 'ghost', sessions: [] }) - ); - const svc = reset(); - expect(svc.activeSession()).toBeNull(); - }); - - it('lap() is a no-op when no active session, when not running, or when zero-duration', () => { - const svc = reset(); - - svc.lap(); // no active session - expect(svc.laps().length).toBe(0); - - svc.createSession(); - svc.lap(); // active but not running - expect(svc.laps().length).toBe(0); - - now = 0; - svc.start(); - svc.lap(); // running but zero duration - expect(svc.laps().length).toBe(0); - - now = 1; - svc.lap(); - expect(svc.laps().length).toBe(1); - }); - - it('each lap captures runId at lap-record time, not session start', () => { - const svc = reset(); - fakeStorage.setCurrentRunId(1); - now = 0; - svc.start(); - fakeStorage.setCurrentRunId(2); - now = 1000; - svc.lap(); - fakeStorage.setCurrentRunId(3); - now = 2000; - svc.lap(); - - expect(svc.activeSession()!.runIdAtSessionStart).toBe(1); - expect(svc.laps()[0].runId).toBe(2); - expect(svc.laps()[1].runId).toBe(3); - }); - - it('bestLap / worstLap / averageLapTime / deltaFromBest reflect the active session laps', () => { - const svc = reset(); - now = 0; - svc.start(); - now = 5000; svc.lap(); // 5000 - now = 13000; svc.lap(); // 8000 - now = 16000; svc.lap(); // 3000 - - expect(svc.bestLap()!.durationMs).toBe(3000); - expect(svc.worstLap()!.durationMs).toBe(8000); - expect(svc.averageLapTime()).toBeCloseTo((5000 + 8000 + 3000) / 3); - expect(svc.deltaFromBest(5000)).toBe(2000); - expect(svc.deltaFromBest(3000)).toBe(0); - }); - - it('LapStats are populated from telemetry: avgSpeed, maxSpeed, energyUsed, maxMotorTemp', () => { - const svc = reset(); - now = 0; - svc.start(); - - fakeStorage.addValue(TOPIC_SPEED, dataValue(40)); - fakeStorage.addValue(TOPIC_SPEED, dataValue(60)); - fakeStorage.addValue(TOPIC_SOC, dataValue(80)); - fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(60)); - fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(70)); - fakeStorage.addValue(TOPIC_SOC, dataValue(78)); - - now = 5000; - svc.lap(); - const stats = svc.laps()[0].stats; - expect(stats.avgSpeed).toBeCloseTo(50); - expect(stats.maxSpeed).toBe(60); - expect(stats.socStart).toBe(80); - expect(stats.socEnd).toBe(78); - expect(stats.energyUsed).toBe(2); - expect(stats.maxMotorTemp).toBe(70); - }); - - it('buildCsv produces a header row + one row per lap with formatted values', () => { - const svc = reset(); - fakeStorage.setCurrentRunId(7); - now = 1745849385000; // arbitrary epoch - svc.start(); - fakeStorage.addValue(TOPIC_SPEED, dataValue(50)); - fakeStorage.addValue(TOPIC_SOC, dataValue(90)); - fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(65)); - fakeStorage.addValue(TOPIC_SOC, dataValue(88)); - now += 60000; - svc.lap(); - fakeStorage.addValue(TOPIC_SOC, dataValue(86)); - fakeStorage.addValue(TOPIC_MOTOR_TEMP, dataValue(70)); - now += 58000; - svc.lap(); - - const built = svc.buildCsv(); - expect(built).not.toBeNull(); - const lines = built!.body.trim().split('\r\n'); - - expect(lines[0]).toBe('Lap,Duration,+/- Best,Time of Day,Run,Avg Speed (mph),Energy (%),Max Temp (°C)'); - expect(lines.length).toBe(3); - - const lap1 = lines[1].split(','); - const lap2 = lines[2].split(','); - expect(lap1[0]).toBe('1'); - expect(lap1[1]).toBe('01:00.00'); - expect(lap1[2]).toBe('+00:02.00'); - expect(lap1[4]).toBe('7'); - expect(lap2[0]).toBe('2'); - expect(lap2[1]).toBe('00:58.00'); - expect(lap2[2]).toBe(''); - - expect(built!.filename).toContain('argos-laps-'); - expect(built!.filename.endsWith('.csv')).toBeTrue(); - }); -}); diff --git a/angular-client/src/services/lap-timer.service.ts b/angular-client/src/services/lap-timer.service.ts index b20de5fc..42af8355 100644 --- a/angular-client/src/services/lap-timer.service.ts +++ b/angular-client/src/services/lap-timer.service.ts @@ -1,5 +1,6 @@ -import { computed, inject, Injectable, InjectionToken, signal, Signal } from '@angular/core'; +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 { @@ -20,18 +21,11 @@ import Storage from './storage.service'; export type LapState = 'idle' | 'running' | 'paused'; -/** Test override. */ -export const TIME_PROVIDER = new InjectionToken<() => number>('LAP_TIMER_TIME_PROVIDER', { - providedIn: 'root', - factory: () => () => Date.now() -}); - const TICK_INTERVAL_MS = 100; @Injectable({ providedIn: 'root' }) export default class LapTimerService { private storage = inject(Storage); - private now = inject(TIME_PROVIDER); private readonly store = signal(hydrate()); @@ -69,7 +63,7 @@ export default class LapTimerService { const s = this.activeSession(); if (!s) return 0; if (s.isRunning && s.currentLapStartEpochMs !== null) { - return s.currentLapAccumulatedMs + (this.now() - s.currentLapStartEpochMs); + return s.currentLapAccumulatedMs + (Date.now() - s.currentLapStartEpochMs); } return s.currentLapAccumulatedMs; }); @@ -141,7 +135,7 @@ export default class LapTimerService { this.mutateActive((s) => { s.isRunning = true; s.isPaused = false; - s.currentLapStartEpochMs = this.now(); + s.currentLapStartEpochMs = Date.now(); }); this.subscribeTelemetry(); this.startTickLoop(); @@ -151,7 +145,7 @@ export default class LapTimerService { const s = this.activeSession(); if (!s || !s.isRunning) return; this.mutateActive((next) => { - const slice = next.currentLapStartEpochMs !== null ? this.now() - next.currentLapStartEpochMs : 0; + const slice = next.currentLapStartEpochMs !== null ? Date.now() - next.currentLapStartEpochMs : 0; next.currentLapAccumulatedMs += slice; next.currentLapStartEpochMs = null; next.isRunning = false; @@ -166,7 +160,7 @@ export default class LapTimerService { this.mutateActive((next) => { next.isRunning = true; next.isPaused = false; - next.currentLapStartEpochMs = this.now(); + next.currentLapStartEpochMs = Date.now(); }); if (this.telemetrySubs.length === 0) this.subscribeTelemetry(); this.startTickLoop(); @@ -176,7 +170,7 @@ export default class LapTimerService { lap(): void { const session = this.activeSession(); if (!session || !session.isRunning || session.currentLapStartEpochMs === null) return; - const endEpochMs = this.now(); + const endEpochMs = Date.now(); const durationMs = session.currentLapAccumulatedMs + (endEpochMs - session.currentLapStartEpochMs); if (durationMs === 0) return; @@ -224,10 +218,10 @@ export default class LapTimerService { if (this.isRunning()) this.pause(); this.unsubscribeTelemetry(); - const startEpochMs = this.now(); + const startEpochMs = Date.now(); const runId = this.storage.getCurrentRunId().getValue() ?? null; const newSession: LapSession = { - id: generateId(), + id: uuidv4(), name: name?.trim() || defaultSessionName(startEpochMs, runId), sessionStartEpochMs: startEpochMs, runIdAtSessionStart: runId, @@ -465,7 +459,7 @@ function hydrate(): LapStore { try { localStorage.removeItem(LAP_STORE_STORAGE_KEY); } catch { - /* ignore */ + // localStorage unavailable (e.g. private mode); nothing to recover. } return emptyLapStore(); } @@ -478,11 +472,3 @@ function cloneStore(s: LapStore): LapStore { sessions: s.sessions.map((sess) => ({ ...sess, laps: [...sess.laps] })) }; } - -function generateId(): string { - // Fallback for runners pre-dating crypto.randomUUID. - const c: Crypto | undefined = typeof crypto !== 'undefined' ? crypto : undefined; - if (c && typeof c.randomUUID === 'function') return c.randomUUID(); - const r = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); - return `${r()}${r()}-${r()}-${r()}-${r()}-${r()}${r()}${r()}`; -} diff --git a/angular-client/src/utils/bms.utils.ts b/angular-client/src/utils/bms.utils.ts index 50e2a4f3..7aedc4f2 100644 --- a/angular-client/src/utils/bms.utils.ts +++ b/angular-client/src/utils/bms.utils.ts @@ -37,40 +37,19 @@ export type SegmentInfo = { totalVoltageKey: string; }; -/** Lazy: avoids `bms.utils` ↔ `topic.utils` cycle in karma. */ -let _segmentInfoMap: Record | null = null; -const buildSegmentInfoMap = (): Record => - Object.fromEntries( - allSegments.map((seg) => [ - seg, - { - segmentTempKey: topics.segmentTemp(seg), - alphaChipTempKey: topics.dieTemp(seg, Chip.Alpha), - betaChipTempKey: topics.dieTemp(seg, Chip.Beta), - voltageKey: topics.segmentVoltage(seg), - totalVoltageKey: topics.segmentTotalVoltage(seg) - } - ]) - ); - -export const segmentInfoMap: Record = new Proxy({} as Record, { - get(_target, prop) { - if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); - return _segmentInfoMap[prop as unknown as Segment]; - }, - has(_target, prop) { - if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); - return prop in _segmentInfoMap; - }, - ownKeys() { - if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); - return Reflect.ownKeys(_segmentInfoMap); - }, - getOwnPropertyDescriptor(_target, prop) { - if (!_segmentInfoMap) _segmentInfoMap = buildSegmentInfoMap(); - return Object.getOwnPropertyDescriptor(_segmentInfoMap, prop); - } -}); +/** Dynamically generated map of segment index → SegmentInfo topic keys. */ +export const segmentInfoMap: Record = Object.fromEntries( + allSegments.map((seg) => [ + seg, + { + segmentTempKey: topics.segmentTemp(seg), + alphaChipTempKey: topics.dieTemp(seg, Chip.Alpha), + betaChipTempKey: topics.dieTemp(seg, Chip.Beta), + voltageKey: topics.segmentVoltage(seg), + totalVoltageKey: topics.segmentTotalVoltage(seg) + } + ]) +); export const getConnectionDotStatusColor = (voltage: number): string => { if (voltage <= 375) { diff --git a/angular-client/tsconfig.spec.json b/angular-client/tsconfig.spec.json index c2f398b4..47e3dd75 100644 --- a/angular-client/tsconfig.spec.json +++ b/angular-client/tsconfig.spec.json @@ -3,7 +3,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine", "node"] + "types": ["jasmine"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] }