UI improvments

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent c9dbe82b79
commit 0ce33cc9d6
106 files changed
+72934 -1675

No files matched your search

@@ -1,5 +1,7 @@
<div class="calendar" role="group" [attr.aria-label]="getMonthName() + ' ' + year">
<h3>{{ getMonthName() }} {{ year }}</h3>
<h3>
{{ getMonthName() }} <span>{{ year }}</span>
</h3>
<div>
<div class="row header">
@if (calendarSettingsService.get('showWeekNumbers')()) {
@@ -1,12 +1,22 @@
.calendar {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
align-items: stretch;
gap: 1rem;
h3 {
margin: 0;
font-weight: normal;
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
span {
font-weight: 400;
font-size: 0.8125rem;
color: var(--ui-text-secondary);
}
}
}
@@ -17,8 +27,13 @@
width: 100%;
&.header {
font-weight: bold;
margin-bottom: 8px;
font-weight: 500;
font-size: 0.75rem;
margin-bottom: 0.25rem;
.day {
height: 1.5rem;
color: var(--ui-text-secondary);
}
}
.day {
@@ -27,12 +42,12 @@
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 26px;
flex: 1;
min-width: 0;
height: 2.75rem;
margin: 0;
margin-top: 2px;
margin-bottom: 2px;
border-radius: 30%;
margin-block: 0.125rem;
border-radius: 0.5rem;
border: 1px solid transparent;
color: var(--number-color);
font: inherit;
@@ -42,7 +57,14 @@
visibility: hidden;
}
&.number:hover {
&.number {
background-color: var(--ui-card-bg);
transition:
background-color 150ms ease,
color 150ms ease;
}
&.number:hover:not(:disabled) {
background-color: var(--number-bg-hover);
color: var(--number-color-hover);
cursor: pointer;
@@ -143,57 +165,54 @@
}
> .start:not(.end) {
border-radius: 30% 0 0 30%;
border-radius: 0.5rem 0 0 0.5rem;
}
> .end:not(.start) {
border-radius: 0 30% 30% 0;
border-radius: 0 0.5rem 0.5rem 0;
}
}
.today {
.row .day.today {
border-color: var(--today-border-color);
border-width: 2px;
border-style: dashed !important;
:has(.dark-mode) {
border-color: #0b385c;
}
}
.other-month {
color: var(--other-month-color);
}
.weekend {
.row .day.weekend {
background-color: var(--weekend-bg);
}
.closed-day {
.row .day.closed-day {
border: 2px solid var(--closed-day-bg);
}
.rtt {
.row .day.rtt {
background-color: var(--rtt-bg);
}
.cp {
.row .day.cp {
background-color: var(--cp-bg);
}
.other {
.row .day.other {
background-color: var(--other-bg);
}
.row .week-number {
height: 30px;
font-weight: bold;
height: 2.75rem;
font-weight: 500;
color: var(--week-number-color);
margin-right: 0.5rem;
margin-right: 0.25rem;
max-width: 1.75rem;
font-size: 0.75rem;
background-color: var(--week-number-bg);
border-radius: 0;
margin-top: 0;
margin-bottom: 0;
margin-block: 0.125rem;
}
.row .week-number:hover {
@@ -18,9 +18,11 @@ import {
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
PlanningObjectiveVector,
PlanningTrace,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { TranslationService } from '../../services/translation-service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -292,7 +294,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('shows the first plan explanation by default and navigates placements', async () => {
it('keeps the calendar clear until a placement is selected and alternatives are requested', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
@@ -332,7 +334,23 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
const placement = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
) as HTMLButtonElement;
placement.click();
fixture.detectChanges();
expect(details.open).toBe(true);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.technical-details').open).toBe(false);
const compare = fixture.nativeElement.querySelector(
'.compare-dates-button',
) as HTMLButtonElement;
compare.click();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-03',
@@ -373,12 +391,314 @@ describe('CalendarYear planning outcome boundary', () => {
'2024-05-06',
);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
details.open = false;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
});
it('explains a whole break and compares another date against the current plan', async () => {
const before: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 1 },
{ id: 'preference:spring', direction: 'maximize', value: 1 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 0 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 12 },
{ id: 'placement-date', direction: 'minimize', value: 'removed' },
],
};
const current: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
{ id: 'preference:spring', direction: 'maximize', value: 2 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 4 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 6 },
{ id: 'placement-date', direction: 'minimize', value: '2024-05-03' },
],
};
const alternative: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
{ id: 'preference:spring', direction: 'maximize', value: 1 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 2 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 8 },
{ id: 'placement-date', direction: 'minimize', value: '2024-05-07' },
],
};
const readable: PlanningExplanation = {
...explanation,
placements: explanation.placements.map((selected) => ({
...selected,
timeOffPeriod: {
start: '2024-05-03',
end: '2024-05-06',
duration: 4,
placementDates: ['2024-05-03', '2024-05-06'],
nonWorkingDates: ['2024-05-04', '2024-05-05'],
nonWorkingDayGain: 2,
},
objectiveVector: current,
comparison: {
before,
after: current,
delta: [],
firstDecisiveObjective: 'unplaced-at-risk',
},
})),
};
const { calendar, calculation } = calendarFor(plan, readable);
calculation.snapshot.rules = [
{
id: 'spring',
name: 'Spring break',
type: 'time_window',
force: 'preference',
startDate: '2024-05-01',
endDate: '2024-05-06',
placeDays: 2,
placeType: 'cp',
},
];
const analysis: CounterfactualAnalysis = {
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: before,
results: [
{
date: '2024-05-03',
status: 'eligible',
comparison: { before, after: current, delta: [] },
},
{
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
},
{
date: '2024-05-07',
status: 'eligible',
comparison: { before, after: alternative, delta: [] },
},
{
date: '2024-05-09',
status: 'eligible',
comparison: { before, after: current, delta: [] },
},
],
};
vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(analysis);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element: HTMLElement = document.body;
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
fixture.detectChanges();
expect(
Array.from(element.querySelectorAll('.break-equation strong'), (node) =>
node.textContent?.trim(),
),
).toEqual(['2', '2', '4']);
expect(element.querySelector('.leave-story')?.textContent).toContain(
'This date uses 1 day from Paid leave.',
);
expect(
Array.from(element.querySelectorAll('.day-impact td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['1', '0', '1', '2', '0', '4', '12', '6']);
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Preference: Spring break',
);
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Leave days in this preferred period.',
);
expect(element.querySelector('.day-impact')?.textContent).not.toContain('placement-date');
const details = element.querySelector<HTMLDetailsElement>('.technical-details')!;
details.open = true;
expect(details.querySelectorAll('.criteria-table')).toHaveLength(1);
expect(details.querySelector('.decisive-row')?.textContent).toContain(
'Leave left at risk of expiry',
);
expect(details.querySelector('.decisive-label')?.textContent).toContain('First difference');
expect(details.querySelector('.balance-ledger')?.textContent).toContain(
'Unused days in the final plan',
);
expect(details.querySelectorAll('.criteria-table tbody tr')).toHaveLength(4);
expect(details.querySelector('.criteria-detail')?.textContent).not.toContain('2024-05-03');
expect(details.querySelector('.tie-breaks')?.textContent).toContain('Earlier dates first');
expect(details.querySelector<HTMLDetailsElement>('.criterion-help')?.open).toBe(false);
expect(details.querySelector<HTMLDetailsElement>('.audit-data')?.open).toBe(false);
expect(JSON.parse(details.querySelector('pre')!.textContent!).objectiveVector).toEqual(
current,
);
element.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
await fixture.whenStable();
fixture.detectChanges();
const input = element.querySelector<HTMLInputElement>('.compare-date-field input')!;
expect(input.type).toBe('text');
expect(input.value).toBe('07/05/2024');
element
.querySelector<HTMLButtonElement>('.compare-date-field mat-datepicker-toggle button')!
.click();
await fixture.whenStable();
fixture.detectChanges();
expect(document.querySelector('mat-calendar')).not.toBeNull();
const chosenDay = document.querySelector<HTMLButtonElement>(
'mat-calendar button[aria-label="3 May 2024"]',
)!;
chosenDay.click();
await fixture.whenStable();
fixture.detectChanges();
expect(input.value).toBe('03/05/2024');
expect(fixture.componentInstance['selectedCounterfactualDate']()).toBe('2024-05-03');
expect(fixture.nativeElement.querySelector('.counterfactual-panel')).toBeNull();
expect(element.querySelector('[role="dialog"]')).not.toBeNull();
expect(
fixture.componentInstance['nearbyComparisonDates']().map((result) => result.date),
).toEqual(['2024-05-07', '2024-05-09']);
input.value = '03/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'This is the planned date.',
);
input.value = '07/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
// Both alternatives share the removed-day baseline, but moving must compare
// current vs alternative (4 -> 2, 6 -> 8), not removed vs alternative (0 -> 2).
expect(
Array.from(
element.querySelectorAll('.counterfactual-result > .impact-table td'),
(node) => node.textContent?.trim(),
),
).toEqual(['2', '1', '4', '2', '6', '8']);
const dateDetails = element.querySelector('.date-calculation-details')!;
expect(
Array.from(dateDetails.querySelectorAll('.criteria-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['0', '0', '2', '1', '4', '2', '6', '8']);
expect(dateDetails.querySelector('.decisive-row')?.textContent).toContain(
'Preference: Spring break',
);
expect(dateDetails.querySelector('thead')?.textContent).toContain('Current plan');
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
input.value = '';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(input.value).toBe('');
expect(element.querySelector('.counterfactual-result')).toBeNull();
expect(element.querySelector('.comparison-empty')?.textContent).toContain('Choose a date');
input.value = '01/01/2025';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(input.value).toBe('01/01/2025');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(element.querySelector('#comparison-date-error')).not.toBeNull();
element.querySelector<HTMLButtonElement>('.nearby-dates button')!.click();
fixture.detectChanges();
expect(input.value).toBe('07/05/2024');
expect(input.getAttribute('aria-invalid')).toBe('false');
expect(input.getAttribute('aria-describedby')).toContain('comparison-range');
input.value = '09/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'These totals are unchanged.',
);
expect(element.querySelector('.counterfactual-result > .impact-table')).toBeNull();
input.value = '04/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'No leave is needed.',
);
TestBed.inject(TranslationService).setLanguage('fr');
fixture.detectChanges();
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Congés risquant d’expirer',
);
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'Aucun congé à poser.',
);
TestBed.inject(TranslationService).setLanguage('en');
fixture.detectChanges();
await fixture.whenStable();
});
it('dismisses a pending comparison, ignores its late result, and keeps errors inside the dialog', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
let finish!: (result: CounterfactualAnalysis) => void;
vi.spyOn(calculation, 'analyzeCounterfactual')
.mockImplementationOnce(
() =>
new Promise<CounterfactualAnalysis>((resolve) => {
finish = resolve;
}),
)
.mockRejectedValueOnce(new Error('worker unavailable'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
fixture.detectChanges();
const compare = element.querySelector<HTMLButtonElement>('.compare-dates-button')!;
compare.click();
fixture.detectChanges();
expect(document.querySelector('.comparison-loading')).not.toBeNull();
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
await fixture.whenStable();
finish({
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: objectiveVector,
results: [{ date: '2024-05-03', status: 'eligible' }],
});
await fixture.whenStable();
fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(fixture.componentInstance['counterfactual']()).toBeNull();
expect(fixture.componentInstance['counterfactualLoading']()).toBe(false);
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
compare.click();
await fixture.whenStable();
fixture.detectChanges();
expect(
document.querySelector('.counterfactual-panel .inline-error')?.textContent,
).toContain('Date alternatives could not be calculated.');
expect(document.querySelector('.comparison-done')).not.toBeNull();
document.querySelector<HTMLButtonElement>('.comparison-done')!.click();
await fixture.whenStable();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it('renders conflict causes and replays an atomic trace decision', async () => {
const conflictExplanation: PlanningExplanation = {
kind: 'ConflictExplanation',
@@ -440,6 +760,7 @@ describe('CalendarYear planning outcome boundary', () => {
status: 'chosen',
date: '2024-05-03',
placement: plan.placements[0],
comparison: explanation.placements[0].comparison,
},
],
deltas: [
@@ -490,6 +811,14 @@ describe('CalendarYear planning outcome boundary', () => {
) as HTMLButtonElement;
firstPhase.click();
fixture.detectChanges();
expect(
Array.from(
fixture.nativeElement.querySelectorAll(
'.candidate-list .criteria-table td',
) as NodeListOf<HTMLElement>,
(node) => node.textContent?.trim(),
),
).toEqual(['3', '4']);
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
beforeButton.click();
fixture.detectChanges();
@@ -607,10 +936,10 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.componentInstance['liveMessage']()).toContain(
'Date alternatives: Ineligible',
);
const counterfactualResult = fixture.nativeElement.querySelector(
const counterfactualResult = document.querySelector(
'.counterfactual-result',
) as HTMLElement;
expect(counterfactualResult.textContent).toContain('Ineligible');
expect(counterfactualResult.textContent).toContain('This date can’t be used');
expect(counterfactualResult.textContent).toContain('Original search status: Ineligible');
const chosenDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
@@ -619,8 +948,34 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
expect(chosenDate.getAttribute('aria-label')).toContain('Chosen');
expect(chosenDate.getAttribute('aria-label')).toContain('Date alternatives: Eligible');
expect(counterfactualResult.textContent).toContain('Eligible');
expect(counterfactualResult.textContent).toContain('Available date');
expect(counterfactualResult.textContent).toContain('Original search status: Chosen');
expect(counterfactualResult.textContent).toContain('Before this decision');
expect(
Array.from(counterfactualResult.querySelectorAll(':scope > .impact-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
expect(
Array.from(counterfactualResult.querySelectorAll('.criteria-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
// Closing the trace restores the actual plan and clears hypothetical markers.
const traceDetails = fixture.nativeElement.querySelector(
'.trace-explorer',
) as HTMLDetailsElement;
traceDetails.open = true;
traceDetails.dispatchEvent(new Event('toggle'));
beforeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(true);
traceDetails.open = false;
traceDetails.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(false);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
expect(fixture.componentInstance['dateStatuses']()).toEqual({});
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+243 -10
View File
@@ -1,7 +1,25 @@
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import {
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { JsonPipe, NgTemplateOutlet } from '@angular/common';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
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 { 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 {
@@ -23,6 +41,7 @@ import {
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningTrace,
@@ -37,6 +56,12 @@ type TraceMoment = 'before' | 'after';
@Component({
selector: 'app-calendar-year',
imports: [
JsonPipe,
NgTemplateOutlet,
MatDialogContent,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatCard,
CalendarMonth,
MatCardContent,
@@ -46,11 +71,18 @@ type TraceMoment = 'before' | 'after';
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
private readonly comparisonTemplate =
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
private comparisonDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
@@ -62,11 +94,15 @@ export class CalendarYear {
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(true);
protected readonly planDetailsOpen = signal(false);
protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly tracePhaseIndex = signal(0);
protected readonly traceDecisionIndex = signal(0);
protected readonly traceCandidatePage = signal(0);
@@ -126,6 +162,70 @@ export class CalendarYear {
),
);
protected readonly nearbyComparisonDates = computed(() => {
const analysis = this.counterfactual();
if (!analysis) return [];
const original = Date.parse(analysis.placement.date);
return analysis.results
.filter(
(result) => result.status === 'eligible' && result.date !== analysis.placement.date,
)
.sort(
(a, b) =>
Math.abs(Date.parse(a.date) - original) -
Math.abs(Date.parse(b.date) - original) || a.date.localeCompare(b.date),
)
.slice(0, 3);
});
protected changeComparisonDate(event: MatDatepickerInputEvent<Date>): void {
const selected = event.value;
// Material returns local midnight; converting to UTC can change the calendar day.
const date = selected
? [
selected.getFullYear(),
String(selected.getMonth() + 1).padStart(2, '0'),
String(selected.getDate()).padStart(2, '0'),
].join('-')
: null;
const valid = !!this.counterfactual()?.results.some((result) => result.date === date);
this.invalidComparisonDate.set(!!(event.targetElement as HTMLInputElement).value && !valid);
if (valid && date) this.selectCalendarDate(date);
else this.selectedCounterfactualDate.set(null);
}
protected readonly placementChanges = computed(() => {
const comparison = this.selectedExplanation()?.comparison;
return this.numericChanges(comparison?.before, comparison?.after);
});
protected readonly dateComparison = computed(() => {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
// Final-plan alternatives share a removed-day baseline. Compare their restored
// plans; a trace candidate instead uses the exact state before its decision.
const before = this.traceReplay()
? comparison.before
: analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
?.after;
return before
? {
before,
after: comparison.after,
firstDecisiveObjective: comparison.after.objectives.find(
(objective) =>
before.objectives.find((item) => item.id === objective.id)?.value !==
objective.value,
)?.id,
}
: undefined;
});
protected readonly dateChanges = computed(() =>
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined;
return this.replayTraceState(
@@ -294,6 +394,8 @@ export class CalendarYear {
});
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
this.destroyRef.onDestroy(() => this.clearCounterfactual());
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
@@ -384,7 +486,8 @@ export class CalendarYear {
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(true);
this.planDetailsOpen.set(false);
this.traceOpen.set(false);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
@@ -456,19 +559,34 @@ export class CalendarYear {
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set(
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
);
void this.analyzeSelection(explanation.placements[selected].placement);
}
protected compareDates(): void {
const placement = this.selectedExplanation()?.placement;
if (placement) void this.analyzeSelection(placement);
}
protected togglePlanDetails(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) this.selectPlacement(this.selectedPlacementIndex());
else this.clearCounterfactual();
if (open) {
this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
protected toggleTrace(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.traceOpen()) return;
this.traceOpen.set(open);
if (open) this.planDetailsOpen.set(false);
this.showFinalPlan();
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
@@ -486,12 +604,22 @@ export class CalendarYear {
}
protected selectCalendarDate(date: string): void {
if (!this.traceReplay() && this.planDetailsOpen()) {
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
this.planDetails()
?.nativeElement.querySelector('summary')
?.focus({ preventScroll: true });
return;
}
}
@@ -629,13 +757,35 @@ export class CalendarYear {
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (!this.comparisonDialog) {
const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'counterfactual-title',
ariaDescribedBy: 'comparison-intro',
});
this.comparisonDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.comparisonDialog === dialog) {
this.comparisonDialog = null;
this.clearCounterfactual();
}
});
}
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
this.selectedCounterfactualDate.set(result.placement.date);
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
@@ -647,12 +797,17 @@ export class CalendarYear {
}
}
private clearCounterfactual(): void {
protected clearCounterfactual(): void {
const dialog = this.comparisonDialog;
this.comparisonDialog = null;
dialog?.close();
this.counterfactualVersion++;
this.counterfactual.set(null);
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(null);
}
private replayTraceState(
@@ -744,7 +899,85 @@ export class CalendarYear {
}
protected objectiveLabel(objective: PlanningObjective): string {
return objective.label ?? objective.id;
return this.objectiveCopy(objective.id)?.label ?? objective.label ?? objective.id;
}
protected objectiveRows(before: PlanningObjectiveVector, after: PlanningObjectiveVector) {
return after.objectives.map((objective) => ({
...objective,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: before.objectives.find((item) => item.id === objective.id)?.value,
numeric: typeof objective.value === 'number',
}));
}
protected hasTieBreaks(vector: PlanningObjectiveVector): boolean {
return vector.objectives.some((objective) => typeof objective.value === 'string');
}
protected objectiveCopy(id: string) {
const key = id.startsWith('preference:') ? 'preference' : id;
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
: undefined;
return (
copy && {
...copy,
label: copy.label.replace('{rule}', this.ruleLabel(id.slice('preference:'.length))),
help:
(rule &&
this.tr().planning.explanation.preferenceHelp[rule.type.toLowerCase()]) ??
copy.help,
}
);
}
protected phaseLabel(name: string): string {
return (
this.tr().planning.explanation.stages[name] ??
this.objectiveCopy(name)?.label ??
this.formatIdentifier(name)
);
}
protected leaveUsed(explanation: PlacementExplanation): string {
return this.tr().planning.explanation.leaveUsed.replace(
'{balance}',
this.balanceLabel(explanation),
);
}
private numericChanges(before?: PlanningObjectiveVector, after?: PlanningObjectiveVector) {
return (after?.objectives ?? []).flatMap((objective) => {
const previous = before?.objectives.find((item) => item.id === objective.id)?.value;
if (
typeof previous !== 'number' ||
typeof objective.value !== 'number' ||
previous === objective.value
)
return [];
return [
{
id: objective.id,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: previous,
after: objective.value,
},
];
});
}
protected displayDate(value: string): string {
return new Intl.DateTimeFormat(this.translationService.locale(), {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
}
protected planningDate(value: string | Date | undefined): string {
@@ -1,7 +1,6 @@
<button
animate.enter="enter-animation"
mat-fab
[color]="effectiveTheme() === 'light' ? 'primary' : 'accent'"
mat-icon-button
type="button"
(click)="toggleTheme()"
[attr.aria-label]="description()"
[attr.title]="description()"
@@ -1,37 +1,13 @@
button {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
min-height: 2.75rem;
transition:
background-color 200ms ease,
color 200ms ease;
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-color: rgba(0, 0, 0, 0);
cursor: pointer;
}
}
.enter-animation {
animation: slide-fade 1s;
}
@keyframes slide-fade {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
background: var(--mat-sys-primary-container);
}
}
@@ -1,11 +1,11 @@
import { Component, computed, inject } from '@angular/core';
import { EffectiveTheme, ThemePreference, ThemeService } from '../../services/theme-service';
import { MatIcon } from '@angular/material/icon';
import { MatFabButton } from '@angular/material/button';
import { MatIconButton } from '@angular/material/button';
@Component({
selector: 'app-dark-light-mode-button',
imports: [MatFabButton, MatIcon],
imports: [MatIconButton, MatIcon],
templateUrl: './dark-light-mode-button.html',
styleUrl: './dark-light-mode-button.scss',
})
+57 -38
View File
@@ -9,7 +9,7 @@
</mat-expansion-panel-header>
<div class="priority-statement">
<mat-icon color="primary">info</mat-icon>
<mat-icon aria-hidden="true">info_outline</mat-icon>
<span>{{ tr().rules.priorityInfo }}</span>
</div>
@@ -18,15 +18,22 @@
<div
class="rule-item"
cdkDrag
cdkDragLockAxis="y"
[cdkDragStartDelay]="{ touch: 180, mouse: 0 }"
[cdkDragDisabled]="editingRuleId() === rule.id"
[class.editing]="editingRuleId() === rule.id"
>
@if (editingRuleId() !== rule.id) {
<div class="rule-drag-grip">
<mat-icon class="drag-handle-icon">drag_handle</mat-icon>
</div>
<div class="rule-display">
<div class="rule-header">
<div
class="rule-drag-grip"
cdkDragHandle
[title]="tr().rules.dragRule"
aria-hidden="true"
>
<mat-icon>drag_indicator</mat-icon>
</div>
<mat-slide-toggle
[checked]="rule.enabled"
(change)="toggleRule(rule.id)"
@@ -37,41 +44,53 @@
{{ rule.name }}</span
>
</mat-slide-toggle>
<div class="rule-actions">
<button
mat-icon-button
[disabled]="i === 0"
[aria-label]="tr().rules.moveRuleUp"
(click)="moveRule(i, -1, $event)"
>
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<button
mat-icon-button
[disabled]="i === rules.length - 1"
[aria-label]="tr().rules.moveRuleDown"
(click)="moveRule(i, 1, $event)"
>
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
<div class="rule-summary" [innerHTML]="getRuleSummary(rule)"></div>
<div class="rule-summary">
<span
class="rule-force"
[class.obligation]="rule.force === 'obligation'"
>
{{
rule.force === 'obligation'
? tr().rules.obligation
: tr().rules.preference
}}
</span>
<span [innerHTML]="getRuleSummary(rule)"></span>
</div>
<div class="rule-actions">
<button
mat-icon-button
[disabled]="i === 0"
[aria-label]="tr().rules.moveRuleUp"
(click)="moveRule(i, -1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_up</mat-icon>
</button>
<button
mat-icon-button
[disabled]="i === rules.length - 1"
[aria-label]="tr().rules.moveRuleDown"
(click)="moveRule(i, 1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_down</mat-icon>
</button>
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
} @else {
<div class="rule-editor">
+116 -61
View File
@@ -1,9 +1,31 @@
.rules-panel-container,
:host {
min-width: 0;
}
:host {
display: block;
}
.rules-panel-container {
margin: 0;
box-shadow: none;
border: 1px solid var(--ui-border);
background: var(--ui-card-bg);
border-radius: var(--ui-radius-md, 0.75rem);
overflow: hidden;
container-type: inline-size;
mat-expansion-panel-header {
padding-inline: 0.75rem;
min-height: 3.5rem;
height: auto;
padding-block: 0.625rem;
}
mat-panel-title {
margin: 0 0.5rem 0 0;
}
mat-panel-title {
display: flex;
flex-wrap: wrap;
@@ -18,6 +40,7 @@
border-radius: 999px;
color: var(--mat-sys-on-primary-container, #001b3f);
font-size: 0.75rem;
white-space: nowrap;
font-weight: 600;
line-height: 1.5;
}
@@ -37,20 +60,16 @@
}
.rule-item {
container: rule-item / inline-size;
font: var(--mat-sys-body-medium);
letter-spacing: var(--mat-sys-body-medium-tracking);
border: 1px solid var(--mat-sys-outline-variant, #c7c7d0);
border-radius: var(--ui-radius-sm, 0.5rem);
transition:
border-color 200ms ease,
background-color 200ms ease;
background: var(--mat-sys-surface-container, #f1f1f5);
&:not(.editing) {
cursor: grab;
&:active {
cursor: grabbing;
}
}
background: var(--ui-panel-bg);
color: var(--mat-sys-on-surface);
&:hover:not(.editing) {
border-color: var(--mat-sys-outline, #777680);
@@ -63,15 +82,18 @@
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
border-color: var(--mat-sys-primary);
background: var(--ui-card-bg);
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 18%);
cursor: grabbing;
}
.cdk-drag-placeholder {
opacity: 0;
border: 1px dashed var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
> * {
visibility: hidden;
}
}
.cdk-drag-animating {
@@ -83,90 +105,116 @@
}
.rule-display {
padding: 16px;
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.priority-statement {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-primary-container, #d7e3ff);
color: var(--mat-sys-on-primary-container, #001b3f);
border-radius: 8px;
font-size: 0.95rem;
margin-top: 8px;
margin-bottom: 8px;
align-items: flex-start;
gap: 0.5rem;
color: var(--ui-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
margin-block: 0.25rem 0.75rem;
mat-icon {
color: var(--mat-sys-primary, #005cbb);
flex-shrink: 0;
}
}
.rule-drag-grip {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
padding-top: 8px;
margin-bottom: -8px;
color: var(--mat-sys-outline, #777680);
opacity: 0.6;
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-sm);
color: var(--ui-text-secondary);
cursor: grab;
transition: opacity 0.2s;
transition: background-color 150ms ease;
.rule-item:active & {
&:active,
.cdk-drag-preview & {
cursor: grabbing;
}
&:hover {
opacity: 1;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
}
.rule-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
align-items: center;
margin-bottom: 8px;
gap: 0.75rem;
mat-slide-toggle {
min-width: 0;
}
.rule-name {
font-weight: 600;
font-size: 1.05rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 1rem;
line-height: 1.4;
overflow-wrap: anywhere;
.rule-number {
color: var(--mat-sys-primary, #005cbb);
font-weight: 700;
}
}
}
.rule-actions {
display: flex;
flex-wrap: wrap;
gap: 0;
.rule-actions {
display: flex;
flex-wrap: wrap;
border-top: 1px solid var(--ui-border);
padding-top: 0.5rem;
button {
width: 2.75rem;
height: 2.75rem;
padding: 0.625rem;
}
button:nth-child(3) {
margin-inline-start: auto;
}
}
.rule-summary {
font-size: 0.95rem;
font-size: 0.875rem;
line-height: 1.5;
padding-left: 48px; /* Align with toggle switch text */
strong {
font-weight: 600;
}
}
.rule-force {
display: inline-block;
margin-inline-end: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
vertical-align: baseline;
white-space: nowrap;
&.obligation {
background: var(--ui-warning-bg);
color: var(--ui-warning-text);
}
}
.rule-editor {
padding: 20px;
padding: clamp(0.75rem, 3cqi, 1.25rem);
display: flex;
flex-direction: column;
gap: 16px;
@@ -186,23 +234,27 @@
align-items: center;
.action-text {
flex-basis: 100%;
font-weight: 500;
margin-right: 4px;
}
mat-form-field {
flex: 1 1 11rem;
min-width: 0;
max-width: 100%;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.small-input {
width: 100px;
flex: 0 1 6.25rem;
}
}
.full-width {
width: 100%;
min-width: 0;
}
.editor-actions {
@@ -226,17 +278,20 @@
}
button {
border-radius: 24px;
border-radius: var(--ui-radius-sm);
min-height: 2.75rem;
height: auto;
padding-block: 0.625rem;
width: 100%;
}
}
@container (max-width: 30rem) {
.rule-summary {
padding-left: 0;
@container rule-item (max-width: 18rem) {
.rule-display {
padding: 0.5rem;
}
.rule-header {
align-items: flex-start;
gap: 0.5rem;
}
}
@@ -3,6 +3,10 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { RulesPanel } from './rules-panel';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { By } from '@angular/platform-browser';
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
describe('RulesPanel', () => {
let component: RulesPanel;
@@ -111,4 +115,31 @@ describe('RulesPanel', () => {
component.moveRule(1, -1, { stopPropagation: () => {} } as Event);
expect(component.rules[0].id).toBe(secondRuleId);
});
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
const initialIds = component.rules.map((rule) => rule.id);
const movedName = component.rules[0].name;
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const list = fixture.debugElement
.query(By.directive(CdkDropList))
.injector.get(CdkDropList);
const drop = {
previousIndex: 0,
currentIndex: 1,
isPointerOverContainer: false,
} as CdkDragDrop<unknown>;
list.dropped.emit(drop);
expect(component.rules.map((rule) => rule.id)).toEqual(initialIds);
expect(announce).not.toHaveBeenCalled();
list.dropped.emit({ ...drop, isPointerOverContainer: true });
expect(component.rules.map((rule) => rule.id)).toEqual([...initialIds].reverse());
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 2.`);
// CDK restores the original DOM before emitting dropped. The new order must
// be rendered before this callback returns, without waiting for another frame.
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
movedName,
);
expect(fixture.nativeElement.querySelectorAll('.rule-number')[1].textContent).toBe('2.');
});
});
+30 -44
View File
@@ -1,9 +1,9 @@
import {
Component,
ChangeDetectionStrategy,
ChangeDetectorRef,
inject,
signal,
Injectable,
effect,
} from '@angular/core';
import {
@@ -25,37 +25,11 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
NativeDateAdapter,
} from '@angular/material/core';
import { CdkDragDrop, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
@Injectable()
class CustomDateAdapter extends NativeDateAdapter {
override format(date: Date, displayFormat: unknown): string {
if (displayFormat === 'input') {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
return super.format(date, displayFormat as object);
}
}
const MY_DATE_FORMATS = {
parse: { dateInput: 'input' },
display: {
dateInput: 'input',
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
},
};
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-rules-panel',
@@ -71,20 +45,19 @@ const MY_DATE_FORMATS = {
FormsModule,
CdkDropList,
CdkDrag,
CdkDragHandle,
],
templateUrl: './rules-panel.html',
styleUrl: './rules-panel.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
],
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class RulesPanel {
public ruleService = inject(RuleService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject(DateAdapter);
private readonly liveAnnouncer = inject(LiveAnnouncer);
private readonly changeDetector = inject(ChangeDetectorRef);
get tr() {
return this.translationService.tr;
@@ -145,16 +118,30 @@ export class RulesPanel {
}
public drop(event: CdkDragDrop<VacationRule[]>) {
if (event.previousIndex !== event.currentIndex) {
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
if (event.isPointerOverContainer && event.previousIndex !== event.currentIndex) {
this.reorderRule(event.previousIndex, event.currentIndex);
}
// CDK restores the original DOM before this callback. Commit the final order
// before the next paint so zoneless rendering doesn't flash the old position.
this.changeDetector.detectChanges();
}
public moveRule(index: number, offset: -1 | 1, event: Event) {
event.stopPropagation();
const destination = index + offset;
if (destination >= 0 && destination < this.rules.length)
this.ruleService.reorderRules(index, destination);
this.reorderRule(index, destination);
}
private reorderRule(from: number, to: number) {
const rule = this.rules[from];
this.ruleService.reorderRules(from, to);
void this.liveAnnouncer.announce(
this.tr()
.rules.ruleMoved.replace('{name}', rule.name)
.replace('{position}', String(to + 1))
.replace('{count}', String(this.rules.length)),
);
}
public toggleRule(id: string) {
@@ -229,7 +216,6 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
const forceLabel = rule.force === 'obligation' ? tr.rules.obligation : tr.rules.preference;
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -241,13 +227,13 @@ export class RulesPanel {
.toLowerCase();
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
return `[${forceLabel}] ${tr.rules.ifThenTemplate
return tr.rules.ifThenTemplate
.replace('{day}', dayLabel)
.replace('{placeDays}', String(ifThen.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{placement}', placementLabel)
.replace('{rewardDays}', String(ifThen.rewardDays))
.replace('{rewardType}', rewardTypeLabel)}`;
.replace('{rewardType}', rewardTypeLabel);
} else {
const timeWindow = rule as TimeWindowRule;
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
@@ -258,11 +244,11 @@ export class RulesPanel {
this.translationService.locale(),
);
return `[${forceLabel}] ${tr.rules.timeWindowTemplate
return tr.rules.timeWindowTemplate
.replace('{placeDays}', String(timeWindow.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{startDate}', startDateStr)
.replace('{endDate}', endDateStr)}`;
.replace('{endDate}', endDateStr);
}
}
}
@@ -2,6 +2,9 @@
<header class="section-heading">
<h2 id="leave-settings-title">{{ tr().userInput.title }}</h2>
<p>{{ tr().userInput.description }}</p>
<a class="jump-to-plan" href="#planning-results-title"
>{{ tr().app.skipToPlan }} <mat-icon aria-hidden="true">arrow_downward</mat-icon></a
>
</header>
<div class="listOfDays">
@@ -20,6 +23,8 @@
</button>
</div>
<app-rules-panel></app-rules-panel>
<button
mat-flat-button
class="calculate-button"
@@ -27,7 +32,7 @@
type="button"
(click)="triggerComputation()"
>
<mat-icon aria-hidden="true">calculate</mat-icon>
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
{{ tr().userInput.calculate }}
</button>
@@ -55,6 +60,4 @@
</mat-card-content>
</mat-card>
}
<app-rules-panel></app-rules-panel>
</div>
+52 -5
View File
@@ -1,7 +1,8 @@
.user-input {
display: flex;
flex-direction: column;
gap: 1.25rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 1rem;
min-width: 0;
}
@@ -16,7 +17,7 @@
}
h2 {
font-size: 1.5rem;
font-size: 1.125rem;
font-weight: 700;
line-height: 1.2;
text-wrap: balance;
@@ -24,6 +25,7 @@
p {
max-width: 55ch;
font-size: 0.875rem;
color: var(--ui-text-secondary, #45464f);
line-height: 1.5;
}
@@ -80,11 +82,15 @@ form {
.add-day-button {
display: flex;
justify-content: flex-start;
justify-content: center;
border-style: dashed;
border-color: var(--mat-sys-outline);
border-radius: var(--ui-radius-sm);
}
.calculate-button {
min-height: 3.25rem;
border-radius: var(--ui-radius-sm);
font-weight: 600;
}
@@ -116,3 +122,44 @@ form {
container: user-input / inline-size;
max-width: 100%;
}
@container user-input (min-width: 40rem) {
.listOfDays {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.add-day-button {
grid-column: 1 / -1;
}
}
@container user-input (min-width: 48rem) {
.user-input {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 1.5rem;
}
.section-heading,
.calculate-button,
.warning-card {
grid-column: 1 / -1;
}
.listOfDays {
grid-template-columns: minmax(0, 1fr);
}
}
.jump-to-plan {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: fit-content;
min-height: 2.75rem;
color: var(--mat-sys-primary);
font-size: 0.875rem;
text-underline-offset: 0.25em;
}
@media (min-width: 75rem) {
.jump-to-plan {
display: none;
}
}
@@ -31,28 +31,41 @@
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
<div class="date-fields">
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
<input
matInput
[matDatepicker]="availablePicker"
[value]="day().availableDate"
(dateChange)="addAvailabilityEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="availablePicker"></mat-datepicker-toggle>
<mat-datepicker #availablePicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
<input
matInput
[matDatepicker]="picker"
[value]="day().expiryDate"
(dateChange)="addEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
</div>
<details class="balance-dates">
<summary>
<mat-icon aria-hidden="true">date_range</mat-icon>
<span
>{{ tr().userInput.expires }}
{{ day().expiryDate.toLocaleDateString(translationService.locale()) }}</span
>
<mat-icon class="dates-chevron" aria-hidden="true">expand_more</mat-icon>
</summary>
<div class="date-fields">
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
<input
matInput
[matDatepicker]="availablePicker"
[value]="day().availableDate"
(dateChange)="addAvailabilityEvent($event)"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="availablePicker"
></mat-datepicker-toggle>
<mat-datepicker #availablePicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
<input
matInput
[matDatepicker]="picker"
[value]="day().expiryDate"
(dateChange)="addEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
</div>
</details>
</mat-card>
@@ -10,8 +10,8 @@
align-items: start;
gap: 0.75rem;
min-width: 0;
padding: 1rem;
background: var(--ui-card-bg, #f1f1f5);
padding: 1rem 0.75rem;
background: var(--ui-panel-bg);
border: 1px solid var(--ui-border, #c7c7d0);
box-shadow: none;
}
@@ -50,7 +50,7 @@
.card {
grid-template-columns: 4.75rem minmax(0, 1fr) 2.75rem;
gap: 0.5rem;
padding: 0.75rem;
padding: 0.875rem 0.625rem;
}
.date-fields {
@@ -59,18 +59,36 @@
}
}
@container day-input (min-width: 52rem) {
.card {
grid-template-columns:
5.25rem minmax(9rem, 1fr) minmax(11rem, 1fr) minmax(11rem, 1fr)
2.75rem;
}
.balance-dates {
grid-column: 1 / -1;
min-width: 0;
color: var(--ui-text-secondary);
font-size: 0.875rem;
summary {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.75rem;
cursor: pointer;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary > mat-icon {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
font-size: 1.125rem;
}
.dates-chevron {
margin-inline-start: auto;
}
&[open] .dates-chevron {
transform: rotate(180deg);
}
.date-fields {
display: contents;
}
.delete-button {
grid-column: 5;
margin-block-start: 0.5rem;
}
}
@@ -1,12 +1,4 @@
import {
Component,
input,
output,
ChangeDetectionStrategy,
Injectable,
inject,
effect,
} from '@angular/core';
import { Component, input, output, ChangeDetectionStrategy, inject, effect } from '@angular/core';
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
import { TranslationService } from '../../../services/translation-service';
import { MatCard } from '@angular/material/card';
@@ -18,54 +10,11 @@ import {
MatDatepickerInputEvent,
MatDatepickerToggle,
} from '@angular/material/datepicker';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
NativeDateAdapter,
} from '@angular/material/core';
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../../shared/day-first-date-adapter';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
@Injectable()
class CustomDateAdapter extends NativeDateAdapter {
override format(date: Date, displayFormat: unknown): string {
if (displayFormat === 'input') {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
return super.format(date, displayFormat as object);
}
override parse(value: string): Date | null {
if (!value) return null;
const parts = value.split('/');
if (parts.length === 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const year = parseInt(parts[2], 10);
if (!isNaN(day) && !isNaN(month) && !isNaN(year)) {
return new Date(year, month, day);
}
}
return null;
}
}
const MY_DATE_FORMATS = {
parse: {
dateInput: 'input',
},
display: {
dateInput: 'input',
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
},
};
@Component({
selector: 'app-day-input',
imports: [
@@ -84,11 +33,7 @@ const MY_DATE_FORMATS = {
templateUrl: './day-input.html',
styleUrl: './day-input.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
],
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class DayInput {
public day = input.required<VacDay>();