Upgraded dependencies and extended stopwatch
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled

This commit is contained in:
Andreas Dahm committed 2026-08-20 12:00:39 +02:00
1 parent 3f1093e3c8
commit f543a5e2f3
31 files changed
+2829 -2551

No files matched your search

+105 -15
View File
@@ -1,4 +1,4 @@
import {Component, computed, effect, inject, OnDestroy, signal} from '@angular/core';
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
@@ -7,10 +7,16 @@ import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {Title} from '@angular/platform-browser';
import {TranslateModule} from '@ngx-translate/core';
import {TranslatePipe} from '@ngx-translate/core';
type StopwatchState = 'idle' | 'running' | 'paused';
/** Which of the two configured intervals a beep belongs to. */
type IntervalSlot = 'first' | 'second';
const FIRST_INTERVAL_FREQUENCY_HZ = 880;
const SECOND_INTERVAL_FREQUENCY_HZ = 587;
@Component({
selector: 'app-stopwatch',
standalone: true,
@@ -22,7 +28,7 @@ type StopwatchState = 'idle' | 'running' | 'paused';
MatIconModule,
MatInputModule,
MatCheckboxModule,
TranslateModule
TranslatePipe
],
templateUrl: './stopwatch.component.html'
})
@@ -31,13 +37,34 @@ export class StopwatchComponent implements OnDestroy {
readonly elapsedMs = signal(0);
readonly intervalEnabled = signal(false);
readonly intervalMinutes = signal(2);
readonly intervalSeconds = signal(0);
readonly firstIntervalMinutes = signal(1);
readonly firstIntervalSeconds = signal(0);
readonly secondIntervalEnabled = signal(false);
readonly secondIntervalMinutes = signal(2);
readonly secondIntervalSeconds = signal(0);
readonly flashing = signal(false);
readonly display = computed(() => formatElapsed(this.elapsedMs()));
readonly firstIntervalMs = computed(
() => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
);
readonly secondIntervalMs = computed(() => {
if (!this.secondIntervalEnabled()) {
return 0;
}
return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
});
/** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
readonly schedulePreview = computed(() => {
const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
return beepTimes.map(formatElapsedShort).join(' · ');
});
private intervalId: number | null = null;
private startTimestamp = 0;
private accumulatedMs = 0;
@@ -88,6 +115,11 @@ export class StopwatchComponent implements OnDestroy {
this.stop();
}
/** Number inputs can emit empty strings or negative values, so sanitize before storing. */
updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
target.set(toNonNegativeInteger(value));
}
ngOnDestroy(): void {
this.cancelTick();
if (this.flashTimeoutId !== null) {
@@ -140,16 +172,18 @@ export class StopwatchComponent implements OnDestroy {
if (!this.intervalEnabled()) {
return;
}
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
if (intervalMs <= 0) {
const firstMs = this.firstIntervalMs();
const secondMs = this.secondIntervalMs();
const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
const currentBeeps = countBeeps(currMs, firstMs, secondMs);
if (currentBeeps <= previousBeeps) {
return;
}
const prevTick = Math.floor(prevMs / intervalMs);
const currTick = Math.floor(currMs / intervalMs);
if (currTick > prevTick) {
this.beep();
this.triggerFlash();
}
const slot = slotOfBeep(currentBeeps, secondMs);
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
this.triggerFlash();
}
private triggerFlash(): void {
@@ -180,7 +214,7 @@ export class StopwatchComponent implements OnDestroy {
}
}
private beep(): void {
private beep(frequencyHz: number): void {
if (!this.audioContext) {
return;
}
@@ -189,7 +223,7 @@ export class StopwatchComponent implements OnDestroy {
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = 880;
oscillator.frequency.value = frequencyHz;
const now = ctx.currentTime;
const duration = 0.18;
@@ -204,6 +238,62 @@ export class StopwatchComponent implements OnDestroy {
}
}
function toMilliseconds(minutes: number, seconds: number): number {
return minutes * 60_000 + seconds * 1_000;
}
function toNonNegativeInteger(value: unknown): number {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
/**
* The two intervals alternate: the first beep lands after `firstMs`, the next one
* `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
* beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
*/
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
if (firstMs <= 0) {
return 0;
}
if (secondMs <= 0) {
return Math.floor(elapsedMs / firstMs);
}
const cycleMs = firstMs + secondMs;
const completedCycles = Math.floor(elapsedMs / cycleMs);
const remainderMs = elapsedMs % cycleMs;
const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;
return completedCycles * 2 + firstBeepOfCycleReached;
}
/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
if (secondMs <= 0) {
return 'first';
}
return beepNumber % 2 === 1 ? 'first' : 'second';
}
export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
if (firstMs <= 0) {
return [];
}
const times: number[] = [];
let current = 0;
for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
current += isFirstSlot ? firstMs : secondMs;
times.push(current);
}
return times;
}
function formatElapsed(totalMs: number): string {
const ms = Math.floor(totalMs % 1000);
const totalSeconds = Math.floor(totalMs / 1000);