UI improvments
This commit is contained in:
1 parent
c9dbe82b79
commit
0ce33cc9d6
106 files changed
+72934
-1675
No files matched your search
+5
-1
@@ -1,9 +1,13 @@
|
||||
@if (showPlanner()) {
|
||||
<div class="app-shell">
|
||||
<a class="skip-link" href="#planning-results-title">{{ tr().app.skipToPlan }}</a>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<mat-icon aria-hidden="true">beach_access</mat-icon>
|
||||
<h1>{{ title() }}</h1>
|
||||
<div>
|
||||
<h1>{{ title() }}</h1>
|
||||
<p>{{ tr().app.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<app-dark-light-mode-button></app-dark-light-mode-button>
|
||||
</header>
|
||||
|
||||
+65
-23
@@ -3,50 +3,64 @@
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--workspace-width: 144rem;
|
||||
--workspace-gutter: clamp(0.75rem, 2vw, 2.5rem);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: min(100%, 120rem);
|
||||
margin-inline: auto;
|
||||
padding: 1rem clamp(1rem, 2vw, 2rem);
|
||||
box-sizing: border-box;
|
||||
border-block-end: 1px solid var(--ui-border, #c7c7d0);
|
||||
padding: 1.25rem
|
||||
max(
|
||||
var(--workspace-gutter),
|
||||
calc((100% - var(--workspace-width)) / 2 + var(--workspace-gutter))
|
||||
);
|
||||
background: var(--ui-card-bg);
|
||||
border-block-end: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.875rem;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-primary, #005cbb);
|
||||
> mat-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.875rem;
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.125rem 0 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.planner-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(23rem, 29rem) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 2rem;
|
||||
width: min(100%, 120rem);
|
||||
gap: clamp(1.25rem, 2vw, 2rem);
|
||||
width: min(100%, var(--workspace-width));
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem clamp(1rem, 2vw, 2rem) 3rem;
|
||||
box-sizing: border-box;
|
||||
padding: 2rem var(--workspace-gutter) 4rem;
|
||||
}
|
||||
|
||||
.configuration-panel,
|
||||
@@ -59,13 +73,41 @@ app-calendar-year {
|
||||
|
||||
.configuration-panel {
|
||||
padding: 1.25rem;
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
border-radius: var(--ui-radius-lg, 1rem);
|
||||
background: var(--ui-card-bg);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 75rem) {
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
inset: 1rem auto auto 1rem;
|
||||
z-index: 10;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
transform: translateY(-200%);
|
||||
|
||||
&:focus {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 75rem) {
|
||||
.planner-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: clamp(26rem, 34%, 40rem) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 35rem) {
|
||||
.app-header {
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.planner-layout {
|
||||
padding-block: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.configuration-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CalendarYear } from './components/calendar-year/calendar-year';
|
||||
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslationService } from './services/translation-service';
|
||||
|
||||
function isBenchmarkUrl(url: string): boolean {
|
||||
return url.split(/[?#]/, 1)[0].replace(/\/+$/, '') === '/benchmark';
|
||||
@@ -28,6 +29,7 @@ function isBenchmarkUrl(url: string): boolean {
|
||||
})
|
||||
export class App {
|
||||
private readonly router = inject(Router);
|
||||
protected readonly tr = inject(TranslationService).tr;
|
||||
protected readonly title = signal('vac-optimizer');
|
||||
protected readonly showPlanner = signal(
|
||||
!isBenchmarkUrl(typeof window !== 'undefined' ? window.location.pathname : this.router.url),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AppTranslations {
|
||||
app: {
|
||||
title: string;
|
||||
description: string;
|
||||
skipToPlan: string;
|
||||
};
|
||||
userInput: {
|
||||
title: string;
|
||||
@@ -56,6 +57,8 @@ export interface AppTranslations {
|
||||
active: string;
|
||||
moveRuleUp: string;
|
||||
moveRuleDown: string;
|
||||
dragRule: string;
|
||||
ruleMoved: string;
|
||||
priorityInfo: string;
|
||||
ifThenTemplate: string;
|
||||
timeWindowTemplate: string;
|
||||
@@ -99,7 +102,76 @@ export interface AppTranslations {
|
||||
};
|
||||
};
|
||||
planning: {
|
||||
comparison: {
|
||||
title: string;
|
||||
candidate: string;
|
||||
preview: string;
|
||||
nearby: string;
|
||||
noAlternatives: string;
|
||||
chooseDate: string;
|
||||
dateRange: string;
|
||||
invalidDate: string;
|
||||
changes: string;
|
||||
available: string;
|
||||
unavailable: string;
|
||||
backToPlan: string;
|
||||
backToTrace: string;
|
||||
};
|
||||
details: {
|
||||
intro: string;
|
||||
balance: string;
|
||||
available: string;
|
||||
expires: string;
|
||||
criteria: string;
|
||||
criteriaHelp: string;
|
||||
firstDifference: string;
|
||||
higher: string;
|
||||
lower: string;
|
||||
tieBreaks: string;
|
||||
audit: string;
|
||||
auditHelp: string;
|
||||
supporting: string;
|
||||
remainingReasons: Record<string, string>;
|
||||
};
|
||||
explanation: {
|
||||
breakTitle: string;
|
||||
leaveDays: string;
|
||||
freeDays: string;
|
||||
daysOff: string;
|
||||
wholeBreak: string;
|
||||
leaveUsed: string;
|
||||
expires: string;
|
||||
linkedRules: string;
|
||||
impact: string;
|
||||
impactHelp: string;
|
||||
criterion: string;
|
||||
withoutDay: string;
|
||||
withDay: string;
|
||||
noChange: string;
|
||||
priorityHelp: string;
|
||||
tryDate: string;
|
||||
plannedDate: string;
|
||||
currentPlan: string;
|
||||
alternative: string;
|
||||
sameDate: string;
|
||||
moveHelp: string;
|
||||
traceHelp: string;
|
||||
beforeDecision: string;
|
||||
withCandidate: string;
|
||||
comparisonUnavailable: string;
|
||||
objectives: Record<string, { label: string; help: string }>;
|
||||
preferenceHelp: Record<string, string | undefined>;
|
||||
stages: Record<string, string>;
|
||||
unavailable: Record<string, string | undefined>;
|
||||
};
|
||||
title: string;
|
||||
calendarHelp: string;
|
||||
explanationHelp: string;
|
||||
traceHelp: string;
|
||||
technicalDetails: string;
|
||||
compareDates: string;
|
||||
stopComparing: string;
|
||||
replayNotice: string;
|
||||
daysPlaced: string;
|
||||
daysGained: string;
|
||||
longestWorkSequence: string;
|
||||
@@ -119,28 +191,8 @@ export interface AppTranslations {
|
||||
previous: string;
|
||||
next: string;
|
||||
last: string;
|
||||
balance: string;
|
||||
rules: string;
|
||||
noRules: string;
|
||||
supporting: string;
|
||||
yes: string;
|
||||
no: string;
|
||||
remaining: string;
|
||||
timeOffPeriod: string;
|
||||
firstDecisive: string;
|
||||
contributions: string;
|
||||
objectiveVector: string;
|
||||
finalMetrics: string;
|
||||
unplacedAtRisk: string;
|
||||
holidayWeekValue: string;
|
||||
shortSequenceDeficit: string;
|
||||
supportingPlacements: string;
|
||||
preferenceSatisfaction: string;
|
||||
removedRestoredComparison: string;
|
||||
fullComparison: string;
|
||||
comparisonHelp: string;
|
||||
counterfactual: string;
|
||||
hypothetical: string;
|
||||
counterfactualLoading: string;
|
||||
counterfactualReady: string;
|
||||
counterfactualError: string;
|
||||
@@ -182,7 +234,6 @@ export interface AppTranslations {
|
||||
nextCandidates: string;
|
||||
page: string;
|
||||
compareAlternatives: string;
|
||||
tieBreak: string;
|
||||
deltas: string;
|
||||
activations: string;
|
||||
noDecisions: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { UserInputService } from './user-input-service';
|
||||
import { SelectableDayType, VacDay } from './calendar-service';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('UserInputService', () => {
|
||||
let service: UserInputService;
|
||||
@@ -13,6 +14,8 @@ describe('UserInputService', () => {
|
||||
service = TestBed.inject(UserInputService);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
@@ -28,12 +31,25 @@ describe('UserInputService', () => {
|
||||
vacations.forEach((day) => {
|
||||
expect(day.id).toBeDefined();
|
||||
expect(day.type).toBeDefined();
|
||||
expect(day.numberOfDays).toBeGreaterThanOrEqual(1);
|
||||
expect(day.numberOfDays).toBeLessThanOrEqual(9);
|
||||
expect(day.numberOfDays).toBeGreaterThanOrEqual(10);
|
||||
expect(day.numberOfDays).toBeLessThanOrEqual(20);
|
||||
expect(day.expiryDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 10],
|
||||
[1 - Number.EPSILON, 20],
|
||||
])('starts every balance at %s random draw with %s days', (draw, expected) => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(draw);
|
||||
const initialBalances = new UserInputService().vacationNumberSignal();
|
||||
expect(initialBalances.map((balance) => balance.numberOfDays)).toEqual([
|
||||
expected,
|
||||
expected,
|
||||
expected,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update vacation number signal', () => {
|
||||
const newVacations: VacDay[] = [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectableDayType, VacDay } from './calendar-service';
|
||||
})
|
||||
export class UserInputService {
|
||||
private getRandomNumber(): number {
|
||||
return Math.floor(Math.random() * 9) + 1;
|
||||
return Math.floor(Math.random() * 11) + 10;
|
||||
}
|
||||
|
||||
private getInitialValues(): VacDay[] {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { DAY_FIRST_DATE_PROVIDERS } from './day-first-date-adapter';
|
||||
|
||||
it('parses day-first dates and rejects invalid calendar days without rolling them over', () => {
|
||||
TestBed.configureTestingModule({ providers: DAY_FIRST_DATE_PROVIDERS });
|
||||
const adapter = TestBed.inject<DateAdapter<Date>>(DateAdapter);
|
||||
for (const locale of ['en-GB', 'fr-FR', 'de-DE', 'es-ES']) {
|
||||
adapter.setLocale(locale);
|
||||
const date = adapter.parse('03/05/2024', 'input')!;
|
||||
expect([date.getFullYear(), date.getMonth(), date.getDate()]).toEqual([2024, 4, 3]);
|
||||
expect(adapter.format(date, 'input')).toBe('03/05/2024');
|
||||
expect(adapter.isValid(adapter.parse('29/02/2024', 'input')!)).toBe(true);
|
||||
for (const invalid of [
|
||||
'31/02/2024',
|
||||
'29/02/2023',
|
||||
'00/05/2024',
|
||||
'03/13/2024',
|
||||
'3x/05/2024',
|
||||
]) {
|
||||
expect(adapter.isValid(adapter.parse(invalid, 'input')!)).toBe(false);
|
||||
}
|
||||
expect(adapter.parse('', 'input')).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DateAdapter, MAT_DATE_FORMATS, NativeDateAdapter } from '@angular/material/core';
|
||||
|
||||
@Injectable()
|
||||
class DayFirstDateAdapter extends NativeDateAdapter {
|
||||
override format(date: Date, displayFormat: object | string): string {
|
||||
if (displayFormat === 'input') {
|
||||
return `${String(date.getDate()).padStart(2, '0')}/${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}`;
|
||||
}
|
||||
return super.format(date, displayFormat);
|
||||
}
|
||||
|
||||
override parse(value: string): Date | null {
|
||||
if (!value) return null;
|
||||
const parts = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value.trim());
|
||||
if (!parts) return this.invalid();
|
||||
const [, day, month, year] = parts.map(Number);
|
||||
const date = new Date(0);
|
||||
date.setFullYear(year, month - 1, day);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getFullYear() === year &&
|
||||
date.getMonth() === month - 1 &&
|
||||
date.getDate() === day
|
||||
? date
|
||||
: this.invalid();
|
||||
}
|
||||
}
|
||||
|
||||
export const DAY_FIRST_DATE_PROVIDERS = [
|
||||
{ provide: DateAdapter, useClass: DayFirstDateAdapter },
|
||||
{
|
||||
provide: MAT_DATE_FORMATS,
|
||||
useValue: {
|
||||
parse: { dateInput: 'input' },
|
||||
display: {
|
||||
dateInput: 'input',
|
||||
monthYearLabel: { year: 'numeric', month: 'short' },
|
||||
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
monthYearA11yLabel: { year: 'numeric', month: 'long' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user