Added timer component
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped

This commit is contained in:
Andreas Dahm committed 2026-05-06 10:46:57 +02:00
1 parent b47bce16b5
commit 39b90a0b81
7 files changed
+296

No files matched your search

@@ -0,0 +1,186 @@
import {Component, computed, OnDestroy, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {TranslateModule} from '@ngx-translate/core';
type StopwatchState = 'idle' | 'running' | 'paused';
@Component({
selector: 'app-stopwatch',
standalone: true,
imports: [
FormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatCheckboxModule,
TranslateModule
],
templateUrl: './stopwatch.component.html'
})
export class StopwatchComponent implements OnDestroy {
readonly state = signal<StopwatchState>('idle');
readonly elapsedMs = signal(0);
readonly intervalEnabled = signal(false);
readonly intervalMinutes = signal(2);
readonly intervalSeconds = signal(0);
readonly flashing = signal(false);
readonly display = computed(() => formatElapsed(this.elapsedMs()));
private rafId: number | null = null;
private startTimestamp = 0;
private accumulatedMs = 0;
private previousElapsedMs = 0;
private audioContext: AudioContext | null = null;
private flashTimeoutId: number | null = null;
start(): void {
if (this.state() === 'running') {
return;
}
this.ensureAudioContext();
this.startTimestamp = performance.now();
this.previousElapsedMs = this.accumulatedMs;
this.state.set('running');
this.tick();
}
pause(): void {
if (this.state() !== 'running') {
return;
}
this.accumulatedMs = this.elapsedMs();
this.cancelTick();
this.state.set('paused');
}
stop(): void {
this.cancelTick();
this.accumulatedMs = 0;
this.previousElapsedMs = 0;
this.elapsedMs.set(0);
this.state.set('idle');
}
reset(): void {
this.stop();
}
ngOnDestroy(): void {
this.cancelTick();
if (this.flashTimeoutId !== null) {
clearTimeout(this.flashTimeoutId);
this.flashTimeoutId = null;
}
if (this.audioContext) {
this.audioContext.close().catch(() => undefined);
this.audioContext = null;
}
}
private tick = (): void => {
if (this.state() !== 'running') {
return;
}
const now = performance.now();
const newElapsed = this.accumulatedMs + (now - this.startTimestamp);
this.checkIntervalCrossing(this.previousElapsedMs, newElapsed);
this.previousElapsedMs = newElapsed;
this.elapsedMs.set(newElapsed);
this.rafId = requestAnimationFrame(this.tick);
};
private cancelTick(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
private checkIntervalCrossing(prevMs: number, currMs: number): void {
if (!this.intervalEnabled()) {
return;
}
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
if (intervalMs <= 0) {
return;
}
const prevTick = Math.floor(prevMs / intervalMs);
const currTick = Math.floor(currMs / intervalMs);
if (currTick > prevTick) {
this.beep();
this.triggerFlash();
}
}
private triggerFlash(): void {
if (this.flashTimeoutId !== null) {
clearTimeout(this.flashTimeoutId);
}
this.flashing.set(true);
this.flashTimeoutId = window.setTimeout(() => {
this.flashing.set(false);
this.flashTimeoutId = null;
}, 450);
}
private ensureAudioContext(): void {
if (this.audioContext) {
return;
}
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctor) {
this.audioContext = new Ctor();
}
}
private beep(): void {
if (!this.audioContext) {
return;
}
const ctx = this.audioContext;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = 880;
const now = ctx.currentTime;
const duration = 0.18;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.25, now + 0.01);
gain.gain.linearRampToValueAtTime(0, now + duration);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(now);
oscillator.stop(now + duration);
}
}
function formatElapsed(totalMs: number): string {
const ms = Math.floor(totalMs % 1000);
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`;
}
function pad(value: number, length: number): string {
return value.toString().padStart(length, '0');
}