From 21452c896bb37ab45fe6cba4b1be05f1878e71ba Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 23 Mar 2026 21:34:14 -0400 Subject: [PATCH 1/5] #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/5] #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/5] #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/5] #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/5] #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,