From b598779c60e13507b6e1ef3860990985c99d3639 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 14 May 2026 13:30:43 +0200 Subject: [PATCH] improve: best practises --- src/app/app.component.ts | 2 +- src/app/footer/footer.component.ts | 2 +- src/app/home/home.component.html | 2 +- src/app/home/home.component.spec.ts | 18 +++++----- src/app/home/home.component.ts | 53 +++++++++++++++++----------- src/app/imprint/imprint.component.ts | 9 ++--- src/app/services/language.service.ts | 52 +++++++++------------------ src/languages/de.ts | 12 +++---- src/languages/en.ts | 12 +++---- src/languages/language.types.ts | 2 +- 10 files changed, 76 insertions(+), 88 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 10d1994..1769d74 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -25,7 +25,7 @@ export class AppComponent implements OnInit, OnDestroy { private readonly document = inject(DOCUMENT); private readonly languageService = inject(LanguageService); - protected readonly content = this.languageService.content; + protected readonly content = this.languageService.shellContent; protected readonly currentLanguageCode = this.languageService.languageCode; protected readonly isLanguageConfirmed = this.languageService.isLanguageConfirmed; protected readonly isLanguageSelectorOpen = signal(false); diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index de34e47..b105ff7 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -13,7 +13,7 @@ import { LanguageService } from '../services/language.service'; export class FooterComponent { private readonly languageService = inject(LanguageService); - protected readonly content = this.languageService.content; + protected readonly content = this.languageService.shellContent; protected readonly global = global; protected readonly currentYear = new Date().getFullYear(); } diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 553944e..55f146b 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -124,7 +124,7 @@ @if (project.icon) { { 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(); })); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 12c912e..0607a33 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -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(() => - 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; diff --git a/src/app/imprint/imprint.component.ts b/src/app/imprint/imprint.component.ts index b9a9795..70b6f8b 100644 --- a/src/app/imprint/imprint.component.ts +++ b/src/app/imprint/imprint.component.ts @@ -1,13 +1,10 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import type { IconDefinition } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { deLanguage } from '../../languages/de'; -import { enLanguage } from '../../languages/en'; import { global } from '../../global'; -import type { LanguagePack } from '../../languages/language.types'; import { FooterComponent } from '../footer/footer.component'; import { LanguageService } from '../services/language.service'; @@ -21,9 +18,7 @@ import { LanguageService } from '../services/language.service'; export class ImprintComponent { private readonly languageService = inject(LanguageService); - protected readonly content = computed(() => - this.languageService.languageCode() === 'de' ? deLanguage : enLanguage, - ); + protected readonly content = this.languageService.content; protected readonly global = global; protected readonly faArrowLeft: IconDefinition = faArrowLeft; protected readonly contactMailHref = `mailto:${global.contactMail}`; diff --git a/src/app/services/language.service.ts b/src/app/services/language.service.ts index d39ba0a..e134baf 100644 --- a/src/app/services/language.service.ts +++ b/src/app/services/language.service.ts @@ -1,36 +1,8 @@ import { Injectable, computed, signal } from '@angular/core'; -import type { LanguageCode, LanguageShellPack } from '../../languages/language.types'; - -const enShellLanguage: LanguageShellPack = { - app: { - selectorTitle: 'Select language', - changeLanguage: 'Language', - changeLanguageAriaLabel: 'Change language', - closeSelector: 'Close language selection', - languageEnglish: 'English', - languageGerman: 'German', - }, - footer: { - noscriptMessage: 'Please enable JavaScript for the full experience.', - imprintLink: 'Imprint', - }, -}; - -const deShellLanguage: LanguageShellPack = { - app: { - selectorTitle: 'Sprache wählen', - changeLanguage: 'Sprache', - changeLanguageAriaLabel: 'Sprache wechseln', - closeSelector: 'Sprachauswahl schliessen', - languageEnglish: 'Englisch', - languageGerman: 'Deutsch', - }, - footer: { - noscriptMessage: 'Bitte aktiviere JavaScript für die volle Darstellung.', - imprintLink: 'Impressum', - }, -}; +import { deLanguage } from '../../languages/de'; +import { enLanguage } from '../../languages/en'; +import type { LanguageCode, LanguagePack, LanguageShellPack } from '../../languages/language.types'; @Injectable({ providedIn: 'root' }) export class LanguageService { @@ -40,9 +12,8 @@ export class LanguageService { readonly languageCode = this.currentLanguageCode.asReadonly(); readonly isLanguageConfirmed = this.languageConfirmed.asReadonly(); - readonly content = computed(() => - this.getShellPack(this.currentLanguageCode()), - ); + readonly content = computed(() => this.getPack(this.currentLanguageCode())); + readonly shellContent = computed(() => this.toShellPack(this.content())); initializeFromStorage(): LanguageCode { if (typeof window === 'undefined') { @@ -67,6 +38,17 @@ export class LanguageService { } getShellPack(code: LanguageCode): LanguageShellPack { - return code === 'de' ? deShellLanguage : enShellLanguage; + return this.toShellPack(this.getPack(code)); + } + + getPack(code: LanguageCode): LanguagePack { + return code === 'de' ? deLanguage : enLanguage; + } + + private toShellPack(pack: LanguagePack): LanguageShellPack { + return { + app: pack.app, + footer: pack.footer, + }; } } diff --git a/src/languages/de.ts b/src/languages/de.ts index f83ef42..8cdddfe 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -188,7 +188,7 @@ export const deLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: false, + circleIcon: false, }, { title: 'SynHost', @@ -202,7 +202,7 @@ export const deLanguage: LanguagePack = { iconSizes: '(max-width: 700px) 64px, 108px', iconWidth: 108, iconHeight: 64, - CircleIcon: false, + circleIcon: false, }, { title: 'SynRadio', @@ -214,7 +214,7 @@ export const deLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, { title: 'Discord Audio Stream Library', @@ -226,7 +226,7 @@ export const deLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, { title: 'Portfolio', @@ -240,7 +240,7 @@ export const deLanguage: LanguagePack = { iconSizes: '(max-width: 700px) 64px, 108px', iconWidth: 320, iconHeight: 385, - CircleIcon: true, + circleIcon: true, }, { title: 'Tauchertreff-Mostviertel', @@ -252,7 +252,7 @@ export const deLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, ], }; diff --git a/src/languages/en.ts b/src/languages/en.ts index be584fd..7f78426 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -188,7 +188,7 @@ export const enLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: false, + circleIcon: false, }, { title: 'SynHost', @@ -202,7 +202,7 @@ export const enLanguage: LanguagePack = { iconSizes: '(max-width: 700px) 64px, 108px', iconWidth: 108, iconHeight: 64, - CircleIcon: false, + circleIcon: false, }, { title: 'SynRadio', @@ -214,7 +214,7 @@ export const enLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, { title: 'Discord Audio Stream Library', @@ -226,7 +226,7 @@ export const enLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, { title: 'Portfolio', @@ -240,7 +240,7 @@ export const enLanguage: LanguagePack = { iconSizes: '(max-width: 700px) 64px, 108px', iconWidth: 320, iconHeight: 385, - CircleIcon: true, + circleIcon: true, }, { title: 'Tauchertreff-Mostviertel', @@ -252,7 +252,7 @@ export const enLanguage: LanguagePack = { iconSrcset: 'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w', iconSizes: '(max-width: 700px) 64px, 108px', - CircleIcon: true, + circleIcon: true, }, ], }; diff --git a/src/languages/language.types.ts b/src/languages/language.types.ts index 65a4c39..3b0066d 100644 --- a/src/languages/language.types.ts +++ b/src/languages/language.types.ts @@ -10,7 +10,7 @@ export interface LanguageProject { iconSizes?: string; iconWidth?: number; iconHeight?: number; - CircleIcon?: boolean; + circleIcon?: boolean; } export interface LanguageBioTextEntry {