diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts
new file mode 100644
index 0000000..820e8ef
--- /dev/null
+++ b/src/app/app-settings.spec.ts
@@ -0,0 +1,221 @@
+import { provideZonelessChangeDetection } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { HarnessLoader, TestKey } from '@angular/cdk/testing';
+import { MatButtonHarness } from '@angular/material/button/testing';
+import { MatOptionHarness } from '@angular/material/core/testing';
+import { MatDialogHarness } from '@angular/material/dialog/testing';
+import { MatSelectHarness } from '@angular/material/select/testing';
+import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
+import { vi } from 'vitest';
+import { App } from './app';
+import { UserInputService } from './services/user-input-service';
+
+describe('Application Settings', () => {
+ let fixture: ComponentFixture;
+ let loader: HarnessLoader;
+ let opener: HTMLButtonElement;
+ let mediaQuery: EventTarget & { matches: boolean; media: string };
+
+ beforeEach(async () => {
+ localStorage.clear();
+ localStorage.setItem('app.language', JSON.stringify('en'));
+ mediaQuery = Object.assign(new EventTarget(), {
+ matches: true,
+ media: '(prefers-color-scheme: light)',
+ });
+ const otherMedia = window.matchMedia('(max-width: 0px)');
+ vi.spyOn(window, 'matchMedia').mockImplementation((query) =>
+ query === mediaQuery.media
+ ? (mediaQuery as unknown as MediaQueryList)
+ : { ...otherMedia, media: query },
+ );
+ await createApp();
+ });
+
+ async function createApp(): Promise {
+ await TestBed.configureTestingModule({
+ imports: [App],
+ providers: [provideZonelessChangeDetection()],
+ }).compileComponents();
+ TestBed.inject(UserInputService).vacationNumberSignal.set([]);
+ fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+ }
+
+ afterEach(() => {
+ fixture?.destroy();
+ vi.restoreAllMocks();
+ localStorage.clear();
+ document.body.classList.remove('light-mode', 'dark-mode');
+ });
+
+ async function openSettings(): Promise {
+ const root = fixture.nativeElement as HTMLElement;
+ opener = root.querySelector(
+ 'header.app-header button[aria-haspopup="dialog"]',
+ )!;
+ expect(opener).toBeDefined();
+ opener.focus();
+ opener.click();
+ return loader.getHarness(MatDialogHarness);
+ }
+
+ async function selectOption(select: MatSelectHarness, text: string): Promise {
+ await select.open();
+ for (const option of await loader.getAllHarnesses(MatOptionHarness)) {
+ if ((await option.getText()) === text) {
+ await option.click();
+ await expect.poll(() => document.querySelector('[role="listbox"]')).toBeNull();
+ return;
+ }
+ }
+ throw new Error(`No option labelled ${text}`);
+ }
+
+ function changeSystemAppearance(isLight: boolean): void {
+ mediaQuery.matches = isLight;
+ mediaQuery.dispatchEvent(Object.assign(new Event('change'), { matches: isLight }));
+ fixture.detectChanges();
+ }
+
+ it('opens one labelled Settings dialog from the header and keeps planner inputs in place', async () => {
+ const dialog = await openSettings();
+ expect(await loader.getAllHarnesses(MatDialogHarness)).toHaveLength(1);
+ expect(await dialog.getTitleText()).toBe('Settings');
+ expect(await dialog.getRole()).toBe('dialog');
+ const titleId = await dialog.getAriaLabelledby();
+ expect(document.getElementById(titleId!)?.textContent?.trim()).toBe('Settings');
+ const selects = await dialog.getAllHarnesses(MatSelectHarness);
+ expect(selects).toHaveLength(2);
+ expect(await selects[0].getLabel()).toBe('Theme');
+ expect(await selects[1].getLabel()).toBe('Language');
+ expect(await selects[0].getValueText()).toBe('System');
+ expect(await selects[1].getValueText()).toBe('English');
+ expect(await dialog.getHarness(MatSlideToggleHarness)).toBeDefined();
+ expect(
+ fixture.nativeElement.querySelector('app-calendar-year app-calendar-settings'),
+ ).toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('app-user-input app-rules-panel'),
+ ).not.toBeNull();
+ expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1);
+ });
+
+ it('supports keyboard theme selection and moves focus into the dialog', async () => {
+ const dialog = await openSettings();
+ const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
+ await expect
+ .poll(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement))
+ .toBe(true);
+ await theme.focus();
+ expect(await theme.isFocused()).toBe(true);
+ expect(document.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(
+ true,
+ );
+ const control = await theme.host();
+ await control.sendKeys(TestKey.ENTER);
+ expect(await theme.isOpen()).toBe(true);
+ await control.sendKeys(TestKey.END, TestKey.ENTER);
+ expect(await theme.getValueText()).toBe('Dark');
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(localStorage.getItem('theme.preference')).toBe('"dark"');
+ });
+
+ it('follows live System appearance and keeps explicit theme selections fixed', async () => {
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ const dialog = await openSettings();
+ const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
+ expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Light/);
+ expect(localStorage.getItem('theme.preference')).toBeNull();
+
+ changeSystemAppearance(false);
+ expect(await theme.getValueText()).toBe('System');
+ expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Dark/);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+ expect(localStorage.getItem('theme.preference')).toBeNull();
+
+ await selectOption(theme, 'Light');
+ changeSystemAppearance(true);
+ changeSystemAppearance(false);
+ expect(await theme.getValueText()).toBe('Light');
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+ await selectOption(theme, 'Dark');
+ changeSystemAppearance(true);
+ expect(await theme.getValueText()).toBe('Dark');
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+
+ await selectOption(theme, 'System');
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ changeSystemAppearance(false);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(localStorage.getItem('theme.preference')).toBe('"system"');
+ });
+
+ it.each(['Close', 'Escape'])(
+ 'applies and saves selections immediately, retaining them after %s',
+ async (closing) => {
+ const dialog = await openSettings();
+ const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
+ await selectOption(theme, 'Dark');
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(localStorage.getItem('theme.preference')).toBe('"dark"');
+ await selectOption(language, 'Français');
+ expect(localStorage.getItem('app.language')).toBe('"fr"');
+ expect(await dialog.getTitleText()).toBe('Paramètres');
+ expect(await theme.getValueText()).toBe('Sombre');
+ expect(fixture.nativeElement.querySelector('header.app-header p').textContent).toBe(
+ 'Faites plus de place aux vacances.',
+ );
+ expect(fixture.nativeElement.querySelector('#leave-settings-title').textContent).toBe(
+ 'Configurez vos congés',
+ );
+ expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
+ await (await dialog.getHarness(MatSlideToggleHarness)).check();
+ expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
+ expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
+ 0,
+ );
+
+ if (closing === 'Close') {
+ const closeButton = await dialog.getHarness(MatButtonHarness);
+ expect(await closeButton.getText()).toBe('Fermer');
+ await closeButton.click();
+ } else {
+ await dialog.close();
+ }
+ await expect
+ .poll(async () => (await loader.getAllHarnesses(MatDialogHarness)).length)
+ .toBe(0);
+ expect(document.activeElement).toBe(opener);
+ expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull();
+ const reopened = await openSettings();
+ const [restoredTheme, restoredLanguage] =
+ await reopened.getAllHarnesses(MatSelectHarness);
+ expect(await restoredTheme.getValueText()).toBe('Sombre');
+ expect(await restoredLanguage.getValueText()).toBe('Français');
+ expect(await (await reopened.getHarness(MatSlideToggleHarness)).isChecked()).toBe(true);
+
+ fixture.destroy();
+ TestBed.resetTestingModule();
+ await createApp();
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(
+ fixture.nativeElement.querySelector('header.app-header button').textContent,
+ ).toContain('Paramètres');
+ expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull();
+ const freshDialog = await openSettings();
+ const [savedTheme, savedLanguage] = await freshDialog.getAllHarnesses(MatSelectHarness);
+ expect(await savedTheme.getValueText()).toBe('Sombre');
+ expect(await savedLanguage.getValueText()).toBe('Français');
+ expect(await (await freshDialog.getHarness(MatSlideToggleHarness)).isChecked()).toBe(
+ true,
+ );
+ },
+ );
+});
diff --git a/src/app/app.html b/src/app/app.html
index dce6931..cfc03ed 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -9,7 +9,16 @@
{{ tr().app.description }}
-
+
diff --git a/src/app/app.scss b/src/app/app.scss
index e265f9c..ed9d694 100644
--- a/src/app/app.scss
+++ b/src/app/app.scss
@@ -22,10 +22,20 @@
border-block-end: 1px solid var(--ui-border);
}
+.settings-button {
+ flex-shrink: 0;
+ min-height: 2.75rem;
+}
+
.brand {
display: flex;
align-items: center;
gap: 0.875rem;
+ min-width: 0;
+
+ > div {
+ min-width: 0;
+ }
> mat-icon {
display: grid;
diff --git a/src/app/app.ts b/src/app/app.ts
index e7eab4a..ed74d4b 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -3,10 +3,13 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { provideNativeDateAdapter } from '@angular/material/core';
import { UserInput } from './components/user-input/user-input';
import { CalendarYear } from './components/calendar-year/calendar-year';
-import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
+import { UserSettings } from './components/user-settings/user-settings';
+import { MatButton } from '@angular/material/button';
+import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { TranslationService } from './services/translation-service';
+import { ThemeService } from './services/theme-service';
function isBenchmarkUrl(url: string): boolean {
return url.split(/[?#]/, 1)[0].replace(/\/+$/, '') === '/benchmark';
@@ -14,14 +17,7 @@ function isBenchmarkUrl(url: string): boolean {
@Component({
selector: 'app-root',
- imports: [
- MatDatepickerModule,
- UserInput,
- CalendarYear,
- DarkLightModeButton,
- MatIcon,
- RouterOutlet,
- ],
+ imports: [MatDatepickerModule, UserInput, CalendarYear, MatButton, MatIcon, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
providers: [provideNativeDateAdapter()],
@@ -29,6 +25,7 @@ function isBenchmarkUrl(url: string): boolean {
})
export class App {
private readonly router = inject(Router);
+ private readonly dialog = inject(MatDialog);
protected readonly tr = inject(TranslationService).tr;
protected readonly title = signal('vac-optimizer');
protected readonly showPlanner = signal(
@@ -36,9 +33,17 @@ export class App {
);
constructor() {
+ inject(ThemeService);
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd)
this.showPlanner.set(!isBenchmarkUrl(event.urlAfterRedirects));
});
}
+
+ protected openSettings(): void {
+ this.dialog.open(UserSettings, {
+ width: '28rem',
+ maxWidth: 'calc(100vw - 2rem)',
+ });
+ }
}
diff --git a/src/app/components/calendar-settings/calendar-settings.html b/src/app/components/calendar-settings/calendar-settings.html
deleted file mode 100644
index 6712189..0000000
--- a/src/app/components/calendar-settings/calendar-settings.html
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/src/app/components/calendar-settings/calendar-settings.scss b/src/app/components/calendar-settings/calendar-settings.scss
deleted file mode 100644
index 1079938..0000000
--- a/src/app/components/calendar-settings/calendar-settings.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-:host {
- display: inline-flex;
-}
-
-button {
- min-height: 2.75rem;
-}
diff --git a/src/app/components/calendar-settings/calendar-settings.spec.ts b/src/app/components/calendar-settings/calendar-settings.spec.ts
deleted file mode 100644
index 35b5cce..0000000
--- a/src/app/components/calendar-settings/calendar-settings.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { provideZonelessChangeDetection } from '@angular/core';
-import { CalendarSettings } from './calendar-settings';
-
-describe('CalendarSettings', () => {
- let component: CalendarSettings;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [CalendarSettings],
- providers: [provideZonelessChangeDetection()],
- }).compileComponents();
-
- fixture = TestBed.createComponent(CalendarSettings);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/src/app/components/calendar-settings/calendar-settings.ts b/src/app/components/calendar-settings/calendar-settings.ts
deleted file mode 100644
index 089b5d4..0000000
--- a/src/app/components/calendar-settings/calendar-settings.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Component, inject } from '@angular/core';
-import { MatButtonModule } from '@angular/material/button';
-import { MatDialog } from '@angular/material/dialog';
-import { MatIconModule } from '@angular/material/icon';
-import { CalendarSettingsDialog } from '../dialog/calendar-settings-dialog/calendar-settings-dialog';
-import { MatDividerModule } from '@angular/material/divider';
-import { TranslationService } from '../../services/translation-service';
-
-@Component({
- selector: 'app-calendar-settings',
- imports: [MatButtonModule, MatDividerModule, MatIconModule],
- templateUrl: './calendar-settings.html',
- styleUrl: './calendar-settings.scss',
-})
-export class CalendarSettings {
- private readonly translationService = inject(TranslationService);
-
- get tr() {
- return this.translationService.tr;
- }
-
- constructor(private dialog: MatDialog) {}
-
- openDialog() {
- this.dialog.open(CalendarSettingsDialog);
- }
-}
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index 08cfa34..59ebd65 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -51,7 +51,6 @@
}
-
@if (planningOutcome(); as outcome) {
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index d019f6d..4758c5a 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -21,7 +21,6 @@ import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
-import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
CalendarService,
DayType,
@@ -69,7 +68,6 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
MatCardContent,
MatProgressSpinner,
MatIcon,
- CalendarSettings,
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.html b/src/app/components/dark-light-mode-button/dark-light-mode-button.html
deleted file mode 100644
index 919c687..0000000
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss b/src/app/components/dark-light-mode-button/dark-light-mode-button.scss
deleted file mode 100644
index 3ae6399..0000000
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-button {
- width: 2.75rem;
- height: 2.75rem;
- padding: 0.625rem;
- border: 1px solid var(--ui-border);
- border-radius: 50%;
- color: var(--mat-sys-primary);
- background: var(--ui-panel-bg);
- transition: background-color 150ms ease;
- &:hover {
- background: var(--mat-sys-primary-container);
- }
-}
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts
deleted file mode 100644
index 9bd228c..0000000
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { provideZonelessChangeDetection } from '@angular/core';
-import { DarkLightModeButton } from './dark-light-mode-button';
-import { vi } from 'vitest';
-
-class ControllableMatchMedia {
- matches: boolean;
- readonly media = '(prefers-color-scheme: light)';
- private listener?: (event: MediaQueryListEvent) => void;
-
- constructor(matches: boolean) {
- this.matches = matches;
- }
-
- addEventListener(_type: string, listener: (event: MediaQueryListEvent) => void): void {
- this.listener = listener;
- }
-
- change(matches: boolean): void {
- this.matches = matches;
- this.listener?.({ matches, media: this.media } as MediaQueryListEvent);
- }
-}
-
-function expectTheme(
- button: HTMLButtonElement,
- icon: string,
- description: string,
- isLight: boolean,
-): void {
- expect(button.querySelector('mat-icon')?.getAttribute('data-mat-icon-name')).toBe(icon);
- expect(button.getAttribute('aria-label')).toBe(description);
- expect(button.getAttribute('title')).toBe(description);
- expect(document.body.classList.contains('light-mode')).toBe(isLight);
- expect(document.body.classList.contains('dark-mode')).toBe(!isLight);
-}
-
-describe('DarkLightModeButton', () => {
- let fixture: ComponentFixture;
- let mediaQuery: ControllableMatchMedia;
-
- beforeEach(async () => {
- mediaQuery = new ControllableMatchMedia(true);
- vi.spyOn(window, 'matchMedia').mockReturnValue(mediaQuery as unknown as MediaQueryList);
-
- await TestBed.configureTestingModule({
- imports: [DarkLightModeButton],
- providers: [provideZonelessChangeDetection()],
- }).compileComponents();
-
- fixture = TestBed.createComponent(DarkLightModeButton);
- fixture.detectChanges();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- document.body.classList.remove('light-mode', 'dark-mode');
- });
-
- it('starts in System and resolves its initial appearance from the browser preference', () => {
- const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
- expectTheme(
- button,
- 'brightness_auto',
- 'Theme: System (currently Light). Switch to Light theme',
- true,
- );
- });
-
- it('cycles System to Light to Dark and wraps back to System', () => {
- const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
-
- button.click();
- fixture.detectChanges();
- expectTheme(
- button,
- 'light_mode',
- 'Theme: Light (currently Light). Switch to Dark theme',
- true,
- );
-
- button.click();
- fixture.detectChanges();
- expectTheme(
- button,
- 'dark_mode',
- 'Theme: Dark (currently Dark). Switch to System theme',
- false,
- );
-
- button.click();
- fixture.detectChanges();
- expectTheme(
- button,
- 'brightness_auto',
- 'Theme: System (currently Light). Switch to Light theme',
- true,
- );
- });
-
- it('follows live browser changes while System is selected', () => {
- const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
-
- mediaQuery.change(false);
- fixture.detectChanges();
- expectTheme(
- button,
- 'brightness_auto',
- 'Theme: System (currently Dark). Switch to Light theme',
- false,
- );
-
- mediaQuery.change(true);
- fixture.detectChanges();
- expectTheme(
- button,
- 'brightness_auto',
- 'Theme: System (currently Light). Switch to Light theme',
- true,
- );
- });
-
- it('keeps explicit modes fixed and uses the latest browser value when returning to System', () => {
- const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
-
- button.click();
- fixture.detectChanges();
- mediaQuery.change(false);
- fixture.detectChanges();
- expectTheme(
- button,
- 'light_mode',
- 'Theme: Light (currently Light). Switch to Dark theme',
- true,
- );
-
- button.click();
- fixture.detectChanges();
- mediaQuery.change(true);
- fixture.detectChanges();
- expectTheme(
- button,
- 'dark_mode',
- 'Theme: Dark (currently Dark). Switch to System theme',
- false,
- );
-
- mediaQuery.change(false);
- button.click();
- fixture.detectChanges();
- expectTheme(
- button,
- 'brightness_auto',
- 'Theme: System (currently Dark). Switch to Light theme',
- false,
- );
- });
-});
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.ts
deleted file mode 100644
index 0821cfc..0000000
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Component, computed, inject } from '@angular/core';
-import { EffectiveTheme, ThemePreference, ThemeService } from '../../services/theme-service';
-import { MatIcon } from '@angular/material/icon';
-import { MatIconButton } from '@angular/material/button';
-
-@Component({
- selector: 'app-dark-light-mode-button',
- imports: [MatIconButton, MatIcon],
- templateUrl: './dark-light-mode-button.html',
- styleUrl: './dark-light-mode-button.scss',
-})
-export class DarkLightModeButton {
- private readonly themeService = inject(ThemeService);
- readonly themePreference = this.themeService.themePreference;
- readonly effectiveTheme = this.themeService.effectiveTheme;
- readonly icon = computed(() => {
- const preference = this.themePreference();
- return preference === 'system' ? 'brightness_auto' : `${preference}_mode`;
- });
- readonly description = computed(() => {
- const preference = this.themePreference();
- const selected = this.preferenceLabel(preference);
- const effective = this.preferenceLabel(this.effectiveTheme());
- const next = this.preferenceLabel(this.themeService.nextThemePreference());
- return `Theme: ${selected} (currently ${effective}). Switch to ${next} theme`;
- });
-
- toggleTheme(): void {
- this.themeService.toggleTheme();
- }
-
- private preferenceLabel(preference: ThemePreference | EffectiveTheme): string {
- return preference.charAt(0).toUpperCase() + preference.slice(1);
- }
-}
diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html
deleted file mode 100644
index f1a208f..0000000
--- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
{{ tr().settings.title }}
-
{{ tr().settings.description }}
-
-
- @for (setting of calendarSettingsService.settingsArray; track setting.id) {
-
-
- {{ getSettingDescription(setting.id) }}
-
-
- }
-
-
- {{ tr().settings.selectLanguage }}
-
- @for (lang of translationService.availableLanguages; track lang) {
- {{
- translationService.getLanguageName(lang)
- }}
- }
-
-
-
-
-
diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss
deleted file mode 100644
index de9fcc0..0000000
--- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss
+++ /dev/null
@@ -1,9 +0,0 @@
-.container {
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100%;
- flex-direction: column;
- overflow: hidden;
- margin: 16px;
-}
diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts
deleted file mode 100644
index a04a71a..0000000
--- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { provideZonelessChangeDetection } from '@angular/core';
-import { MatDialogRef } from '@angular/material/dialog';
-import { vi } from 'vitest';
-import { CalendarSettingsDialog } from './calendar-settings-dialog';
-
-describe('CalendarSettingsDialog', () => {
- let component: CalendarSettingsDialog;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [CalendarSettingsDialog],
- providers: [
- provideZonelessChangeDetection(),
- {
- provide: MatDialogRef,
- useValue: {
- close: vi.fn(),
- },
- },
- ],
- }).compileComponents();
-
- fixture = TestBed.createComponent(CalendarSettingsDialog);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- it('should have calendar settings service', () => {
- expect(component['calendarSettingsService']).toBeDefined();
- });
-
- it('should close dialog when closeDialog is called', () => {
- component.closeDialog();
- expect(component.dialogRef.close).toHaveBeenCalled();
- });
-});
diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts
deleted file mode 100644
index 81fcd91..0000000
--- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Component, inject } from '@angular/core';
-import { MatButton } from '@angular/material/button';
-import { MatDialogRef } from '@angular/material/dialog';
-import { CalendarSettingsService } from '../../../services/calendar-settings-service';
-import { MatSlideToggle } from '@angular/material/slide-toggle';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatSelectModule } from '@angular/material/select';
-import { TranslationService } from '../../../services/translation-service';
-
-@Component({
- selector: 'app-calendar-settings-dialog',
- imports: [MatButton, MatSlideToggle, MatFormFieldModule, MatSelectModule],
- templateUrl: './calendar-settings-dialog.html',
- styleUrl: './calendar-settings-dialog.scss',
-})
-export class CalendarSettingsDialog {
- public readonly dialogRef = inject>(MatDialogRef);
- protected readonly calendarSettingsService = inject(CalendarSettingsService);
- protected readonly translationService = inject(TranslationService);
-
- get tr() {
- return this.translationService.tr;
- }
-
- getSettingDescription(id: string): string {
- const tr = this.tr();
- if (id === 'showWeekNumbers') {
- return tr.settings.showWeekNumbers;
- }
- return id;
- }
-
- closeDialog() {
- this.dialogRef.close();
- }
-}
diff --git a/src/app/components/user-settings/user-settings.html b/src/app/components/user-settings/user-settings.html
index 6191a86..f976c58 100644
--- a/src/app/components/user-settings/user-settings.html
+++ b/src/app/components/user-settings/user-settings.html
@@ -1 +1,47 @@
-user-settings works!
+{{ tr().settings.title }}
+
+ {{ tr().settings.description }}
+
+
+ {{ tr().settings.selectTheme }}
+
+ @for (preference of themePreferences; track preference) {
+ {{
+ tr().settings.themeOptions[preference]
+ }}
+ }
+
+ @if (themeService.themePreference() === 'system') {
+
+ {{ tr().settings.systemAppearance }}:
+ {{ tr().settings.themeOptions[themeService.effectiveTheme()] }}
+
+ }
+
+
+ {{ tr().settings.selectLanguage }}
+
+ @for (lang of translationService.availableLanguages; track lang) {
+ {{
+ translationService.getLanguageName(lang)
+ }}
+ }
+
+
+
+ {{ tr().settings.showWeekNumbers }}
+
+
+
+
+
+
diff --git a/src/app/components/user-settings/user-settings.scss b/src/app/components/user-settings/user-settings.scss
index e69de29..16e79eb 100644
--- a/src/app/components/user-settings/user-settings.scss
+++ b/src/app/components/user-settings/user-settings.scss
@@ -0,0 +1,18 @@
+:host {
+ display: block;
+}
+
+mat-dialog-content > p {
+ margin: 0 0 1.5rem;
+ color: var(--ui-text-secondary);
+}
+
+.settings-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+mat-form-field {
+ width: 100%;
+}
diff --git a/src/app/components/user-settings/user-settings.ts b/src/app/components/user-settings/user-settings.ts
index c310b86..b985dba 100644
--- a/src/app/components/user-settings/user-settings.ts
+++ b/src/app/components/user-settings/user-settings.ts
@@ -1,9 +1,38 @@
-import { Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { MatButton } from '@angular/material/button';
+import {
+ MatDialogActions,
+ MatDialogClose,
+ MatDialogContent,
+ MatDialogTitle,
+} from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatSlideToggle } from '@angular/material/slide-toggle';
+import { CalendarSettingsService } from '../../services/calendar-settings-service';
+import { ThemeService } from '../../services/theme-service';
+import { TranslationService } from '../../services/translation-service';
@Component({
selector: 'app-user-settings',
- imports: [],
+ imports: [
+ MatButton,
+ MatDialogActions,
+ MatDialogClose,
+ MatDialogContent,
+ MatDialogTitle,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatSlideToggle,
+ ],
templateUrl: './user-settings.html',
styleUrl: './user-settings.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class UserSettings {}
+export class UserSettings {
+ protected readonly themeService = inject(ThemeService);
+ protected readonly translationService = inject(TranslationService);
+ protected readonly showWeekNumbers = inject(CalendarSettingsService).get('showWeekNumbers');
+ protected readonly tr = this.translationService.tr;
+ protected readonly themePreferences = ['system', 'light', 'dark'] as const;
+}
diff --git a/src/app/services/calendar-settings-service.ts b/src/app/services/calendar-settings-service.ts
index 7db0fe9..e4f1a5e 100644
--- a/src/app/services/calendar-settings-service.ts
+++ b/src/app/services/calendar-settings-service.ts
@@ -33,7 +33,7 @@ export class CalendarSettingsService {
constructor() {
for (const [id, def] of this.definitions) {
const storageKey = `calendarSettings.${id}`;
- this.signals.set(id, storedSignal(storageKey, def.defaultValue, this.injector));
+ this.signals.set(id, storedSignal(storageKey, def.defaultValue, this.injector, 0));
}
this.settingsArray = Array.from(this.definitions.entries()).map(([id, def]) => ({
diff --git a/src/app/services/theme-service.ts b/src/app/services/theme-service.ts
index 65291c6..cf7e334 100644
--- a/src/app/services/theme-service.ts
+++ b/src/app/services/theme-service.ts
@@ -1,4 +1,5 @@
-import { computed, Injectable, signal } from '@angular/core';
+import { computed, inject, Injectable, Injector, signal } from '@angular/core';
+import { storedSignal } from '../shared/utils/stored-signal';
export type ThemePreference = 'system' | 'light' | 'dark';
export type EffectiveTheme = 'light' | 'dark';
@@ -7,15 +8,23 @@ export type EffectiveTheme = 'light' | 'dark';
export class ThemeService {
private readonly lightModeMedia = window.matchMedia('(prefers-color-scheme: light)');
private readonly systemIsLight = signal(this.lightModeMedia.matches);
- private readonly selectedPreference = signal('system');
+ private readonly selectedPreference = storedSignal(
+ 'theme.preference',
+ 'system',
+ inject(Injector),
+ 0,
+ );
readonly themePreference = this.selectedPreference.asReadonly();
readonly effectiveTheme = computed(() => {
const preference = this.selectedPreference();
return preference === 'system' ? (this.systemIsLight() ? 'light' : 'dark') : preference;
});
- readonly nextThemePreference = computed(() => this.nextPreference(this.selectedPreference()));
constructor() {
+ const preference = this.selectedPreference();
+ if (preference !== 'system' && preference !== 'light' && preference !== 'dark') {
+ this.selectedPreference.set('system');
+ }
this.applyEffectiveTheme();
this.lightModeMedia.addEventListener('change', (e) => {
this.systemIsLight.set(e.matches);
@@ -25,19 +34,11 @@ export class ThemeService {
});
}
- toggleTheme(): void {
- this.setThemePreference(this.nextThemePreference());
- }
-
- private setThemePreference(preference: ThemePreference): void {
+ setThemePreference(preference: ThemePreference): void {
this.selectedPreference.set(preference);
this.applyEffectiveTheme();
}
- private nextPreference(preference: ThemePreference): ThemePreference {
- return preference === 'system' ? 'light' : preference === 'light' ? 'dark' : 'system';
- }
-
private applyEffectiveTheme(): void {
const isLight = this.effectiveTheme() === 'light';
document.body.classList.toggle('light-mode', isLight);
diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts
index b1f6c73..bf18f27 100644
--- a/src/app/services/translation-service.ts
+++ b/src/app/services/translation-service.ts
@@ -50,6 +50,13 @@ export interface AppTranslations {
showWeekNumbers: string;
close: string;
selectLanguage: string;
+ selectTheme: string;
+ themeOptions: {
+ system: string;
+ light: string;
+ dark: string;
+ };
+ systemAppearance: string;
};
rules: {
title: string;
@@ -307,6 +314,7 @@ export class TranslationService {
'app.language',
this.getBrowserLanguage(),
this.injector,
+ 0,
);
public readonly tr = computed(() => {
diff --git a/src/app/shared/utils/stored-signal.ts b/src/app/shared/utils/stored-signal.ts
index 99ba0ee..17e25ac 100644
--- a/src/app/shared/utils/stored-signal.ts
+++ b/src/app/shared/utils/stored-signal.ts
@@ -18,14 +18,15 @@ function isLocalStorageAvailable(): boolean {
* The signal will:
* - Initialize with the stored value from localStorage if available
* - Fall back to the provided initial value if no stored value exists or parsing fails
- * - Automatically sync changes to localStorage with debouncing (300ms delay)
+ * - Automatically sync changes to localStorage with debouncing (300ms delay), or
+ * synchronously on selection when debounceMs is 0
* - Work safely in SSR and environments where localStorage is unavailable
*
* @template T - The type of value stored in the signal (must be JSON-serializable)
* @param key - The localStorage key used to store the value
* @param initialValue - The default value used when no stored value exists
* @param injector - The Angular injector (use inject(Injector) to get current context)
- * @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
+ * @param debounceMs - Debounce delay in milliseconds; 0 saves synchronously (default: 300ms)
* @returns A writable signal that persists to localStorage
*
* @example
@@ -71,6 +72,25 @@ export function storedSignal(
}
const s = signal(parsedValue);
+ const saveValue = (value: T): void => {
+ if (isLocalStorageAvailable()) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.warn(`Failed to save value for key "${key}" to localStorage.`, error);
+ }
+ }
+ };
+
+ if (debounceMs === 0) {
+ const set = s.set;
+ s.set = (value) => {
+ set(value);
+ saveValue(value);
+ };
+ s.update = (updater) => s.set(updater(s()));
+ return s;
+ }
let timeoutId: ReturnType | null = null;
let isFirstRun = true;
@@ -92,16 +112,7 @@ export function storedSignal(
// Debounce the localStorage write
timeoutId = setTimeout(() => {
- if (isLocalStorageAvailable()) {
- try {
- localStorage.setItem(key, JSON.stringify(value));
- } catch (error) {
- console.warn(
- `Failed to save value for key "${key}" to localStorage.`,
- error,
- );
- }
- }
+ saveValue(value);
timeoutId = null;
}, debounceMs);
});
diff --git a/src/i18n/de.json b/src/i18n/de.json
index f1bd760..2d1772d 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -42,10 +42,13 @@
},
"settings": {
"title": "Einstellungen",
- "description": "Einstellungen anpassen",
+ "description": "Änderungen werden automatisch gespeichert.",
"showWeekNumbers": "Wochennummern anzeigen",
"close": "Schließen",
- "selectLanguage": "Sprache auswählen"
+ "selectLanguage": "Sprache auswählen",
+ "selectTheme": "Design",
+ "themeOptions": { "system": "System", "light": "Hell", "dark": "Dunkel" },
+ "systemAppearance": "Aktuelles Systemdesign"
},
"rules": {
"title": "Planungsregeln",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3611a93..327d5a4 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -41,11 +41,14 @@
"any": "Any"
},
"settings": {
- "title": "Calendar Settings",
- "description": "Configure your calendar settings here.",
+ "title": "Settings",
+ "description": "Changes are saved automatically.",
"showWeekNumbers": "Show week numbers",
"close": "Close",
- "selectLanguage": "Language"
+ "selectLanguage": "Language",
+ "selectTheme": "Theme",
+ "themeOptions": { "system": "System", "light": "Light", "dark": "Dark" },
+ "systemAppearance": "Current system appearance"
},
"rules": {
"title": "Planning rules",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 10a7dc4..7586f12 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -41,11 +41,14 @@
"any": "Cualquier"
},
"settings": {
- "title": "Ajustes del calendario",
- "description": "Configure sus ajustes del calendario aquí.",
+ "title": "Ajustes",
+ "description": "Los cambios se guardan automáticamente.",
"showWeekNumbers": "Mostrar números de semana",
"close": "Cerrar",
- "selectLanguage": "Idioma"
+ "selectLanguage": "Idioma",
+ "selectTheme": "Tema",
+ "themeOptions": { "system": "Sistema", "light": "Claro", "dark": "Oscuro" },
+ "systemAppearance": "Apariencia actual del sistema"
},
"rules": {
"title": "Reglas de planificación",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 25c20a8..617a6b4 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -41,11 +41,14 @@
"any": "N'importe"
},
"settings": {
- "title": "Paramètres du calendrier",
- "description": "Configurez vos paramètres de calendrier ici.",
+ "title": "Paramètres",
+ "description": "Les modifications sont enregistrées automatiquement.",
"showWeekNumbers": "Afficher les numéros de semaine",
"close": "Fermer",
- "selectLanguage": "Langue"
+ "selectLanguage": "Langue",
+ "selectTheme": "Thème",
+ "themeOptions": { "system": "Système", "light": "Clair", "dark": "Sombre" },
+ "systemAppearance": "Apparence actuelle du système"
},
"rules": {
"title": "Règles de planification",