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
Thumbs.db
# Auto-generated i18n files
# Auto-generated application data
/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/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
View File
@@ -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",
+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();
});
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);
}
+14
View File
@@ -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();
},
);
+4
View File
@@ -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;
+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",
"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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",