Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21c9b34bd | ||
|
|
2a39dd480b |
No files matched your search
+2
-1
@@ -42,5 +42,6 @@ __screenshots__/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Auto-generated i18n files
|
||||
# Auto-generated application data
|
||||
/src/app/i18n-registry.ts
|
||||
/src/app/services/french-holidays-data.json
|
||||
@@ -17,7 +17,7 @@
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.1",
|
||||
"date-holidays": "^3.30.2",
|
||||
"date-holidays-parser": "^3.4.7",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
"rxjs": "~7.8.2",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@vitest/coverage-istanbul": "^5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vitest/ui": "^5.0.0",
|
||||
"date-holidays": "^3.30.2",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
|
||||
+10
-8
@@ -3,15 +3,16 @@
|
||||
"version": "1.5.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "bun scripts/generate-langs.js && ng serve",
|
||||
"build": "bun scripts/generate-langs.js && ng build",
|
||||
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
||||
"test": "bun scripts/generate-langs.js && ng test",
|
||||
"generate": "bun scripts/generate-langs.js && bun scripts/generate-holidays.js",
|
||||
"start": "bun run generate && ng serve",
|
||||
"build": "bun run generate && ng build",
|
||||
"watch": "bun run generate && ng build --watch --configuration development",
|
||||
"test": "bun run generate && ng test",
|
||||
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
|
||||
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
||||
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
||||
"lint": "bun run generate && eslint \"src/**/*.ts\"",
|
||||
"lint:fix": "bun run generate && eslint \"src/**/*.ts\" --fix",
|
||||
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
|
||||
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
||||
"format:check": "bun run generate && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
@@ -39,7 +40,7 @@
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.1",
|
||||
"date-holidays": "^3.30.2",
|
||||
"date-holidays-parser": "^3.4.7",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
"rxjs": "~7.8.2",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@vitest/coverage-istanbul": "^5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vitest/ui": "^5.0.0",
|
||||
"date-holidays": "^3.30.2",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { data } = require('date-holidays/data');
|
||||
|
||||
// Use the upstream rules and translations, while bundling only the supported country.
|
||||
const france = { ...data, holidays: { FR: data.holidays.FR } };
|
||||
const outputFile = path.join(__dirname, '../src/app/services/french-holidays-data.json');
|
||||
fs.writeFileSync(outputFile, JSON.stringify(france, null, '\t') + '\n');
|
||||
console.log(`Successfully generated French holiday data at ${outputFile}.`);
|
||||
@@ -414,6 +414,172 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'four declared days with both weekend dates outside the horizon',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-15',
|
||||
declared: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13'],
|
||||
entries: ['10 Jun 2024 – 16 Jun 2024 Only workday: 14 Jun 2024'],
|
||||
},
|
||||
{
|
||||
label: 'four placed days',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-17',
|
||||
placed: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13'],
|
||||
entries: ['10 Jun 2024 – 16 Jun 2024 Only workday: 14 Jun 2024'],
|
||||
},
|
||||
{
|
||||
label: 'zero remaining workdays',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-17',
|
||||
declared: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13', '2024-06-14'],
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'two consecutive remaining workdays',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-17',
|
||||
declared: ['2024-06-10', '2024-06-11', '2024-06-12'],
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'two separate one-day work sequences',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-17',
|
||||
declared: ['2024-06-10', '2024-06-12', '2024-06-14'],
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'two French holidays and two declared weekdays',
|
||||
start: '2024-05-06',
|
||||
end: '2024-05-11',
|
||||
declared: ['2024-05-06', '2024-05-07'],
|
||||
entries: ['6 May 2024 – 12 May 2024 Only workday: 10 May 2024'],
|
||||
},
|
||||
{
|
||||
label: 'a holiday week with no leave and three remaining workdays',
|
||||
start: '2024-05-06',
|
||||
end: '2024-05-11',
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'a clipped starting workweek with only Friday left to work',
|
||||
start: '2024-06-13',
|
||||
end: '2024-06-17',
|
||||
declared: ['2024-06-13'],
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'an exclusive Friday end with only Thursday left to work',
|
||||
start: '2024-06-10',
|
||||
end: '2024-06-14',
|
||||
declared: ['2024-06-10', '2024-06-11', '2024-06-12'],
|
||||
entries: [],
|
||||
},
|
||||
{
|
||||
label: 'a complete week after the clipped starting week',
|
||||
start: '2024-06-13',
|
||||
end: '2024-06-22',
|
||||
declared: ['2024-06-13', '2024-06-17', '2024-06-18', '2024-06-19', '2024-06-20'],
|
||||
entries: ['17 Jun 2024 – 23 Jun 2024 Only workday: 21 Jun 2024'],
|
||||
},
|
||||
{
|
||||
label: 'a week across month boundaries with a Wednesday holiday',
|
||||
start: '2024-04-29',
|
||||
end: '2024-05-04',
|
||||
declared: ['2024-04-29', '2024-04-30', '2024-05-02'],
|
||||
entries: ['29 Apr 2024 – 5 May 2024 Only workday: 3 May 2024'],
|
||||
},
|
||||
{
|
||||
label: 'a week across year boundaries with New Year excluded',
|
||||
start: '2024-12-30',
|
||||
end: '2025-01-04',
|
||||
declared: ['2024-12-30', '2024-12-31', '2025-01-02'],
|
||||
entries: ['30 Dec 2024 – 5 Jan 2025 Only workday: 3 Jan 2025'],
|
||||
},
|
||||
{
|
||||
label: 'chronological matching weeks in multiple years without saved external weeks',
|
||||
start: '2024-12-30',
|
||||
end: '2026-01-03',
|
||||
declared: [
|
||||
'2025-12-31',
|
||||
'2024-12-23',
|
||||
'2025-01-02',
|
||||
'2025-12-29',
|
||||
'2024-12-24',
|
||||
'2024-12-30',
|
||||
'2026-01-05',
|
||||
'2024-12-26',
|
||||
'2025-12-30',
|
||||
'2024-12-31',
|
||||
'2026-01-06',
|
||||
'2026-01-07',
|
||||
'2026-01-08',
|
||||
],
|
||||
entries: [
|
||||
'30 Dec 2024 – 5 Jan 2025 Only workday: 3 Jan 2025',
|
||||
'29 Dec 2025 – 4 Jan 2026 Only workday: 2 Jan 2026',
|
||||
],
|
||||
},
|
||||
])(
|
||||
'reports weeks with one workday: $label',
|
||||
async ({ start, end, declared = [], placed = [], entries }) => {
|
||||
const core = await import('../../services/leave-planner.core');
|
||||
const outcome = core.calculatePlanningInternal({
|
||||
calculationDate: '2020-01-01',
|
||||
horizon: { start, end },
|
||||
balances: placed.length
|
||||
? [{ id: 'cp', type: 'cp', quantity: placed.length, expiryDate: '2026-12-31' }]
|
||||
: [],
|
||||
rules: placed.map((date) => ({
|
||||
id: date,
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
})),
|
||||
declaredDays: declared.map((date) => ({ date, type: 'other' })),
|
||||
});
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan.');
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(placed);
|
||||
const { calendar } = calendarFor(outcome);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).setLanguage('en');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||||
await fixture.whenStable();
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
const label = Array.from(dialog.querySelectorAll('dt')).find((entry) =>
|
||||
entry.textContent?.includes('Weeks with one workday'),
|
||||
);
|
||||
expect(label?.nextElementSibling?.textContent?.trim()).toBe(String(entries.length));
|
||||
const warning = dialog.querySelector('[aria-labelledby="one-workday-weeks-title"]');
|
||||
expect(
|
||||
Array.from(warning?.querySelectorAll('li') ?? [], (entry) =>
|
||||
entry.textContent?.replace(/\s+/g, ' ').trim(),
|
||||
),
|
||||
).toEqual(entries);
|
||||
if (entries.length) {
|
||||
expect(warning?.textContent).toContain('Warning: weeks with one workday');
|
||||
expect(warning?.textContent).toContain(
|
||||
'Working just one day in a full week is an undesirable pattern.',
|
||||
);
|
||||
} else expect(warning).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'a Monday holiday directly beside a weekend',
|
||||
@@ -678,10 +844,19 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
['Average time-off period (calendar days)', '2.5'],
|
||||
['Longest time-off period (calendar days)', '4'],
|
||||
['Public holidays connected to a weekend (calendar days)', '2'],
|
||||
['Weeks with one workday', placements ? '0' : '1'],
|
||||
['Earned leave before use (days)', '3'],
|
||||
['Original generating rule', '3'],
|
||||
];
|
||||
expect(statistics()).toEqual(expectedStatistics);
|
||||
const matchingWeeks = () =>
|
||||
Array.from(dialog.querySelectorAll('.one-workday-weeks li'), (entry) =>
|
||||
entry.textContent?.replace(/\s+/g, ' ').trim(),
|
||||
);
|
||||
const expectedWeeks = placements
|
||||
? []
|
||||
: ['6 May 2024 – 12 May 2024 Only workday: 6 May 2024'];
|
||||
expect(matchingWeeks()).toEqual(expectedWeeks);
|
||||
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
||||
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
||||
@@ -694,6 +869,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(statistics()).toEqual(expectedStatistics);
|
||||
expect(matchingWeeks()).toEqual(expectedWeeks);
|
||||
input.updateBalances(() => []);
|
||||
input.toggleDeclaredDay('2024-05-10', 'other');
|
||||
rules.deleteRule(rules.rules[0].id);
|
||||
@@ -703,6 +879,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(statistics()).toEqual(expectedStatistics);
|
||||
expect(matchingWeeks()).toEqual(expectedWeeks);
|
||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
},
|
||||
@@ -1108,6 +1285,18 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await fixture.whenStable();
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
expect(dialog.textContent).toContain('Partial plan');
|
||||
const oneWorkdayLabel = Array.from(dialog.querySelectorAll('dt')).find((label) =>
|
||||
label.textContent?.includes('Weeks with one workday'),
|
||||
);
|
||||
expect(oneWorkdayLabel?.nextElementSibling?.textContent?.trim()).toBe('2');
|
||||
expect(
|
||||
Array.from(dialog.querySelectorAll('.one-workday-weeks li'), (entry) =>
|
||||
entry.textContent?.replace(/\s+/g, ' ').trim(),
|
||||
),
|
||||
).toEqual([
|
||||
'29 Apr 2024 – 5 May 2024 Only workday: 30 Apr 2024',
|
||||
'6 May 2024 – 12 May 2024 Only workday: 7 May 2024',
|
||||
]);
|
||||
expect(
|
||||
dialog
|
||||
.querySelector('.statistics-summary > div:last-child dd')
|
||||
@@ -1162,8 +1351,8 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
),
|
||||
).toEqual(
|
||||
allDaysOff
|
||||
? ['0', '4', '—', '0', '9', '9', '0', '0']
|
||||
: ['0', '0', '5', '5', '—', '—', '0', '0'],
|
||||
? ['0', '4', '—', '0', '9', '9', '0', '0', '0']
|
||||
: ['0', '0', '5', '5', '—', '—', '0', '0', '0'],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1171,6 +1360,10 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
|
||||
const { calendar } = calendarFor({
|
||||
...plan,
|
||||
declaredDays: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13'].map((date) => ({
|
||||
date,
|
||||
type: 'other',
|
||||
})),
|
||||
metrics: {
|
||||
...plan.metrics,
|
||||
averageWorkSequence: 10 / 3,
|
||||
@@ -1201,19 +1394,50 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title');
|
||||
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
|
||||
for (const [language, workUnit, calendarUnit, holidayLabel] of [
|
||||
['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'],
|
||||
['es', 'días laborables', 'días naturales', 'Festivos conectados con un fin de semana'],
|
||||
['fr', 'jours ouvrés', 'jours calendaires', 'Jours fériés reliés à un week-end'],
|
||||
expect(dialog.querySelector('.one-workday-weeks li')?.textContent).toContain('14 Jun 2024');
|
||||
for (const [language, workUnit, calendarUnit, holidayLabel, warningTitle, entry] of [
|
||||
[
|
||||
'de',
|
||||
'Arbeitstage',
|
||||
'Kalendertage',
|
||||
'Feiertage mit Wochenendanschluss',
|
||||
'Achtung: Wochen mit nur einem Arbeitstag',
|
||||
'10.06.2024 – 16.06.2024 Einziger Arbeitstag: 14.06.2024',
|
||||
],
|
||||
[
|
||||
'es',
|
||||
'días laborables',
|
||||
'días naturales',
|
||||
'Festivos conectados con un fin de semana',
|
||||
'Atención: semanas con un solo día de trabajo',
|
||||
'10 jun 2024 – 16 jun 2024 Único día de trabajo: 14 jun 2024',
|
||||
],
|
||||
[
|
||||
'fr',
|
||||
'jours ouvrés',
|
||||
'jours calendaires',
|
||||
'Jours fériés reliés à un week-end',
|
||||
'Attention : semaines avec un seul jour travaillé',
|
||||
'10 juin 2024 – 16 juin 2024 Seul jour travaillé: 14 juin 2024',
|
||||
],
|
||||
]) {
|
||||
translation.setLanguage(language);
|
||||
fixture.detectChanges();
|
||||
expect(dialog.textContent).toContain(workUnit);
|
||||
expect(dialog.textContent).toContain(calendarUnit);
|
||||
expect(dialog.textContent).toContain(holidayLabel);
|
||||
expect(dialog.querySelector('#one-workday-weeks-title')?.textContent?.trim()).toBe(
|
||||
warningTitle,
|
||||
);
|
||||
expect(
|
||||
dialog
|
||||
.querySelector('.one-workday-weeks li')
|
||||
?.textContent?.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
).toBe(entry);
|
||||
expect(
|
||||
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
|
||||
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0', '0']);
|
||||
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0', '1', '0']);
|
||||
}
|
||||
expect(dialog.textContent).toContain('Statistiques du planning');
|
||||
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
|
||||
@@ -1238,13 +1462,22 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
] satisfies [string, PlanningOutcome, boolean][])(
|
||||
'replaces open statistics after %s and ignores an older valid outcome',
|
||||
async (_, replacement: PlanningOutcome, focusMoved) => {
|
||||
const previousPlan: PlanOutcome = {
|
||||
...plan,
|
||||
declaredDays: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13'].map(
|
||||
(date) => ({
|
||||
date,
|
||||
type: 'other',
|
||||
}),
|
||||
),
|
||||
};
|
||||
const oldOutcome = deferred<PlanningOutcome>();
|
||||
const oldTrace = deferred<PlanningTrace | undefined>();
|
||||
const { calendar } = calendarFor(plan);
|
||||
const { calendar } = calendarFor(previousPlan);
|
||||
calendar.createPlanningCalculation
|
||||
.mockReturnValueOnce(calculationFor(plan))
|
||||
.mockReturnValueOnce(calculationFor(previousPlan))
|
||||
.mockReturnValueOnce({
|
||||
...calculationFor(plan, undefined, undefined, oldTrace.promise),
|
||||
...calculationFor(previousPlan, undefined, undefined, oldTrace.promise),
|
||||
outcome: oldOutcome.promise,
|
||||
})
|
||||
.mockReturnValueOnce(calculationFor(replacement));
|
||||
@@ -1255,6 +1488,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).setLanguage('en');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
@@ -1264,6 +1498,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
||||
await fixture.whenStable();
|
||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||
expect(
|
||||
document.querySelector('[role="dialog"] .one-workday-weeks li')?.textContent,
|
||||
).toContain('14 Jun 2024');
|
||||
input.requestComputation();
|
||||
fixture.detectChanges();
|
||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||
@@ -1276,7 +1513,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||
oldOutcome.resolve(plan);
|
||||
oldOutcome.resolve(previousPlan);
|
||||
oldTrace.resolve(jointTrace());
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
@@ -1287,6 +1524,11 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
opener!.click();
|
||||
await fixture.whenStable();
|
||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('1');
|
||||
expect(document.querySelector('[role="dialog"] .one-workday-weeks')).toBeNull();
|
||||
const label = Array.from(document.querySelectorAll('[role="dialog"] dt')).find(
|
||||
(entry) => entry.textContent?.includes('Weeks with one workday'),
|
||||
);
|
||||
expect(label?.nextElementSibling?.textContent?.trim()).toBe('0');
|
||||
} else {
|
||||
expect(opener).toBeNull();
|
||||
expect(document.activeElement).toBe(
|
||||
@@ -3509,7 +3751,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
||||
value.textContent?.trim(),
|
||||
),
|
||||
).toEqual(['2', '4', '6', '6', '—', '—', '0', '0']);
|
||||
).toEqual(['2', '4', '6', '6', '—', '—', '0', '0', '0']);
|
||||
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||
activateWithKeyboard(afterButton);
|
||||
|
||||
@@ -1303,6 +1303,10 @@
|
||||
</dt>
|
||||
<dd>{{ formatStatistic(weekendConnectedHolidays()) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.statistics.weeksWithOneWorkday }}</dt>
|
||||
<dd>{{ formatStatistic(weeksWithOneWorkday().length) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{{ tr().planning.statistics.earnedLeave }} ({{
|
||||
@@ -1332,6 +1336,24 @@
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
@if (weeksWithOneWorkday().length) {
|
||||
<section class="one-workday-weeks" aria-labelledby="one-workday-weeks-title">
|
||||
<h3 id="one-workday-weeks-title">
|
||||
{{ tr().planning.statistics.oneWorkdayWarning }}
|
||||
</h3>
|
||||
<p>{{ tr().planning.statistics.oneWorkdayWarningHelp }}</p>
|
||||
<ol>
|
||||
@for (week of weeksWithOneWorkday(); track week.start) {
|
||||
<li>
|
||||
{{ displayDate(week.start) }} – {{ displayDate(week.end) }}
|
||||
<br />
|
||||
{{ tr().planning.statistics.onlyWorkday }}:
|
||||
{{ displayDate(week.workday) }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
|
||||
@@ -248,167 +248,6 @@ summary:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
.placement-explanation,
|
||||
.outcome-explanation,
|
||||
.trace-decisions {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.placement-explanation,
|
||||
.trace-layout {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.placement-explanation ul {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.break-story,
|
||||
.outcome-explanation,
|
||||
.replay-notice {
|
||||
padding: 1rem;
|
||||
border-radius: var(--ui-radius-sm);
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.technical-details {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
> dl,
|
||||
> div {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
.break-story,
|
||||
.day-impact,
|
||||
.leave-story,
|
||||
.counterfactual-result {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.break-story > p,
|
||||
.day-impact > p {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.break-equation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
.impact-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 0.875rem;
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th:first-child {
|
||||
width: 52%;
|
||||
text-align: start;
|
||||
}
|
||||
td {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
}
|
||||
.compare-date-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.compare-dates-button {
|
||||
justify-self: start;
|
||||
}
|
||||
.placement-stepper,
|
||||
.trace-toolbar,
|
||||
.trace-decisions nav,
|
||||
.trace-phase-stepper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.placement-stepper button,
|
||||
.trace-phase-stepper button {
|
||||
padding: 0.375rem;
|
||||
}
|
||||
output {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.compact-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
.compact-facts > div {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
.compact-facts dt {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
.compact-facts dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reason-list {
|
||||
padding: 1rem 1rem 1rem 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
.inline-error,
|
||||
.counterfactual-inline-error,
|
||||
.conflict-explanation {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 4px solid var(--ui-error-text);
|
||||
background: var(--ui-error-bg);
|
||||
color: var(--ui-error-text);
|
||||
}
|
||||
.counterfactual-inline-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.decisive-summary {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 3px solid var(--mat-sys-primary);
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.incomplete-explanation,
|
||||
.incomplete-incumbent {
|
||||
padding: 1rem;
|
||||
border-inline-start: 4px solid var(--ui-warning-text);
|
||||
background: var(--ui-warning-bg);
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.calendar-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -617,248 +456,3 @@ output {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calculation-content,
|
||||
.criteria-detail,
|
||||
.balance-detail {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.calculation-content {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.calculation-intro,
|
||||
.balance-detail > p {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.balance-ledger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2rem;
|
||||
margin: 0;
|
||||
dt {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.criterion-help {
|
||||
summary {
|
||||
padding: 0;
|
||||
align-content: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
p {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
}
|
||||
.decisive-row {
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.decisive-label {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tie-breaks {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 2px solid var(--ui-border);
|
||||
font-size: 0.875rem;
|
||||
ol {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
}
|
||||
.audit-data {
|
||||
border-top: 1px solid var(--ui-border);
|
||||
summary {
|
||||
padding-inline: 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
pre {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.counterfactual-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
background: var(--ui-card-bg);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
.comparison-heading,
|
||||
.comparison-footer,
|
||||
.comparison-result-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.comparison-heading {
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
.comparison-close {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
.counterfactual-panel .comparison-content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.comparison-date-pair {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
> mat-icon {
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
> .original-date {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
}
|
||||
.original-date {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
span {
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
strong {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
.comparison-range {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.nearby-dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
> span {
|
||||
flex-basis: 100%;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
}
|
||||
.comparison-result-heading {
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.available-date,
|
||||
.unavailable-date,
|
||||
.comparison-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.available-date {
|
||||
color: var(--ui-success-text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.unavailable-date {
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
border-radius: var(--ui-radius-sm);
|
||||
background: var(--ui-warning-bg);
|
||||
color: var(--ui-warning-text);
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
.comparison-empty {
|
||||
padding: 1rem;
|
||||
background: var(--ui-panel-bg);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
.comparison-loading {
|
||||
padding-block: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.comparison-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ui-text-secondary);
|
||||
.comparison-done {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
&:hover {
|
||||
background: var(--mat-sys-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.comparison-heading {
|
||||
padding: 1rem;
|
||||
}
|
||||
.counterfactual-panel .comparison-content {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
.comparison-date-pair {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
> mat-icon {
|
||||
display: none;
|
||||
}
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
.comparison-footer {
|
||||
padding: 1rem;
|
||||
flex-wrap: wrap;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nearby-dates button[aria-pressed='true'] {
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
||||
MatIcon,
|
||||
],
|
||||
templateUrl: './calendar-year.html',
|
||||
styleUrl: './calendar-year.scss',
|
||||
styleUrls: ['./planning-details.scss', './calendar-year.scss'],
|
||||
providers: DAY_FIRST_DATE_PROVIDERS,
|
||||
})
|
||||
export class CalendarYear {
|
||||
@@ -202,6 +202,32 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
||||
protected readonly weeksWithOneWorkday = computed(() => {
|
||||
const plan = this.displayedPlan();
|
||||
if (!plan) return [];
|
||||
const timeOff = new Set([
|
||||
...plan.placements.map(({ date }) => date),
|
||||
...(plan.declaredDays ?? []).map(({ date }) => date),
|
||||
]);
|
||||
const startDay = new Date(`${plan.horizon.start}T00:00:00Z`).getUTCDay();
|
||||
const weeks: { start: string; end: string; workday: string }[] = [];
|
||||
for (
|
||||
let monday = addPlanningDays(plan.horizon.start, (8 - startDay) % 7);
|
||||
addPlanningDays(monday, 4) < plan.horizon.end;
|
||||
monday = addPlanningDays(monday, 7)
|
||||
) {
|
||||
const workdays = Array.from({ length: 5 }, (_, day) =>
|
||||
addPlanningDays(monday, day),
|
||||
).filter((date) => isWorkingPlanningDate(date) && !timeOff.has(date));
|
||||
if (workdays.length === 1)
|
||||
weeks.push({
|
||||
start: monday,
|
||||
end: addPlanningDays(monday, 6),
|
||||
workday: workdays[0],
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
});
|
||||
protected readonly weekendConnectedHolidays = computed(() => {
|
||||
const isWeekend = (date: string) => {
|
||||
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
.placement-explanation,
|
||||
.outcome-explanation,
|
||||
.trace-decisions {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.placement-explanation,
|
||||
.trace-layout {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.placement-explanation ul {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.break-story,
|
||||
.outcome-explanation,
|
||||
.replay-notice {
|
||||
padding: 1rem;
|
||||
border-radius: var(--ui-radius-sm);
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.technical-details {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
> dl,
|
||||
> div {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
.break-story,
|
||||
.day-impact,
|
||||
.leave-story,
|
||||
.counterfactual-result {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.break-story > p,
|
||||
.day-impact > p {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.break-equation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
.impact-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 0.875rem;
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th:first-child {
|
||||
width: 52%;
|
||||
text-align: start;
|
||||
}
|
||||
td {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
}
|
||||
.compare-date-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.compare-dates-button {
|
||||
justify-self: start;
|
||||
}
|
||||
.placement-stepper,
|
||||
.trace-toolbar,
|
||||
.trace-decisions nav,
|
||||
.trace-phase-stepper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.placement-stepper button,
|
||||
.trace-phase-stepper button {
|
||||
padding: 0.375rem;
|
||||
}
|
||||
output {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.compact-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
.compact-facts > div {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
.compact-facts dt {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
.compact-facts dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reason-list {
|
||||
padding: 1rem 1rem 1rem 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
.inline-error,
|
||||
.counterfactual-inline-error,
|
||||
.conflict-explanation {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 4px solid var(--ui-error-text);
|
||||
background: var(--ui-error-bg);
|
||||
color: var(--ui-error-text);
|
||||
}
|
||||
.counterfactual-inline-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.decisive-summary {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 3px solid var(--mat-sys-primary);
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.incomplete-explanation,
|
||||
.incomplete-incumbent,
|
||||
.one-workday-weeks {
|
||||
padding: 1rem;
|
||||
border-inline-start: 4px solid var(--ui-warning-text);
|
||||
background: var(--ui-warning-bg);
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.calculation-content,
|
||||
.criteria-detail,
|
||||
.balance-detail {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.calculation-content {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.calculation-intro,
|
||||
.balance-detail > p {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.balance-ledger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2rem;
|
||||
margin: 0;
|
||||
dt {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.criterion-help {
|
||||
summary {
|
||||
padding: 0;
|
||||
align-content: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
p {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
}
|
||||
.decisive-row {
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.decisive-label {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tie-breaks {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 2px solid var(--ui-border);
|
||||
font-size: 0.875rem;
|
||||
ol {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
}
|
||||
.audit-data {
|
||||
border-top: 1px solid var(--ui-border);
|
||||
summary {
|
||||
padding-inline: 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
pre {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.counterfactual-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
background: var(--ui-card-bg);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
.comparison-heading,
|
||||
.comparison-footer,
|
||||
.comparison-result-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.comparison-heading {
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
.comparison-close {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
.counterfactual-panel .comparison-content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.comparison-date-pair {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
> mat-icon {
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
> .original-date {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
}
|
||||
.original-date {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
background: var(--ui-panel-bg);
|
||||
span {
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
strong {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
.comparison-range {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.nearby-dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
> span {
|
||||
flex-basis: 100%;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
}
|
||||
.comparison-result-heading {
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.available-date,
|
||||
.unavailable-date,
|
||||
.comparison-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.available-date {
|
||||
color: var(--ui-success-text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.unavailable-date {
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
border-radius: var(--ui-radius-sm);
|
||||
background: var(--ui-warning-bg);
|
||||
color: var(--ui-warning-text);
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
.comparison-empty {
|
||||
padding: 1rem;
|
||||
background: var(--ui-panel-bg);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
}
|
||||
.comparison-loading {
|
||||
padding-block: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.comparison-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ui-text-secondary);
|
||||
.comparison-done {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
&:hover {
|
||||
background: var(--mat-sys-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.comparison-heading {
|
||||
padding: 1rem;
|
||||
}
|
||||
.counterfactual-panel .comparison-content {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
.comparison-date-pair {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
> mat-icon {
|
||||
display: none;
|
||||
}
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
.comparison-footer {
|
||||
padding: 1rem;
|
||||
flex-wrap: wrap;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nearby-dates button[aria-pressed='true'] {
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
SelectedDates,
|
||||
} from './calendar-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
import Holidays from 'date-holidays';
|
||||
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
let service: CalendarService;
|
||||
@@ -158,6 +160,18 @@ describe('CalendarService', () => {
|
||||
});
|
||||
|
||||
describe('getHolidays', () => {
|
||||
it('preserves upstream French public holidays across historical and future years', () => {
|
||||
const upstream = new Holidays('FR', { languages: ['en'] });
|
||||
for (const year of [1900, 1918, 1945, 1999, 2000, 2024, 2038, 2100]) {
|
||||
expect([...frenchPublicHolidaysForYear(year)]).toEqual(
|
||||
upstream
|
||||
.getHolidays(year, 'en')
|
||||
.filter(({ type }) => type === 'public')
|
||||
.map(({ date }) => date.slice(0, 10)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return an array of holidays', () => {
|
||||
const holidays = service.getHolidays();
|
||||
expect(Array.isArray(holidays)).toBe(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Holidays from 'date-holidays';
|
||||
import Holidays from 'date-holidays-parser';
|
||||
import frenchHolidays from './french-holidays-data.json';
|
||||
import { isPlanningDate } from './planning-date';
|
||||
import type { LeaveType } from './leave-planner.core';
|
||||
|
||||
@@ -13,7 +14,7 @@ export function frenchPublicHolidaysForYear(year: number): ReadonlySet<string> {
|
||||
let holidays = holidaysByYear.get(year);
|
||||
if (!holidays) {
|
||||
holidays = new Set(
|
||||
new Holidays('FR', { languages: ['en'] })
|
||||
new Holidays(frenchHolidays, 'FR', { languages: ['en'] })
|
||||
.getHolidays(year, 'en')
|
||||
.filter((holiday) => holiday.type === 'public')
|
||||
.map((holiday) => String(holiday.date).slice(0, 10)),
|
||||
|
||||
@@ -228,6 +228,21 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
)
|
||||
?.nextElementSibling?.textContent?.trim();
|
||||
expect(connectedHolidays()).toBe('1');
|
||||
const oneWorkdayWeeks = () => [
|
||||
Array.from(document.querySelectorAll('[role="dialog"] .statistics-summary dt'))
|
||||
.find((label) =>
|
||||
label.textContent?.includes('Semaines avec un seul jour travaillé'),
|
||||
)
|
||||
?.nextElementSibling?.textContent?.trim(),
|
||||
...Array.from(
|
||||
document.querySelectorAll('[role="dialog"] .one-workday-weeks li'),
|
||||
(entry) => entry.textContent?.replace(/\s+/g, ' ').trim(),
|
||||
),
|
||||
];
|
||||
expect(oneWorkdayWeeks()).toEqual([
|
||||
'1',
|
||||
'29 avr. 2024 – 5 mai 2024 Seul jour travaillé: 2 mai 2024',
|
||||
]);
|
||||
const earnedStatistics = () => [
|
||||
dialog
|
||||
.querySelector('.statistics-summary > div:last-child dt')
|
||||
@@ -266,6 +281,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(connectedHolidays()).toBe('1');
|
||||
expect(oneWorkdayWeeks()).toEqual([
|
||||
'1',
|
||||
'29 avr. 2024 – 5 mai 2024 Seul jour travaillé: 2 mai 2024',
|
||||
]);
|
||||
expect(earnedStatistics()).toEqual([
|
||||
'Congés acquis avant consommation (jours)',
|
||||
'1',
|
||||
@@ -343,6 +362,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
|
||||
await fixture.whenStable();
|
||||
expect(connectedHolidays()).toBe('1');
|
||||
expect(oneWorkdayWeeks()).toEqual([
|
||||
'1',
|
||||
'29 avr. 2024 – 5 mai 2024 Seul jour travaillé: 2 mai 2024',
|
||||
]);
|
||||
fixture.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -135,6 +135,10 @@ export interface AppTranslations {
|
||||
averageTimeOffPeriod: string;
|
||||
longestTimeOffPeriod: string;
|
||||
weekendConnectedHolidays: string;
|
||||
weeksWithOneWorkday: string;
|
||||
oneWorkdayWarning: string;
|
||||
oneWorkdayWarningHelp: string;
|
||||
onlyWorkday: string;
|
||||
earnedLeave: string;
|
||||
earnedLeaveByRule: string;
|
||||
leaveDaysUnit: string;
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// The parser ships these declarations but omits them from its package exports.
|
||||
import '../node_modules/date-holidays-parser/types/index';
|
||||
@@ -144,6 +144,10 @@
|
||||
"averageTimeOffPeriod": "Durchschnittliche Ruhephase",
|
||||
"longestTimeOffPeriod": "Längste Ruhephase",
|
||||
"weekendConnectedHolidays": "Feiertage mit Wochenendanschluss",
|
||||
"weeksWithOneWorkday": "Wochen mit nur einem Arbeitstag",
|
||||
"oneWorkdayWarning": "Achtung: Wochen mit nur einem Arbeitstag",
|
||||
"oneWorkdayWarningHelp": "Nur einen Tag in einer vollständigen Woche zu arbeiten, ist ein ungünstiges Muster.",
|
||||
"onlyWorkday": "Einziger Arbeitstag",
|
||||
"workingDays": "Arbeitstage",
|
||||
"calendarDays": "Kalendertage",
|
||||
"partialPlan": "Teilplan",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"averageTimeOffPeriod": "Average time-off period",
|
||||
"longestTimeOffPeriod": "Longest time-off period",
|
||||
"weekendConnectedHolidays": "Public holidays connected to a weekend",
|
||||
"weeksWithOneWorkday": "Weeks with one workday",
|
||||
"oneWorkdayWarning": "Warning: weeks with one workday",
|
||||
"oneWorkdayWarningHelp": "Working just one day in a full week is an undesirable pattern.",
|
||||
"onlyWorkday": "Only workday",
|
||||
"workingDays": "working days",
|
||||
"calendarDays": "calendar days",
|
||||
"partialPlan": "Partial plan",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"averageTimeOffPeriod": "Período de descanso medio",
|
||||
"longestTimeOffPeriod": "Período de descanso más largo",
|
||||
"weekendConnectedHolidays": "Festivos conectados con un fin de semana",
|
||||
"weeksWithOneWorkday": "Semanas con un solo día de trabajo",
|
||||
"oneWorkdayWarning": "Atención: semanas con un solo día de trabajo",
|
||||
"oneWorkdayWarningHelp": "Trabajar solo un día en una semana completa es un patrón poco deseable.",
|
||||
"onlyWorkday": "Único día de trabajo",
|
||||
"workingDays": "días laborables",
|
||||
"calendarDays": "días naturales",
|
||||
"partialPlan": "Plan parcial",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"averageTimeOffPeriod": "Période de repos moyenne",
|
||||
"longestTimeOffPeriod": "Plus longue période de repos",
|
||||
"weekendConnectedHolidays": "Jours fériés reliés à un week-end",
|
||||
"weeksWithOneWorkday": "Semaines avec un seul jour travaillé",
|
||||
"oneWorkdayWarning": "Attention : semaines avec un seul jour travaillé",
|
||||
"oneWorkdayWarningHelp": "Travailler un seul jour dans une semaine complète est un rythme peu souhaitable.",
|
||||
"onlyWorkday": "Seul jour travaillé",
|
||||
"workingDays": "jours ouvrés",
|
||||
"calendarDays": "jours calendaires",
|
||||
"partialPlan": "Planning partiel",
|
||||
|
||||
Reference in new issue
Block a user