diff --git a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md index 5014cd6..24be004 100644 --- a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md +++ b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md @@ -6,7 +6,7 @@ Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_it VacOptimizer accepts explicitly selected, fixed planning horizons whose start may be past and whose duration may exceed twelve months, retaining today through the same date twelve months later as the default. Calculations remain in the browser and run to completion without an automatic elapsed-time cutoff, with an indicative duration estimate and an explicit Cancel action; this accepts variable waiting time and resource use to make longer horizons usable without silently changing the user's dates. This supersedes ADR 0003's ten-second automatic planning limit and its retention in both ADR 0004 documents, while preserving the separation between the planning outcome, stable explanation, and original trace. -The settings' end date is the inclusive last planning date. The planner retains its half-open horizon by using the following calendar date as its exclusive end, matching the inclusive date-selection convention of TIME_WINDOW rules. +The planning-period control in Configure your leave uses an inclusive last planning date. The planner retains its half-open horizon by using the following calendar date as its exclusive end, matching the inclusive date-selection convention of TIME_WINDOW rules. Editing the horizon does not trigger a calculation. The user can adjust dates, balances, and rules before using the existing Calculate action; until a new calculation is requested, the displayed calendar retains its preceding outcome and horizon. diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts index 80dbcfe..9168844 100644 --- a/src/app/app-settings.spec.ts +++ b/src/app/app-settings.spec.ts @@ -339,6 +339,53 @@ describe('Application Settings', () => { expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1); }); + it('saves planning dates before balances and recalculates only when Calculate is selected', async () => { + const root = fixture.nativeElement as HTMLElement; + const period = root.querySelector('app-user-input .planning-dates')!; + expect( + period.compareDocumentPosition(root.querySelector('.listOfDays')!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(period.querySelector('legend')?.textContent).toBe('Planning period'); + expect(period.querySelector('#planning-dates-help')?.textContent).toContain( + 'Calculate plan', + ); + const start = period.querySelector('input[name="planningStart"]')!; + const last = period.querySelector('input[name="lastPlanningDate"]')!; + start.value = '2024-02-29'; + start.dispatchEvent(new Event('input')); + last.value = '2024-03-08'; + last.dispatchEvent(new Event('input')); + await fixture.whenStable(); + expect(calculate).toHaveBeenCalledOnce(); + expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ + start: '2024-02-29', + lastPlanningDate: '2024-03-08', + }); + + root.querySelector('.calculate-button')!.click(); + await fixture.whenStable(); + expect(calculate).toHaveBeenCalledTimes(2); + expect(calculate.mock.calls[1][0].horizon).toEqual({ + start: '2024-02-29', + end: '2024-03-09', + }); + const dialog = await openSettings(); + expect(await dialog.getContentText()).not.toContain('Planning period'); + const [, language] = await dialog.getAllHarnesses(MatSelectHarness); + await selectOption(language, 'Français'); + expect(period.querySelector('legend')?.textContent).toBe('Période à planifier'); + await dialog.close(); + + await revisitApp(true); + expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe( + '2024-02-29', + ); + expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe( + '2024-03-08', + ); + }); + it('supports keyboard theme selection and moves focus into the dialog', async () => { const dialog = await openSettings(); const [theme] = await dialog.getAllHarnesses(MatSelectHarness); diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 12fb305..82af8c7 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -7,6 +7,56 @@ > +
+ {{ tr().settings.planningHorizon }} +

{{ tr().settings.planningDatesHelp }}

+ + {{ tr().settings.planningStart }} + + + + {{ tr().settings.lastPlanningDate }} + + + @if (calendarSettings.planningHorizonError(); as error) { + + } +
+
@for (day of daysOff; track day.id) {
@@ -80,7 +130,7 @@ [disabled]="!!calendarSettings.planningHorizonError()" [attr.aria-describedby]=" calendarSettings.planningHorizonError() - ? 'invalid-planning-horizon' + ? 'planning-dates-error' : 'planning-duration-estimate' " (click)="triggerComputation()" @@ -88,9 +138,6 @@ {{ tr().userInput.calculate }} - @if (calendarSettings.planningHorizonError()) { - - } @if (hasRemainingDays()) { diff --git a/src/app/components/user-input/user-input.scss b/src/app/components/user-input/user-input.scss index a1df6f9..ae3cb5d 100644 --- a/src/app/components/user-input/user-input.scss +++ b/src/app/components/user-input/user-input.scss @@ -31,6 +31,41 @@ } } +.planning-dates { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-column: 1 / -1; + column-gap: 1rem; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + + legend { + font-weight: 600; + } + + mat-form-field { + min-width: 0; + width: 100%; + } + + p { + grid-column: 1 / -1; + margin: 0.5rem 0 1rem; + color: var(--ui-text-secondary); + font-size: 0.875rem; + line-height: 1.5; + } + + .validation-error { + color: var(--ui-error-text); + background: var(--ui-error-bg); + padding: 0.75rem; + border-radius: 0.5rem; + } +} + .listOfDays { display: flex; flex-direction: column; @@ -250,6 +285,9 @@ form { } @container user-input (min-width: 40rem) { + .planning-dates { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .listOfDays { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts index 413d853..0f82465 100644 --- a/src/app/components/user-input/user-input.spec.ts +++ b/src/app/components/user-input/user-input.spec.ts @@ -202,24 +202,62 @@ describe('UserInput', () => { it('prevents calculation while planning dates are invalid and re-enables it after correction', () => { const settings = TestBed.inject(CalendarSettingsService); - settings.setPlanningDates('2024-03-01', '2024-02-29'); - fixture.detectChanges(); + settings.setPlanningDates('2024-03-01', '2024-03-01'); + editInput('input[name="lastPlanningDate"]', '2024-02-29'); const root = fixture.nativeElement as HTMLElement; const calculate = root.querySelector('.calculate-button')!; expect(calculate.disabled).toBe(true); - expect(root.querySelector('[role="alert"]')?.textContent).toContain( - 'planning dates in Settings', - ); + expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after'); + expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error'); calculate.click(); component.triggerComputation(); expect(userInputService.computeTriggerSignal()).toBe(0); - settings.setPlanningDates('2024-02-29', '2024-02-29'); - fixture.detectChanges(); + editInput('input[name="planningStart"]', '2024-02-29'); expect(calculate.disabled).toBe(false); calculate.click(); expect(userInputService.computeTriggerSignal()).toBe(1); }); + it.each(['', '2024-02-30', 'not-a-date'])( + 'reports an invalid planning date without saving it: %s', + (value) => { + const settings = TestBed.inject(CalendarSettingsService); + settings.setPlanningDates('2024-02-29', '2027-02-28'); + editInput('input[name="planningStart"]', value); + expect( + fixture.nativeElement.querySelector('#planning-dates-error')?.textContent, + ).toContain('two valid calendar dates'); + expect(settings.planningHorizon()).toBeNull(); + expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ + start: '2024-02-29', + lastPlanningDate: '2027-02-28', + }); + }, + ); + + it('edits inclusive dates with inline validation while preserving the saved planning horizon', () => { + const settings = TestBed.inject(CalendarSettingsService); + settings.setPlanningDates('2024-02-29', '2027-02-28'); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + const start = root.querySelector('input[name="planningStart"]')!; + const last = root.querySelector('input[name="lastPlanningDate"]')!; + expect(start.value).toBe('2024-02-29'); + expect(last.value).toBe('2027-02-28'); + expect(root.textContent).toContain('Last planning date (inclusive)'); + editInput('input[name="lastPlanningDate"]', '2024-02-28'); + expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after'); + expect(last.getAttribute('aria-invalid')).toBe('true'); + expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error'); + expect(settings.planningHorizon()).toBeNull(); + editInput('input[name="lastPlanningDate"]', '2024-02-29'); + expect(root.querySelector('#planning-dates-error')).toBeNull(); + expect(last.getAttribute('aria-invalid')).toBe('false'); + expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error'); + expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' }); + expect(userInputService.computeTriggerSignal()).toBe(0); + }); + it('should initialize with vacation days from service', () => { const vacationDays = userInputService.vacationNumberSignal(); expect(vacationDays).toBeDefined(); diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index 37f183c..cc99140 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -7,6 +7,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { UserInputService } from '../../services/user-input-service'; import { RulesPanel } from '../rules-panel/rules-panel'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -24,6 +25,7 @@ import { addPlanningDays } from '../../services/planning-date'; selector: 'app-user-input', imports: [ MatInputModule, + MatFormFieldModule, RulesPanel, MatCard, MatCardContent, diff --git a/src/app/components/user-settings/user-settings.html b/src/app/components/user-settings/user-settings.html index 73dba40..f976c58 100644 --- a/src/app/components/user-settings/user-settings.html +++ b/src/app/components/user-settings/user-settings.html @@ -2,53 +2,6 @@

{{ tr().settings.description }}

-
- {{ tr().settings.planningHorizon }} -

{{ tr().settings.planningDatesHelp }}

- - {{ tr().settings.planningStart }} - - - - {{ tr().settings.lastPlanningDate }} - - - @if (calendarSettings.planningHorizonError(); as error) { - - } -
{{ tr().settings.selectTheme }} p { mat-form-field { width: 100%; } - -.planning-dates { - min-width: 0; - margin: 0 0 1rem; - padding: 0; - border: 0; - - legend { - font-weight: 600; - } - - p { - margin: 0.5rem 0 1rem; - color: var(--ui-text-secondary); - } - - .validation-error { - color: var(--ui-error-text); - background: var(--ui-error-bg); - padding: 0.75rem; - border-radius: 0.5rem; - } -} diff --git a/src/app/components/user-settings/user-settings.spec.ts b/src/app/components/user-settings/user-settings.spec.ts index 0ca2d9b..967838e 100644 --- a/src/app/components/user-settings/user-settings.spec.ts +++ b/src/app/components/user-settings/user-settings.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { UserSettings } from './user-settings'; -import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { TranslationService } from '../../services/translation-service'; describe('UserSettings', () => { @@ -16,7 +15,6 @@ describe('UserSettings', () => { }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); - TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28'); fixture = TestBed.createComponent(UserSettings); component = fixture.componentInstance; fixture.detectChanges(); @@ -24,49 +22,8 @@ describe('UserSettings', () => { afterEach(() => localStorage.clear()); - it.each(['', '2024-02-30', 'not-a-date'])( - 'reports an invalid date entry without saving it: %s', - (value) => { - const root = fixture.nativeElement as HTMLElement; - const start = root.querySelector('input[name="planningStart"]')!; - start.value = value; - start.dispatchEvent(new Event('input')); - fixture.detectChanges(); - expect(root.querySelector('[role="alert"]')?.textContent).toContain( - 'two valid calendar dates', - ); - expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull(); - expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ - start: '2024-02-29', - lastPlanningDate: '2027-02-28', - }); - }, - ); - - it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => { - const root = fixture.nativeElement as HTMLElement; - const start = root.querySelector('input[name="planningStart"]')!; - const last = root.querySelector('input[name="lastPlanningDate"]')!; - expect(start.value).toBe('2024-02-29'); - expect(last.value).toBe('2027-02-28'); - expect(root.textContent).toContain('Last planning date (inclusive)'); - last.value = '2024-02-28'; - last.dispatchEvent(new Event('input')); - fixture.detectChanges(); - expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after'); - expect(last.getAttribute('aria-invalid')).toBe('true'); - expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull(); - last.value = '2024-02-29'; - last.dispatchEvent(new Event('input')); - fixture.detectChanges(); - expect(root.querySelector('[role="alert"]')).toBeNull(); - expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({ - start: '2024-02-29', - end: '2024-03-01', - }); - }); - it('should create', () => { expect(component).toBeTruthy(); + expect(fixture.nativeElement.querySelector('input[type="date"]')).toBeNull(); }); }); diff --git a/src/app/components/user-settings/user-settings.ts b/src/app/components/user-settings/user-settings.ts index a7f3772..2374184 100644 --- a/src/app/components/user-settings/user-settings.ts +++ b/src/app/components/user-settings/user-settings.ts @@ -9,7 +9,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggle } from '@angular/material/slide-toggle'; -import { MatInputModule } from '@angular/material/input'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { ThemeService } from '../../services/theme-service'; import { TranslationService } from '../../services/translation-service'; @@ -25,7 +24,6 @@ import { TranslationService } from '../../services/translation-service'; MatFormFieldModule, MatSelectModule, MatSlideToggle, - MatInputModule, ], templateUrl: './user-settings.html', styleUrl: './user-settings.scss', diff --git a/src/i18n/de.json b/src/i18n/de.json index 4f08b9f..ea1af2f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -17,7 +17,7 @@ }, "userInput": { "title": "Urlaub konfigurieren", - "description": "Fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.", + "description": "Wählen Sie den Planungszeitraum und fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.", "addVacationDay": "Urlaubskonto hinzufügen", "calculate": "Plan berechnen", "durationEstimate": "Ungefähre Wartezeit: {range} {unit}", @@ -83,7 +83,7 @@ "planningDatesHelp": "Wählen Sie beliebige Daten, auch vergangene. Der Kalender wird aktualisiert, wenn Sie Plan berechnen auswählen.", "invalidDate": "Geben Sie zwei gültige Kalenderdaten ein.", "horizonOrder": "Der letzte Planungstag muss am oder nach dem Startdatum liegen.", - "invalidHorizon": "Korrigieren Sie die Planungsdaten in den Einstellungen vor der Berechnung." + "invalidHorizon": "Korrigieren Sie die Planungsdaten vor der Berechnung." }, "rules": { "title": "Planungsregeln", diff --git a/src/i18n/en.json b/src/i18n/en.json index 432d629..1c01223 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -17,7 +17,7 @@ }, "userInput": { "title": "Configure your leave", - "description": "Add each leave balance and its validity period.", + "description": "Choose a planning period, then add each leave balance and its validity period.", "addVacationDay": "Add leave balance", "calculate": "Calculate plan", "durationEstimate": "Approximate wait: {range} {unit}", @@ -77,13 +77,13 @@ "dark": "Dark" }, "systemAppearance": "Current system appearance", - "planningHorizon": "Planning horizon", + "planningHorizon": "Planning period", "planningStart": "Start date (inclusive)", "lastPlanningDate": "Last planning date (inclusive)", "planningDatesHelp": "Choose any dates, including past dates. Your calendar updates when you select Calculate plan.", "invalidDate": "Enter two valid calendar dates.", "horizonOrder": "The last planning date must be on or after the start date.", - "invalidHorizon": "Correct the planning dates in Settings before calculating." + "invalidHorizon": "Correct the planning dates before calculating." }, "rules": { "title": "Planning rules", diff --git a/src/i18n/es.json b/src/i18n/es.json index 04ad197..43cd5fd 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -17,7 +17,7 @@ }, "userInput": { "title": "Configura tus vacaciones", - "description": "Añade cada saldo de vacaciones y su periodo de validez.", + "description": "Elige el período de planificación y añade cada saldo de vacaciones y su periodo de validez.", "addVacationDay": "Añadir saldo de vacaciones", "calculate": "Calcular plan", "durationEstimate": "Espera aproximada: {range} {unit}", @@ -77,13 +77,13 @@ "dark": "Oscuro" }, "systemAppearance": "Apariencia actual del sistema", - "planningHorizon": "Horizonte de planificación", + "planningHorizon": "Período de planificación", "planningStart": "Fecha de inicio (incluida)", "lastPlanningDate": "Última fecha de planificación (incluida)", "planningDatesHelp": "Elige cualquier fecha, incluso pasada. El calendario se actualiza al seleccionar Calcular plan.", "invalidDate": "Introduce dos fechas válidas.", "horizonOrder": "La última fecha de planificación debe ser igual o posterior a la fecha de inicio.", - "invalidHorizon": "Corrige las fechas de planificación en Ajustes antes de calcular." + "invalidHorizon": "Corrige las fechas de planificación antes de calcular." }, "rules": { "title": "Reglas de planificación", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 599a00e..989a7d0 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -17,7 +17,7 @@ }, "userInput": { "title": "Configurez vos congés", - "description": "Ajoutez chaque solde de congés et sa période de validité.", + "description": "Choisissez la période à planifier, puis ajoutez chaque solde de congés et sa période de validité.", "addVacationDay": "Ajouter un solde de congés", "calculate": "Calculer le planning", "durationEstimate": "Attente indicative : {range} {unit}", @@ -77,13 +77,13 @@ "dark": "Sombre" }, "systemAppearance": "Apparence actuelle du système", - "planningHorizon": "Horizon de planification", + "planningHorizon": "Période à planifier", "planningStart": "Date de début (incluse)", "lastPlanningDate": "Dernière date de planification (incluse)", "planningDatesHelp": "Choisissez vos dates, même passées. Le calendrier est mis à jour lorsque vous cliquez sur Calculer le planning.", "invalidDate": "Saisissez deux dates calendaires valides.", "horizonOrder": "La dernière date de planification doit être égale ou postérieure à la date de début.", - "invalidHorizon": "Corrigez les dates de planification dans les paramètres avant de calculer." + "invalidHorizon": "Corrigez les dates de planification avant de calculer." }, "rules": { "title": "Règles de planification",