improve: best practises

This commit is contained in:
2026-05-14 13:30:43 +02:00
parent 1ce29e47a5
commit b598779c60
10 changed files with 76 additions and 88 deletions
+1 -1
View File
@@ -124,7 +124,7 @@
@if (project.icon) {
<img
class="project-icon"
[class.project-icon-circle]="project.CircleIcon !== false"
[class.project-icon-circle]="project.circleIcon !== false"
[ngSrc]="project.icon"
[attr.srcset]="project.iconSrcset ?? null"
[attr.sizes]="project.iconSizes ?? null"
+9 -9
View File
@@ -4,7 +4,6 @@ import { provideRouter } from '@angular/router';
import { IMAGE_LOADER } from '@angular/common';
import type { ImageLoaderConfig } from '@angular/common';
import { NgZone } from '@angular/core';
import type { Subscription } from 'rxjs';
import { HomeComponent } from './home.component';
import { global } from '../../global';
@@ -25,7 +24,6 @@ describe('HomeComponent', (): void => {
isLongBioMounted: boolean;
isPortraitSwitching: boolean;
currentPortraitHighlightIndex: number;
sub: Subscription;
bioHideTimeoutId: number | null;
scrollAnimationFrameId: number | null;
projectEntryAnimationFrameId: number | null;
@@ -113,14 +111,13 @@ describe('HomeComponent', (): void => {
});
});
describe('interval-driven bio cycling (subscription)', (): void => {
describe('interval-driven bio cycling', (): void => {
it('should start with currentIndex 0', (): void => {
expect(comp.currentIndex).toBe(0);
});
it('should be backed by an active RxJS Subscription', (): void => {
expect(comp.sub).toBeDefined();
expect(comp.sub.closed).toBeFalse();
it('should expose a valid bio entry for the current index', (): void => {
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
@@ -352,10 +349,13 @@ describe('HomeComponent', (): void => {
// ── Lifecycle cleanup ──────────────────────────────────────────────────────
describe('ngOnDestroy', (): void => {
it('should unsubscribe the interval subscription on destroy', fakeAsync((): void => {
expect(comp.sub.closed).toBeFalse();
it('should clear pending portrait switch timers on destroy', fakeAsync((): void => {
comp.showNextPortraitHighlight();
const clearSpy = spyOn(window, 'clearTimeout').and.callThrough();
fixture.destroy();
expect(comp.sub.closed).toBeTrue();
expect(clearSpy).toHaveBeenCalled();
discardPeriodicTasks();
}));
+32 -21
View File
@@ -2,7 +2,7 @@ import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
NgZone,
signal,
@@ -12,15 +12,12 @@ import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/fre
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
LanguagePack,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
@@ -35,11 +32,10 @@ import { LanguageService } from '../services/language.service';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = computed<LanguagePack>(() =>
this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
);
protected readonly content = this.languageService.content;
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
@@ -73,18 +69,18 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope;
protected readonly faPhone: IconDefinition = faPhone;
readonly faDiscord: IconDefinition = faDiscord;
readonly faGithub: IconDefinition = faGithub;
readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly faDiscord: IconDefinition = faDiscord;
protected readonly faGithub: IconDefinition = faGithub;
protected readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' };
private readonly isLongBioShownState = signal(false);
private readonly isLongBioMountedState = signal(false);
private readonly currentPortraitHighlightIndexState = signal(0);
private readonly isPortraitSwitchingState = signal(false);
private readonly currentIndexState = signal(0);
private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
private portraitSwitchTimeoutId: number | null = null;
private portraitSwitchDoneTimeoutId: number | null = null;
protected get isLongBioShown(): boolean {
return this.isLongBioShownState();
@@ -143,12 +139,14 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
const len = this.content().bioTextsList.length;
this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((): void => {
this.zone.run((): void => {
const len = this.content().bioTextsList.length;
this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
});
});
});
});
}
@@ -157,11 +155,11 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.clearPortraitSwitchTimeouts();
this.destroyNameGradientScrollAnimation();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
@@ -199,12 +197,14 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
this.isPortraitSwitching = true;
setTimeout((): void => {
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
this.portraitSwitchTimeoutId = null;
}, 125);
setTimeout((): void => {
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
this.isPortraitSwitching = false;
this.portraitSwitchDoneTimeoutId = null;
}, 250);
}
protected scrollToAboutSection(): void {
@@ -281,6 +281,17 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
}
document.documentElement.style.removeProperty('--name-pride-progress');
}
private clearPortraitSwitchTimeouts(): void {
if (typeof window === 'undefined') return;
if (this.portraitSwitchTimeoutId !== null) {
window.clearTimeout(this.portraitSwitchTimeoutId);
this.portraitSwitchTimeoutId = null;
}
if (this.portraitSwitchDoneTimeoutId !== null) {
window.clearTimeout(this.portraitSwitchDoneTimeoutId);
this.portraitSwitchDoneTimeoutId = null;
}
}
private updateNameGradientProgress(): void {
if (typeof window === 'undefined') return;
const root: HTMLElement = document.documentElement;