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

+3 -3
View File
@@ -124,7 +124,7 @@
@if (entry.externalLink) {
<div class="mt-[0.1rem] opacity-85">
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">open_in_new</mat-icon>
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
</a>
</div>
@@ -132,7 +132,7 @@
<div class="mt-[0.1rem] opacity-85">
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
rel="noopener noreferrer">
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">link</mat-icon>
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">link</mat-icon>
{{ (entry.key + '.LINK_INTERNAL') | translate }}
</a>
</div>
@@ -169,7 +169,7 @@
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
{
<mat-divider class="!my-2"></mat-divider>
<mat-divider class="my-2!"></mat-divider>
}
}
</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { TranslateModule } from '@ngx-translate/core';
import { TranslatePipe } from '@ngx-translate/core';
import {UrlConstants} from '../../constants/UrlConstants';
import {AssetsConstants} from '../../constants/AssetsConstants';
import {RouterLink} from '@angular/router';
@@ -21,7 +21,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
MatIconModule,
MatButtonModule,
MatDividerModule,
TranslateModule,
TranslatePipe,
RouterLink
],
templateUrl: './about.component.html',
@@ -5,8 +5,8 @@
@for (category of categories; track category.id) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
<mat-card-content>
<div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
<mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
<div class="flex items-center text-(--mat-sys-primary) mb-4">
<mat-icon class="text-[26px]! w-[26px]! h-[26px]!">{{ category.icon }}</mat-icon>
</div>
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
@@ -47,7 +47,7 @@
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div>
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
[ngClass]="{
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
@@ -5,7 +5,7 @@ import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
@@ -25,7 +25,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
MatCardModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
TranslatePipe,
Information
],
templateUrl: './four-color.component.html',
@@ -18,7 +18,7 @@
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
@@ -8,7 +8,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
import {PathfindingService} from './service/pathfinding.service';
@@ -35,7 +35,7 @@ enum NodeType {
MatButtonToggleModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
TranslatePipe,
MatCard,
MatCardHeader,
MatCardTitle,
@@ -49,7 +49,7 @@
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
@for (item of sortArray; track $index) {
<div
class="grow w-2.5 min-w-px transition-all duration-[50ms]"
class="grow w-2.5 min-w-px transition-all duration-50"
[style.height.px]="item.value * 3"
[ngClass]="{
'bg-[#424242]': item.state === 'unsorted',
@@ -5,7 +5,7 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select";
import {MatButtonModule} from "@angular/material/button";
import {MatIconModule} from "@angular/material/icon";
import {TranslateModule} from "@ngx-translate/core";
import {TranslatePipe} from "@ngx-translate/core";
import { SortingService } from './service/sorting.service';
import { SortingAudioService } from './service/sorting-audio.service';
import {SortData, SortSnapshot} from './sorting.models';
@@ -16,7 +16,7 @@ import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
@Component({
selector: 'app-sorting',
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
templateUrl: './sorting.component.html',
styleUrl: './sorting.component.scss'
})
@@ -13,9 +13,9 @@
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
<mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
</div>
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@@ -25,9 +25,9 @@
</ul>
</div>
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
<mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
</div>
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@@ -46,7 +46,7 @@
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
@for (img of project.images; track img) {
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
<img class="w-full h-auto !max-h-[clamp(300px,60vh,512px)] object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
@if (img.source) {
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
{{ img.source }}
@@ -1,6 +1,6 @@
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
@if (featuredProject(); as project) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
@@ -27,7 +27,7 @@
}
@for (project of otherProjects(); track project) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header>
@@ -7,7 +7,7 @@
<div
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
[class]="flashing()
? 'scale-110 !text-[var(--mat-sys-primary)] !ring-[var(--mat-sys-primary)] shadow-[0_0_40px_var(--mat-sys-primary)]'
? 'scale-110 text-(--mat-sys-primary)! ring-(--mat-sys-primary)! shadow-[0_0_40px_var(--mat-sys-primary)]'
: ''"
>
{{ display() }}
@@ -37,6 +37,8 @@
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
</mat-checkbox>
<p class="m-0 font-medium text-sm">{{ 'STOPWATCH.FIRST_INTERVAL' | translate }}</p>
<div class="flex flex-wrap gap-3 items-center justify-center">
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
@@ -46,8 +48,8 @@
min="0"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalMinutes()"
(ngModelChange)="intervalMinutes.set($event)"
[ngModel]="firstIntervalMinutes()"
(ngModelChange)="updateIntervalValue(firstIntervalMinutes, $event)"
/>
</mat-form-field>
@@ -60,15 +62,60 @@
max="59"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalSeconds()"
(ngModelChange)="intervalSeconds.set($event)"
[ngModel]="firstIntervalSeconds()"
(ngModelChange)="updateIntervalValue(firstIntervalSeconds, $event)"
/>
</mat-form-field>
</div>
<mat-checkbox
[checked]="secondIntervalEnabled()"
[disabled]="!intervalEnabled()"
(change)="secondIntervalEnabled.set($event.checked)"
>
{{ 'STOPWATCH.SECOND_INTERVAL_ENABLED' | translate }}
</mat-checkbox>
@if (secondIntervalEnabled()) {
<div class="flex flex-wrap gap-3 items-center justify-center">
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="secondIntervalMinutes()"
(ngModelChange)="updateIntervalValue(secondIntervalMinutes, $event)"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
max="59"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="secondIntervalSeconds()"
(ngModelChange)="updateIntervalValue(secondIntervalSeconds, $event)"
/>
</mat-form-field>
</div>
}
<p class="m-0 opacity-70 text-sm text-center">
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
</p>
@if (intervalEnabled() && schedulePreview()) {
<p class="m-0 opacity-70 text-sm text-center">
{{ 'STOPWATCH.INTERVAL_SCHEDULE' | translate }} {{ schedulePreview() }} …
</p>
}
</div>
</div>
</mat-card-content>
@@ -0,0 +1,39 @@
import {collectBeepTimes, countBeeps, slotOfBeep} from './stopwatch.component';
const MINUTE = 60_000;
describe('stopwatch interval scheduling', () => {
const firstMs = 1 * MINUTE;
const secondMs = 2 * MINUTE;
it('counts no beeps without a configured first interval', () => {
expect(countBeeps(10 * MINUTE, 0, secondMs)).toBe(0);
});
it('falls back to a single repeating interval when the second one is off', () => {
expect(countBeeps(0.9 * MINUTE, firstMs, 0)).toBe(0);
expect(countBeeps(1 * MINUTE, firstMs, 0)).toBe(1);
expect(countBeeps(3 * MINUTE, firstMs, 0)).toBe(3);
});
it('alternates both intervals so beeps fall on 1, 3, 4, 6, 7 minutes', () => {
const expectedBeepCountPerMinute = [0, 1, 1, 2, 3, 3, 4, 5, 5, 6];
expectedBeepCountPerMinute.forEach((expected, minute) => {
expect(countBeeps(minute * MINUTE, firstMs, secondMs)).toBe(expected);
});
});
it('reports the alternating beep times', () => {
const times = collectBeepTimes(firstMs, secondMs, 5);
expect(times).toEqual([1 * MINUTE, 3 * MINUTE, 4 * MINUTE, 6 * MINUTE, 7 * MINUTE]);
});
it('assigns odd beeps to the first interval and even ones to the second', () => {
expect(slotOfBeep(1, secondMs)).toBe('first');
expect(slotOfBeep(2, secondMs)).toBe('second');
expect(slotOfBeep(3, secondMs)).toBe('first');
expect(slotOfBeep(2, 0)).toBe('first');
});
});
+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);