Compare commits

...
18 changed files with 794 additions and 431 deletions

No files matched your search

+2 -1
View File
@@ -42,5 +42,6 @@ __screenshots__/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Auto-generated i18n files # Auto-generated application data
/src/app/i18n-registry.ts /src/app/i18n-registry.ts
/src/app/services/french-holidays-data.json
+2 -1
View File
@@ -17,7 +17,7 @@
"@angular/platform-server": "22.2.1", "@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1", "@angular/router": "22.2.1",
"@angular/ssr": "22.2.1", "@angular/ssr": "22.2.1",
"date-holidays": "^3.30.2", "date-holidays-parser": "^3.4.7",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"express": "^5.2.1", "express": "^5.2.1",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
@@ -39,6 +39,7 @@
"@vitest/coverage-istanbul": "^5.0.0", "@vitest/coverage-istanbul": "^5.0.0",
"@vitest/coverage-v8": "^5.0.0", "@vitest/coverage-v8": "^5.0.0",
"@vitest/ui": "^5.0.0", "@vitest/ui": "^5.0.0",
"date-holidays": "^3.30.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
+10 -8
View File
@@ -3,15 +3,16 @@
"version": "1.5.0", "version": "1.5.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "bun scripts/generate-langs.js && ng serve", "generate": "bun scripts/generate-langs.js && bun scripts/generate-holidays.js",
"build": "bun scripts/generate-langs.js && ng build", "start": "bun run generate && ng serve",
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development", "build": "bun run generate && ng build",
"test": "bun scripts/generate-langs.js && ng test", "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", "serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"", "lint": "bun run generate && eslint \"src/**/*.ts\"",
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix", "lint:fix": "bun run generate && eslint \"src/**/*.ts\" --fix",
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"", "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": { "prettier": {
"printWidth": 100, "printWidth": 100,
@@ -39,7 +40,7 @@
"@angular/platform-server": "22.2.1", "@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1", "@angular/router": "22.2.1",
"@angular/ssr": "22.2.1", "@angular/ssr": "22.2.1",
"date-holidays": "^3.30.2", "date-holidays-parser": "^3.4.7",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"express": "^5.2.1", "express": "^5.2.1",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
@@ -61,6 +62,7 @@
"@vitest/coverage-istanbul": "^5.0.0", "@vitest/coverage-istanbul": "^5.0.0",
"@vitest/coverage-v8": "^5.0.0", "@vitest/coverage-v8": "^5.0.0",
"@vitest/ui": "^5.0.0", "@vitest/ui": "^5.0.0",
"date-holidays": "^3.30.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
+9
View File
@@ -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(); 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([ it.each([
{ {
label: 'a Monday holiday directly beside a weekend', 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'], ['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'], ['Longest time-off period (calendar days)', '4'],
['Public holidays connected to a weekend (calendar days)', '2'], ['Public holidays connected to a weekend (calendar days)', '2'],
['Weeks with one workday', placements ? '0' : '1'],
['Earned leave before use (days)', '3'], ['Earned leave before use (days)', '3'],
['Original generating rule', '3'], ['Original generating rule', '3'],
]; ];
expect(statistics()).toEqual(expectedStatistics); 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(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(input.planningOutcomeSignal()).toBe(outcome); expect(input.planningOutcomeSignal()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances); expect(input.vacationNumberSignal()).toEqual(before.balances);
@@ -694,6 +869,7 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
expect(statistics()).toEqual(expectedStatistics); expect(statistics()).toEqual(expectedStatistics);
expect(matchingWeeks()).toEqual(expectedWeeks);
input.updateBalances(() => []); input.updateBalances(() => []);
input.toggleDeclaredDay('2024-05-10', 'other'); input.toggleDeclaredDay('2024-05-10', 'other');
rules.deleteRule(rules.rules[0].id); rules.deleteRule(rules.rules[0].id);
@@ -703,6 +879,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics); expect(statistics()).toEqual(expectedStatistics);
expect(matchingWeeks()).toEqual(expectedWeeks);
expect(input.planningOutcomeSignal()).toBe(outcome); expect(input.planningOutcomeSignal()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
}, },
@@ -1108,6 +1285,18 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable(); await fixture.whenStable();
const dialog = document.querySelector('[role="dialog"]')!; const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain('Partial plan'); 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( expect(
dialog dialog
.querySelector('.statistics-summary > div:last-child dd') .querySelector('.statistics-summary > div:last-child dd')
@@ -1162,8 +1351,8 @@ describe('CalendarYear planning outcome boundary', () => {
), ),
).toEqual( ).toEqual(
allDaysOff allDaysOff
? ['0', '4', '—', '0', '9', '9', '0', '0'] ? ['0', '4', '—', '0', '9', '9', '0', '0', '0']
: ['0', '0', '5', '5', '—', '—', '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 () => { it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
const { calendar } = calendarFor({ const { calendar } = calendarFor({
...plan, ...plan,
declaredDays: ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13'].map((date) => ({
date,
type: 'other',
})),
metrics: { metrics: {
...plan.metrics, ...plan.metrics,
averageWorkSequence: 10 / 3, 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-labelledby')).toBe('plan-statistics-title');
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help'); expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2'))); await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
for (const [language, workUnit, calendarUnit, holidayLabel] of [ expect(dialog.querySelector('.one-workday-weeks li')?.textContent).toContain('14 Jun 2024');
['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'], for (const [language, workUnit, calendarUnit, holidayLabel, warningTitle, entry] of [
['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'], '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); translation.setLanguage(language);
fixture.detectChanges(); fixture.detectChanges();
expect(dialog.textContent).toContain(workUnit); expect(dialog.textContent).toContain(workUnit);
expect(dialog.textContent).toContain(calendarUnit); expect(dialog.textContent).toContain(calendarUnit);
expect(dialog.textContent).toContain(holidayLabel); 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( expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()), 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.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés'); 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][])( ] satisfies [string, PlanningOutcome, boolean][])(
'replaces open statistics after %s and ignores an older valid outcome', 'replaces open statistics after %s and ignores an older valid outcome',
async (_, replacement: PlanningOutcome, focusMoved) => { 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 oldOutcome = deferred<PlanningOutcome>();
const oldTrace = deferred<PlanningTrace | undefined>(); const oldTrace = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan); const { calendar } = calendarFor(previousPlan);
calendar.createPlanningCalculation calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan)) .mockReturnValueOnce(calculationFor(previousPlan))
.mockReturnValueOnce({ .mockReturnValueOnce({
...calculationFor(plan, undefined, undefined, oldTrace.promise), ...calculationFor(previousPlan, undefined, undefined, oldTrace.promise),
outcome: oldOutcome.promise, outcome: oldOutcome.promise,
}) })
.mockReturnValueOnce(calculationFor(replacement)); .mockReturnValueOnce(calculationFor(replacement));
@@ -1255,6 +1488,7 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar }, { provide: CalendarService, useValue: calendar },
], ],
}).compileComponents(); }).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const input = TestBed.inject(UserInputService); const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear); const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges(); fixture.detectChanges();
@@ -1264,6 +1498,9 @@ describe('CalendarYear planning outcome boundary', () => {
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!); activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable(); await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); 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(); input.requestComputation();
fixture.detectChanges(); fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
@@ -1276,7 +1513,7 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
oldOutcome.resolve(plan); oldOutcome.resolve(previousPlan);
oldTrace.resolve(jointTrace()); oldTrace.resolve(jointTrace());
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
@@ -1287,6 +1524,11 @@ describe('CalendarYear planning outcome boundary', () => {
opener!.click(); opener!.click();
await fixture.whenStable(); await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('1'); 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 { } else {
expect(opener).toBeNull(); expect(opener).toBeNull();
expect(document.activeElement).toBe( expect(document.activeElement).toBe(
@@ -3509,7 +3751,7 @@ describe('CalendarYear planning outcome boundary', () => {
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) => Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(), 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(); document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton); activateWithKeyboard(afterButton);
@@ -1303,6 +1303,10 @@
</dt> </dt>
<dd>{{ formatStatistic(weekendConnectedHolidays()) }}</dd> <dd>{{ formatStatistic(weekendConnectedHolidays()) }}</dd>
</div> </div>
<div>
<dt>{{ tr().planning.statistics.weeksWithOneWorkday }}</dt>
<dd>{{ formatStatistic(weeksWithOneWorkday().length) }}</dd>
</div>
<div> <div>
<dt> <dt>
{{ tr().planning.statistics.earnedLeave }} ({{ {{ tr().planning.statistics.earnedLeave }} ({{
@@ -1332,6 +1336,24 @@
</dl> </dl>
</section> </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-content>
<mat-dialog-actions align="end"> <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 { .calendar-help {
display: flex; display: flex;
align-items: center; 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, MatIcon,
], ],
templateUrl: './calendar-year.html', templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss', styleUrls: ['./planning-details.scss', './calendar-year.scss'],
providers: DAY_FIRST_DATE_PROVIDERS, providers: DAY_FIRST_DATE_PROVIDERS,
}) })
export class CalendarYear { export class CalendarYear {
@@ -202,6 +202,32 @@ export class CalendarYear {
} }
protected readonly displayedPlan = this.userInput.displayedPlanSignal; 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(() => { protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => { const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay(); 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);
}
+14
View File
@@ -8,6 +8,8 @@ import {
SelectedDates, SelectedDates,
} from './calendar-service'; } from './calendar-service';
import { DateRange } from '@angular/material/datepicker'; import { DateRange } from '@angular/material/datepicker';
import Holidays from 'date-holidays';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
describe('CalendarService', () => { describe('CalendarService', () => {
let service: CalendarService; let service: CalendarService;
@@ -158,6 +160,18 @@ describe('CalendarService', () => {
}); });
describe('getHolidays', () => { 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', () => { it('should return an array of holidays', () => {
const holidays = service.getHolidays(); const holidays = service.getHolidays();
expect(Array.isArray(holidays)).toBe(true); 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 { isPlanningDate } from './planning-date';
import type { LeaveType } from './leave-planner.core'; import type { LeaveType } from './leave-planner.core';
@@ -13,7 +14,7 @@ export function frenchPublicHolidaysForYear(year: number): ReadonlySet<string> {
let holidays = holidaysByYear.get(year); let holidays = holidaysByYear.get(year);
if (!holidays) { if (!holidays) {
holidays = new Set( holidays = new Set(
new Holidays('FR', { languages: ['en'] }) new Holidays(frenchHolidays, 'FR', { languages: ['en'] })
.getHolidays(year, 'en') .getHolidays(year, 'en')
.filter((holiday) => holiday.type === 'public') .filter((holiday) => holiday.type === 'public')
.map((holiday) => String(holiday.date).slice(0, 10)), .map((holiday) => String(holiday.date).slice(0, 10)),
@@ -228,6 +228,21 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
) )
?.nextElementSibling?.textContent?.trim(); ?.nextElementSibling?.textContent?.trim();
expect(connectedHolidays()).toBe('1'); 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 = () => [ const earnedStatistics = () => [
dialog dialog
.querySelector('.statistics-summary > div:last-child dt') .querySelector('.statistics-summary > div:last-child dt')
@@ -266,6 +281,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
expect(connectedHolidays()).toBe('1'); expect(connectedHolidays()).toBe('1');
expect(oneWorkdayWeeks()).toEqual([
'1',
'29 avr. 2024 – 5 mai 2024 Seul jour travaillé: 2 mai 2024',
]);
expect(earnedStatistics()).toEqual([ expect(earnedStatistics()).toEqual([
'Congés acquis avant consommation (jours)', 'Congés acquis avant consommation (jours)',
'1', '1',
@@ -343,6 +362,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
root.querySelector<HTMLButtonElement>('.statistics-button')!.click(); root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable(); await fixture.whenStable();
expect(connectedHolidays()).toBe('1'); expect(connectedHolidays()).toBe('1');
expect(oneWorkdayWeeks()).toEqual([
'1',
'29 avr. 2024 – 5 mai 2024 Seul jour travaillé: 2 mai 2024',
]);
fixture.destroy(); fixture.destroy();
}, },
); );
+4
View File
@@ -135,6 +135,10 @@ export interface AppTranslations {
averageTimeOffPeriod: string; averageTimeOffPeriod: string;
longestTimeOffPeriod: string; longestTimeOffPeriod: string;
weekendConnectedHolidays: string; weekendConnectedHolidays: string;
weeksWithOneWorkday: string;
oneWorkdayWarning: string;
oneWorkdayWarningHelp: string;
onlyWorkday: string;
earnedLeave: string; earnedLeave: string;
earnedLeaveByRule: string; earnedLeaveByRule: string;
leaveDaysUnit: string; leaveDaysUnit: string;
+2
View File
@@ -0,0 +1,2 @@
// The parser ships these declarations but omits them from its package exports.
import '../node_modules/date-holidays-parser/types/index';
+4
View File
@@ -144,6 +144,10 @@
"averageTimeOffPeriod": "Durchschnittliche Ruhephase", "averageTimeOffPeriod": "Durchschnittliche Ruhephase",
"longestTimeOffPeriod": "Längste Ruhephase", "longestTimeOffPeriod": "Längste Ruhephase",
"weekendConnectedHolidays": "Feiertage mit Wochenendanschluss", "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", "workingDays": "Arbeitstage",
"calendarDays": "Kalendertage", "calendarDays": "Kalendertage",
"partialPlan": "Teilplan", "partialPlan": "Teilplan",
+4
View File
@@ -144,6 +144,10 @@
"averageTimeOffPeriod": "Average time-off period", "averageTimeOffPeriod": "Average time-off period",
"longestTimeOffPeriod": "Longest time-off period", "longestTimeOffPeriod": "Longest time-off period",
"weekendConnectedHolidays": "Public holidays connected to a weekend", "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", "workingDays": "working days",
"calendarDays": "calendar days", "calendarDays": "calendar days",
"partialPlan": "Partial plan", "partialPlan": "Partial plan",
+4
View File
@@ -144,6 +144,10 @@
"averageTimeOffPeriod": "Período de descanso medio", "averageTimeOffPeriod": "Período de descanso medio",
"longestTimeOffPeriod": "Período de descanso más largo", "longestTimeOffPeriod": "Período de descanso más largo",
"weekendConnectedHolidays": "Festivos conectados con un fin de semana", "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", "workingDays": "días laborables",
"calendarDays": "días naturales", "calendarDays": "días naturales",
"partialPlan": "Plan parcial", "partialPlan": "Plan parcial",
+4
View File
@@ -144,6 +144,10 @@
"averageTimeOffPeriod": "Période de repos moyenne", "averageTimeOffPeriod": "Période de repos moyenne",
"longestTimeOffPeriod": "Plus longue période de repos", "longestTimeOffPeriod": "Plus longue période de repos",
"weekendConnectedHolidays": "Jours fériés reliés à un week-end", "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", "workingDays": "jours ouvrés",
"calendarDays": "jours calendaires", "calendarDays": "jours calendaires",
"partialPlan": "Planning partiel", "partialPlan": "Planning partiel",