From b0ba4227ac499e78ff6a87c68fa404570e5c6581 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sun, 4 Oct 2026 23:19:21 +0200
Subject: [PATCH] #22
---
src/app/app-settings.spec.ts | 123 ++++++++++++++-
.../components/calendar-year/calendar-year.ts | 7 +-
.../components/user-input/user-input.spec.ts | 99 +++++++++++-
src/app/services/user-input-service.spec.ts | 147 +++++++++++++++++-
src/app/services/user-input-service.ts | 58 ++++++-
5 files changed, 426 insertions(+), 8 deletions(-)
diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts
index 28f9de2..a647928 100644
--- a/src/app/app-settings.spec.ts
+++ b/src/app/app-settings.spec.ts
@@ -7,9 +7,10 @@ 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 { type MockInstance, vi } from 'vitest';
import { App } from './app';
-import { UserInputService } from './services/user-input-service';
+import { CalendarService } from './services/calendar-service';
+import { calculatePlanningInternal } from './services/leave-planner.core';
describe('Application Settings', () => {
const savedBalances = JSON.stringify([
@@ -25,6 +26,7 @@ describe('Application Settings', () => {
let loader: HarnessLoader;
let opener: HTMLButtonElement;
let mediaQuery: EventTarget & { matches: boolean; media: string };
+ let calculate: MockInstance;
beforeEach(async () => {
localStorage.clear();
@@ -50,7 +52,7 @@ describe('Application Settings', () => {
imports: [App],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
- TestBed.inject(UserInputService).vacationNumberSignal.set([]);
+ calculate = vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation');
fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
@@ -93,6 +95,121 @@ describe('Application Settings', () => {
fixture.detectChanges();
}
+ async function revisitApp(isLight: boolean): Promise {
+ fixture.destroy();
+ TestBed.resetTestingModule();
+ mediaQuery = Object.assign(new EventTarget(), {
+ matches: isLight,
+ media: '(prefers-color-scheme: light)',
+ });
+ await createApp();
+ }
+
+ it('passes restored expired balances to the initial calculation without rewriting storage', () => {
+ const root = fixture.nativeElement as HTMLElement;
+ expect(
+ root.querySelector('app-day-input input[type="number"]')?.value,
+ ).toBe('3');
+ expect(root.querySelectorAll('.date-fields input')[1].value).toBe(
+ '31/05/2026',
+ );
+ expect(calculate).toHaveBeenCalledOnce();
+ const request = calculate.mock.calls[0][0];
+ expect(request.balances).toMatchObject([
+ {
+ id: 'saved-balance',
+ type: 'cp',
+ quantity: 3,
+ availableFrom: '2026-01-01',
+ expiryDate: '2026-05-31',
+ },
+ ]);
+ const outcome = calculatePlanningInternal(request);
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan')
+ throw new Error('Expected the restored balance to be readable.');
+ expect(outcome.remainingBalances).toMatchObject([
+ {
+ id: 'saved-balance',
+ quantity: 3,
+ availableFrom: '2026-01-01',
+ expiryDate: '2026-05-31',
+ classification: 'unavoidable-expiry',
+ },
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
+ });
+
+ it.each([
+ ['negative quantity', { numberOfDays: -1 }, 'balance-quantity'],
+ ['reversed dates', { availableDate: '2026-06-01' }, 'balance-dates'],
+ ])('leaves readable saved inputs (%s) to planner validation', async (_, invalid, reason) => {
+ const saved = JSON.stringify([
+ {
+ id: 'saved-balance',
+ type: 'cp',
+ numberOfDays: 3,
+ availableDate: '2026-01-01',
+ expiryDate: '2026-05-31',
+ ...invalid,
+ },
+ ]);
+ localStorage.setItem('leaveBalances', saved);
+ await revisitApp(true);
+ await expect
+ .poll(() => fixture.nativeElement.querySelector('.reason-list')?.textContent)
+ .toContain(reason.replaceAll('-', ' '));
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
+ it.each([
+ ['light', 'Clair', false],
+ ['dark', 'Sombre', true],
+ ] as const)(
+ 'restores %s, language, and week numbers before presenting their controls',
+ async (preference, label, isLight) => {
+ localStorage.setItem('theme.preference', JSON.stringify(preference));
+ localStorage.setItem('app.language', '"fr"');
+ localStorage.setItem('calendarSettings.showWeekNumbers', 'true');
+ await revisitApp(isLight);
+
+ expect(document.body.classList.contains(`${preference}-mode`)).toBe(true);
+ expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull();
+ const dialog = await openSettings();
+ const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
+ expect(await dialog.getTitleText()).toBe('Paramètres');
+ expect(await theme.getValueText()).toBe(label);
+ expect(await language.getValueText()).toBe('Français');
+ expect(await (await dialog.getHarness(MatSlideToggleHarness)).isChecked()).toBe(true);
+ changeSystemAppearance(!isLight);
+ changeSystemAppearance(isLight);
+ expect(document.body.classList.contains(`${preference}-mode`)).toBe(true);
+ expect(localStorage.getItem('theme.preference')).toBe(JSON.stringify(preference));
+ expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
+ },
+ );
+
+ it.each([true, false])(
+ 'restores System using the current device appearance (%s)',
+ async (isLight) => {
+ localStorage.setItem('theme.preference', '"system"');
+ await revisitApp(isLight);
+ expect(document.body.classList.contains(isLight ? 'light-mode' : 'dark-mode')).toBe(
+ true,
+ );
+ const dialog = await openSettings();
+ const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
+ expect(await theme.getValueText()).toBe('System');
+ changeSystemAppearance(!isLight);
+ expect(document.body.classList.contains(isLight ? 'dark-mode' : 'light-mode')).toBe(
+ true,
+ );
+ expect(await theme.getValueText()).toBe('System');
+ expect(localStorage.getItem('theme.preference')).toBe('"system"');
+ expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
+ },
+ );
+
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);
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 4758c5a..f9f52da 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
+import dayjs from 'dayjs';
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';
@@ -459,8 +460,10 @@ export class CalendarYear {
type: balance.type,
quantity: balance.numberOfDays,
label: this.translationService.getDayTypeLabel(balance.type),
- availableFrom: balance.availableDate,
- expiryDate: balance.expiryDate,
+ availableFrom: balance.availableDate
+ ? dayjs(balance.availableDate).format('YYYY-MM-DD')
+ : undefined,
+ expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: currentRules,
labels: Object.fromEntries(
diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts
index 5ce8918..3f5ddbd 100644
--- a/src/app/components/user-input/user-input.spec.ts
+++ b/src/app/components/user-input/user-input.spec.ts
@@ -27,6 +27,10 @@ describe('UserInput', () => {
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
+ await createInput();
+ });
+
+ async function createInput(): Promise {
await TestBed.configureTestingModule({
imports: [UserInput, UserSettings],
providers: [provideZonelessChangeDetection()],
@@ -36,7 +40,13 @@ describe('UserInput', () => {
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
fixture.detectChanges();
- });
+ }
+
+ async function revisit(): Promise {
+ fixture.destroy();
+ TestBed.resetTestingModule();
+ await createInput();
+ }
afterEach(() => {
fixture.destroy();
@@ -91,6 +101,93 @@ describe('UserInput', () => {
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
});
+ it('renders restored quantities, leave types, and unchanged availability and expiry dates', async () => {
+ const saved = JSON.stringify([
+ {
+ id: 'rtt-first',
+ type: 'rtt',
+ numberOfDays: 36,
+ availableDate: '2026-03-29',
+ expiryDate: '2026-10-25',
+ },
+ {
+ id: 'expired-cp',
+ type: 'cp',
+ numberOfDays: 2,
+ availableDate: '2024-02-29',
+ expiryDate: '2024-05-31',
+ },
+ ]);
+ localStorage.setItem('leaveBalances', saved);
+ await revisit();
+
+ const root = fixture.nativeElement as HTMLElement;
+ expect(
+ Array.from(root.querySelectorAll('app-day-input input')).map(
+ (input) => input.value,
+ ),
+ ).toEqual(['36', '29/03/2026', '25/10/2026', '2', '29/02/2024', '31/05/2024']);
+ const types = await TestbedHarnessEnvironment.loader(fixture).getAllHarnesses(
+ MatSelectHarness.with({ selector: 'app-day-input mat-select' }),
+ );
+ expect(await Promise.all(types.map((type) => type.getValueText()))).toEqual([
+ 'RTT',
+ 'Paid Leave',
+ ]);
+ expect(userInputService.vacationNumberSignal().map((balance) => balance.id)).toEqual([
+ 'rtt-first',
+ 'expired-cp',
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
+ it('renders no balance rows on a fresh visit with a saved empty list', async () => {
+ localStorage.setItem('leaveBalances', '[]');
+ await revisit();
+ expect(fixture.nativeElement.querySelector('app-day-input')).toBeNull();
+ expect(localStorage.getItem('leaveBalances')).toBe('[]');
+ });
+
+ it.each([
+ ['absent', null],
+ ['unreadable', '{'],
+ ])('renders unsaved random defaults when balance data is %s', async (_, saved) => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ if (saved !== null) localStorage.setItem('leaveBalances', saved);
+ await revisit();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(
+ Array.from(root.querySelectorAll('app-day-input input')).map(
+ (input) => input.value,
+ ),
+ ).toEqual([
+ '15',
+ '04/10/2026',
+ '04/10/2027',
+ '15',
+ '04/10/2026',
+ '04/10/2027',
+ '15',
+ '04/10/2026',
+ '04/10/2027',
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+ expect(localStorage.getItem('app.language')).toBe('"en"');
+ });
+
+ it('restores the complete edited list on a fresh visit', async () => {
+ editInput('app-day-input input[type="number"]', '7');
+ const saved = localStorage.getItem('leaveBalances');
+ await revisit();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(
+ Array.from(root.querySelectorAll('input[type="number"]')).map(
+ (input) => input.value,
+ ),
+ ).toEqual(['7', '15', '15']);
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
it('saves the complete ordered balance list on the first quantity edit', () => {
expect(localStorage.getItem('leaveBalances')).toBeNull();
editInput('app-day-input input[type="number"]', '7');
diff --git a/src/app/services/user-input-service.spec.ts b/src/app/services/user-input-service.spec.ts
index 735bccd..ff47d4f 100644
--- a/src/app/services/user-input-service.spec.ts
+++ b/src/app/services/user-input-service.spec.ts
@@ -5,16 +5,27 @@ import { SelectableDayType, VacDay } from './calendar-service';
import { vi } from 'vitest';
describe('UserInputService', () => {
+ const savedBalance = {
+ id: 'saved-cp',
+ type: 'cp',
+ numberOfDays: 3,
+ availableDate: '2026-01-01',
+ expiryDate: '2026-05-31',
+ };
let service: UserInputService;
beforeEach(() => {
+ localStorage.clear();
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(UserInputService);
});
- afterEach(() => vi.restoreAllMocks());
+ afterEach(() => {
+ vi.restoreAllMocks();
+ localStorage.clear();
+ });
it('should be created', () => {
expect(service).toBeTruthy();
@@ -37,6 +48,134 @@ describe('UserInputService', () => {
});
});
+ it('restores the saved balance order, identities, quantities, types, and calendar dates', () => {
+ const saved = JSON.stringify([
+ {
+ id: 'saved-other',
+ type: 'other',
+ numberOfDays: 41,
+ availableDate: '2026-03-29',
+ expiryDate: '2026-10-25',
+ },
+ {
+ id: 'expired-cp',
+ type: 'cp',
+ numberOfDays: 5,
+ availableDate: '2024-02-29',
+ expiryDate: '2024-05-31',
+ },
+ {
+ id: 'empty-rtt',
+ type: 'rtt',
+ numberOfDays: 0,
+ availableDate: '2027-01-01',
+ expiryDate: '2027-12-31',
+ },
+ ]);
+ localStorage.setItem('leaveBalances', saved);
+
+ expect(new UserInputService().vacationNumberSignal()).toEqual([
+ {
+ id: 'saved-other',
+ type: SelectableDayType.OTHER,
+ numberOfDays: 41,
+ availableDate: new Date(2026, 2, 29),
+ expiryDate: new Date(2026, 9, 25),
+ },
+ {
+ id: 'expired-cp',
+ type: SelectableDayType.CP,
+ numberOfDays: 5,
+ availableDate: new Date(2024, 1, 29),
+ expiryDate: new Date(2024, 4, 31),
+ },
+ {
+ id: 'empty-rtt',
+ type: SelectableDayType.RTT,
+ numberOfDays: 0,
+ availableDate: new Date(2027, 0, 1),
+ expiryDate: new Date(2027, 11, 31),
+ },
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
+ it('restores a saved empty list without generating or saving replacement balances', () => {
+ localStorage.setItem('leaveBalances', '[]');
+ expect(new UserInputService().vacationNumberSignal()).toEqual([]);
+ expect(localStorage.getItem('leaveBalances')).toBe('[]');
+ });
+
+ it('preserves an omitted availability date from the supported save format', () => {
+ const saved = JSON.stringify([{ ...savedBalance, availableDate: undefined }]);
+ localStorage.setItem('leaveBalances', saved);
+ expect(new UserInputService().vacationNumberSignal()).toEqual([
+ {
+ id: 'saved-cp',
+ type: SelectableDayType.CP,
+ numberOfDays: 3,
+ availableDate: undefined,
+ expiryDate: new Date(2026, 4, 31),
+ },
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
+ it.each([
+ ['empty storage value', ''],
+ ['malformed JSON', '{'],
+ ['non-list', '{}'],
+ ['null list', 'null'],
+ ['null record', '[null]'],
+ ['missing identity', JSON.stringify([{ ...savedBalance, id: undefined }])],
+ ['unreadable quantity', JSON.stringify([{ ...savedBalance, numberOfDays: '3' }])],
+ ['invalid availability', JSON.stringify([{ ...savedBalance, availableDate: null }])],
+ ['missing expiry', JSON.stringify([{ ...savedBalance, expiryDate: undefined }])],
+ ['rolled-over date', JSON.stringify([{ ...savedBalance, expiryDate: '2026-02-30' }])],
+ [
+ 'mixed supported and unsupported records',
+ JSON.stringify([savedBalance, { ...savedBalance, type: 'any' }]),
+ ],
+ ])(
+ 'deletes only unreadable balance data (%s) before using unsaved random defaults',
+ (_, saved) => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
+ const unrelated = {
+ 'theme.preference': '"dark"',
+ 'app.language': '"fr"',
+ 'calendarSettings.showWeekNumbers': 'true',
+ vacationRules: '[]',
+ 'unrelated.entry': 'keep this value',
+ };
+ for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value);
+ localStorage.setItem('leaveBalances', saved);
+
+ const balances = new UserInputService().vacationNumberSignal();
+ expect(balances.map((balance) => [balance.type, balance.numberOfDays])).toEqual([
+ [SelectableDayType.CP, 15],
+ [SelectableDayType.RTT, 15],
+ [SelectableDayType.OTHER, 15],
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+ for (const [key, value] of Object.entries(unrelated))
+ expect(localStorage.getItem(key)).toBe(value);
+ },
+ );
+
+ it('keeps saved data intact when browser storage temporarily cannot be read', () => {
+ const saved = JSON.stringify([savedBalance]);
+ localStorage.setItem('leaveBalances', saved);
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new DOMException('Storage is unavailable', 'SecurityError');
+ });
+
+ expect(new UserInputService().vacationNumberSignal()).toHaveLength(3);
+ read.mockRestore();
+ expect(localStorage.getItem('leaveBalances')).toBe(saved);
+ });
+
it.each([
[0, 10],
[1 - Number.EPSILON, 20],
@@ -48,6 +187,12 @@ describe('UserInputService', () => {
expected,
expected,
]);
+ expect(initialBalances.map((balance) => balance.type)).toEqual([
+ SelectableDayType.CP,
+ SelectableDayType.RTT,
+ SelectableDayType.OTHER,
+ ]);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
});
it('should update vacation number signal', () => {
diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts
index 7415bc2..02f44f8 100644
--- a/src/app/services/user-input-service.ts
+++ b/src/app/services/user-input-service.ts
@@ -6,6 +6,60 @@ import dayjs from 'dayjs';
providedIn: 'root',
})
export class UserInputService {
+ private restoreDate(value: unknown): Date {
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
+ const date = new Date(`${value}T00:00:00`);
+ if (dayjs(date).format('YYYY-MM-DD') === value) return date;
+ }
+ throw new Error('Unsupported saved balance date.');
+ }
+
+ private getSavedValues(): VacDay[] | undefined {
+ let stored: string | null = null;
+ try {
+ if (typeof localStorage === 'undefined') return;
+ stored = localStorage.getItem('leaveBalances');
+ if (stored === null) return;
+ const saved: unknown = JSON.parse(stored);
+ if (!Array.isArray(saved)) throw new Error('Saved leave balances must be a list.');
+ return saved.map((value: unknown) => {
+ if (typeof value !== 'object' || value === null)
+ throw new Error('Unsupported saved balance record.');
+ const { id, type, numberOfDays, availableDate, expiryDate } = value as Record<
+ string,
+ unknown
+ >;
+ if (
+ typeof id !== 'string' ||
+ typeof numberOfDays !== 'number' ||
+ !Number.isFinite(numberOfDays) ||
+ (type !== SelectableDayType.CP &&
+ type !== SelectableDayType.RTT &&
+ type !== SelectableDayType.OTHER)
+ )
+ throw new Error('Unsupported saved balance record.');
+ return {
+ id,
+ type,
+ numberOfDays,
+ availableDate:
+ availableDate === undefined ? undefined : this.restoreDate(availableDate),
+ expiryDate: this.restoreDate(expiryDate),
+ };
+ });
+ } catch (error) {
+ console.warn('Failed to restore leave balances from localStorage.', error);
+ if (stored !== null) {
+ try {
+ localStorage.removeItem('leaveBalances');
+ } catch {
+ // Keep the random fallback usable when browser storage is unavailable.
+ }
+ }
+ }
+ return undefined;
+ }
+
private getRandomNumber(): number {
return Math.floor(Math.random() * 11) + 10;
}
@@ -48,7 +102,9 @@ export class UserInputService {
};
}
- readonly vacationNumberSignal = signal(this.getInitialValues());
+ readonly vacationNumberSignal = signal(
+ this.getSavedValues() ?? this.getInitialValues(),
+ );
readonly remainingVacationDaysSignal = signal([]);
readonly computeTriggerSignal = signal(0);