From 578aafbf9121e1071d9edcb7f8f7ebb47465734e Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sun, 4 Oct 2026 22:48:37 +0200
Subject: [PATCH] #21
---
src/app/app-settings.spec.ts | 18 ++
.../components/user-input/user-input.spec.ts | 215 +++++++++++++++++-
src/app/components/user-input/user-input.ts | 8 +-
src/app/services/user-input-service.ts | 21 ++
4 files changed, 256 insertions(+), 6 deletions(-)
diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts
index 820e8ef..28f9de2 100644
--- a/src/app/app-settings.spec.ts
+++ b/src/app/app-settings.spec.ts
@@ -12,6 +12,15 @@ import { App } from './app';
import { UserInputService } from './services/user-input-service';
describe('Application Settings', () => {
+ const savedBalances = JSON.stringify([
+ {
+ id: 'saved-balance',
+ type: 'cp',
+ numberOfDays: 3,
+ availableDate: '2026-01-01',
+ expiryDate: '2026-05-31',
+ },
+ ]);
let fixture: ComponentFixture;
let loader: HarnessLoader;
let opener: HTMLButtonElement;
@@ -20,6 +29,9 @@ describe('Application Settings', () => {
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
+ localStorage.setItem('leaveBalances', savedBalances);
+ localStorage.setItem('vacationRules', '[]');
+ localStorage.setItem('unrelated.entry', 'keep this value');
mediaQuery = Object.assign(new EventTarget(), {
matches: true,
media: '(prefers-color-scheme: light)',
@@ -178,6 +190,9 @@ describe('Application Settings', () => {
expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
+ expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
+ expect(localStorage.getItem('vacationRules')).toBe('[]');
+ expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
0,
);
@@ -204,6 +219,9 @@ describe('Application Settings', () => {
fixture.destroy();
TestBed.resetTestingModule();
await createApp();
+ expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
+ expect(localStorage.getItem('vacationRules')).toBe('[]');
+ expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(
fixture.nativeElement.querySelector('header.app-header button').textContent,
diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts
index eed0787..5ce8918 100644
--- a/src/app/components/user-input/user-input.spec.ts
+++ b/src/app/components/user-input/user-input.spec.ts
@@ -1,7 +1,15 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { MatOptionHarness } from '@angular/material/core/testing';
+import { MatDialog } from '@angular/material/dialog';
+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 { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
+import { UserSettings } from '../user-settings/user-settings';
describe('UserInput', () => {
let component: UserInput;
@@ -9,8 +17,18 @@ describe('UserInput', () => {
let userInputService: UserInputService;
beforeEach(async () => {
+ localStorage.clear();
+ localStorage.setItem('app.language', '"en"');
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 9, 4, 12));
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
+ vi.spyOn(crypto, 'randomUUID')
+ .mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
+ .mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
+ .mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
+ .mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
await TestBed.configureTestingModule({
- imports: [UserInput],
+ imports: [UserInput, UserSettings],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
@@ -20,6 +38,36 @@ describe('UserInput', () => {
fixture.detectChanges();
});
+ afterEach(() => {
+ fixture.destroy();
+ TestBed.resetTestingModule();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ localStorage.clear();
+ document.body.classList.remove('light-mode', 'dark-mode');
+ });
+
+ function editInput(selector: string, value: string): void {
+ const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
+ input.value = value;
+ input.dispatchEvent(new Event('input'));
+ input.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+ }
+
+ async function selectOption(select: MatSelectHarness, text: string): Promise {
+ await select.open();
+ const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+ 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}`);
+ }
+
it('should create', () => {
expect(component).toBeTruthy();
});
@@ -42,4 +90,169 @@ describe('UserInput', () => {
component.removeFromList(0);
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
});
+
+ 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');
+
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toEqual([
+ {
+ id: '00000000-0000-4000-8000-000000000001',
+ type: 'cp',
+ numberOfDays: 7,
+ availableDate: '2026-10-04',
+ expiryDate: '2027-10-04',
+ },
+ {
+ id: '00000000-0000-4000-8000-000000000002',
+ type: 'rtt',
+ numberOfDays: 15,
+ availableDate: '2026-10-04',
+ expiryDate: '2027-10-04',
+ },
+ {
+ id: '00000000-0000-4000-8000-000000000003',
+ type: 'other',
+ numberOfDays: 15,
+ availableDate: '2026-10-04',
+ expiryDate: '2027-10-04',
+ },
+ ]);
+ });
+
+ it('saves a leave-type selection as the first balance edit', async () => {
+ const loader = TestbedHarnessEnvironment.loader(fixture);
+ const type = await loader.getHarness(
+ MatSelectHarness.with({ selector: 'app-day-input mat-select' }),
+ );
+ await selectOption(type, 'RTT');
+
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
+ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15, type: 'rtt' },
+ { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15, type: 'rtt' },
+ { id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15, type: 'other' },
+ ]);
+ });
+
+ it.each([
+ [
+ 'availability',
+ 1,
+ '29/03/2026',
+ { availableDate: '2026-03-29', expiryDate: '2027-10-04' },
+ ],
+ ['expiry', 2, '25/10/2026', { availableDate: '2026-10-04', expiryDate: '2026-10-25' }],
+ ])('saves an %s date edit without shifting its calendar date', (_, field, value, dates) => {
+ editInput(`app-day-input .date-fields mat-form-field:nth-child(${field}) input`, value);
+
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
+ { id: '00000000-0000-4000-8000-000000000001', ...dates },
+ { id: '00000000-0000-4000-8000-000000000002' },
+ { id: '00000000-0000-4000-8000-000000000003' },
+ ]);
+ });
+
+ it('saves adding a balance as the first edit and keeps saving subsequent edits', () => {
+ fixture.nativeElement.querySelector('.add-day-button').click();
+ fixture.detectChanges();
+
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
+ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
+ { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
+ { id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15 },
+ {
+ id: '00000000-0000-4000-8000-000000000004',
+ type: 'cp',
+ numberOfDays: 1,
+ availableDate: '2026-10-04',
+ expiryDate: '2027-10-04',
+ },
+ ]);
+
+ editInput('app-day-input input[type="number"]', '8');
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
+ editInput('app-day-input input[type="number"]', '9');
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(9);
+ });
+
+ it('saves removing a balance as the first edit and retains an entry after the final deletion', () => {
+ fixture.nativeElement.querySelectorAll('.delete-button')[2].click();
+ fixture.detectChanges();
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
+ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
+ { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
+ ]);
+
+ fixture.nativeElement.querySelectorAll('.delete-button')[1].click();
+ fixture.detectChanges();
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toHaveLength(1);
+ fixture.nativeElement.querySelector('.delete-button').click();
+ fixture.detectChanges();
+ expect(userInputService.vacationNumberSignal()).toEqual([]);
+ expect(localStorage.getItem('leaveBalances')).toBe('[]');
+ });
+
+ it('keeps untouched random balances unsaved through settings, rule changes, and calculation', async () => {
+ const initialBalances = userInputService.vacationNumberSignal();
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+ const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+ const dialogRef = TestBed.inject(MatDialog).open(UserSettings);
+ const dialog = await loader.getHarness(MatDialogHarness);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+
+ const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
+ await selectOption(theme, 'Dark');
+ await selectOption(language, 'Français');
+ await (await dialog.getHarness(MatSlideToggleHarness)).check();
+ expect(localStorage.getItem('theme.preference')).toBe('"dark"');
+ expect(localStorage.getItem('app.language')).toBe('"fr"');
+ expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
+ dialogRef.close();
+
+ const rule = await loader.getHarness(
+ MatSlideToggleHarness.with({ selector: 'app-rules-panel mat-slide-toggle' }),
+ );
+ await rule.uncheck();
+ await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
+ expect(JSON.parse(localStorage.getItem('vacationRules')!)[0].enabled).toBe(false);
+ fixture.nativeElement.querySelector('.calculate-button').click();
+ await fixture.whenStable();
+
+ expect(userInputService.computeTriggerSignal()).toBe(1);
+ expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+ });
+
+ it('preserves unrelated stored preferences, rules, and other values when editing balances', () => {
+ const unrelated = {
+ 'theme.preference': '"system"',
+ 'app.language': '"en"',
+ 'calendarSettings.showWeekNumbers': 'true',
+ vacationRules: '[]',
+ 'unrelated.entry': 'keep this value',
+ };
+ for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value);
+
+ editInput('app-day-input input[type="number"]', '7');
+
+ for (const [key, value] of Object.entries(unrelated)) {
+ expect(localStorage.getItem(key)).toBe(value);
+ }
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(7);
+ });
+
+ it('keeps balance edits usable when storage fails and saves the next edit when it recovers', () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('Storage is unavailable', 'SecurityError');
+ });
+
+ editInput('app-day-input input[type="number"]', '7');
+ expect(userInputService.vacationNumberSignal()[0].numberOfDays).toBe(7);
+ expect(localStorage.getItem('leaveBalances')).toBeNull();
+
+ write.mockRestore();
+ editInput('app-day-input input[type="number"]', '8');
+ expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
+ });
});
diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts
index 5fc58a2..61d5758 100644
--- a/src/app/components/user-input/user-input.ts
+++ b/src/app/components/user-input/user-input.ts
@@ -82,20 +82,18 @@ export class UserInput {
}
public addDay() {
- this.userInputService.vacationNumberSignal.update((days) => [
+ this.userInputService.updateBalances((days) => [
...days,
this.userInputService.createVacationDay(),
]);
}
public removeFromList(index: number) {
- this.userInputService.vacationNumberSignal.update((days) =>
- days.filter((_, i) => i !== index),
- );
+ this.userInputService.updateBalances((days) => days.filter((_, i) => i !== index));
}
public updateDay(index: number, updatedDay: VacDay) {
- this.userInputService.vacationNumberSignal.update((days) =>
+ this.userInputService.updateBalances((days) =>
days.map((day, i) => (i === index ? updatedDay : day)),
);
}
diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts
index 0c76ad6..7415bc2 100644
--- a/src/app/services/user-input-service.ts
+++ b/src/app/services/user-input-service.ts
@@ -1,5 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
+import dayjs from 'dayjs';
@Injectable({
providedIn: 'root',
@@ -51,6 +52,26 @@ export class UserInputService {
readonly remainingVacationDaysSignal = signal([]);
readonly computeTriggerSignal = signal(0);
+ public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
+ this.vacationNumberSignal.update(updater);
+ try {
+ if (typeof localStorage === 'undefined') return;
+ // Store calendar dates, so restoring them does not depend on a UTC offset.
+ const balances = this.vacationNumberSignal().map((balance) => ({
+ id: balance.id,
+ type: balance.type,
+ numberOfDays: balance.numberOfDays,
+ availableDate: balance.availableDate
+ ? dayjs(balance.availableDate).format('YYYY-MM-DD')
+ : undefined,
+ expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
+ }));
+ localStorage.setItem('leaveBalances', JSON.stringify(balances));
+ } catch (error) {
+ console.warn('Failed to save leave balances to localStorage.', error);
+ }
+ }
+
public requestComputation(): void {
this.computeTriggerSignal.update((count) => count + 1);
}