feat(planning): implement constraint-first leave planner

This commit is contained in:
nessar committed 2026-08-28 22:44:14 +02:00
1 parent ba8dbe92d9
commit 7ceaf1a57a
32 files changed
+4842 -880

No files matched your search

+12 -9
View File
@@ -1,10 +1,13 @@
<app-dark-light-mode-button class="modeButton"></app-dark-light-mode-button>
<main class="main">
<h1 style="display: flex; gap: 1rem"><mat-icon>beach_access</mat-icon> {{ title() }}</h1>
<div class="content">
<app-user-input></app-user-input>
<mat-divider [vertical]="true" />
<mat-divider />
<app-calendar-year></app-calendar-year>
</div>
</main>
@if (showPlanner()) {
<main class="main">
<h1 style="display: flex; gap: 1rem"><mat-icon>beach_access</mat-icon> {{ title() }}</h1>
<div class="content">
<app-user-input></app-user-input>
<mat-divider [vertical]="true" />
<mat-divider />
<app-calendar-year></app-calendar-year>
</div>
</main>
}
<router-outlet></router-outlet>
+7 -1
View File
@@ -1,3 +1,9 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];
export const routes: Routes = [
{
path: 'benchmark',
loadComponent: () =>
import('./benchmark/benchmark-page').then((module) => module.BenchmarkPage),
},
];
+12
View File
@@ -22,4 +22,16 @@ describe('App', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('vac-optimizer');
});
it('does not mount the normal planner shell on the benchmark route', () => {
const originalPath = window.location.pathname;
history.pushState({}, '', '/benchmark');
try {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-calendar-year')).toBeNull();
} finally {
history.replaceState({}, '', originalPath);
}
});
});
+18 -2
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { provideNativeDateAdapter } from '@angular/material/core';
import { MatDivider } from '@angular/material/divider';
@@ -6,6 +6,11 @@ import { UserInput } from './components/user-input/user-input';
import { CalendarYear } from './components/calendar-year/calendar-year';
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
import { MatIcon } from '@angular/material/icon';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
function isBenchmarkUrl(url: string): boolean {
return url.split(/[?#]/, 1)[0].replace(/\/+$/, '') === '/benchmark';
}
@Component({
selector: 'app-root',
@@ -15,8 +20,8 @@ import { MatIcon } from '@angular/material/icon';
UserInput,
CalendarYear,
DarkLightModeButton,
DarkLightModeButton,
MatIcon,
RouterOutlet,
],
templateUrl: './app.html',
styleUrl: './app.scss',
@@ -24,5 +29,16 @@ import { MatIcon } from '@angular/material/icon';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
private readonly router = inject(Router);
protected readonly title = signal('vac-optimizer');
protected readonly showPlanner = signal(
!isBenchmarkUrl(typeof window !== 'undefined' ? window.location.pathname : this.router.url),
);
constructor() {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd)
this.showPlanner.set(!isBenchmarkUrl(event.urlAfterRedirects));
});
}
}
+44
View File
@@ -0,0 +1,44 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { benchmarkReport, runLeavePlannerBenchmark } from './leave-planner-benchmark';
import { TranslationService } from '../services/translation-service';
@Component({
selector: 'app-benchmark-page',
template: `
<section aria-labelledby="benchmark-title">
<h2 id="benchmark-title">{{ tr().benchmark.title }}</h2>
<p>{{ tr().benchmark.instructions }}</p>
<button type="button" (click)="run()" [disabled]="running()">
{{ running() ? tr().benchmark.running : tr().benchmark.run }}
</button>
<button type="button" (click)="copy()" [disabled]="!report()">
{{ tr().benchmark.copy }}
</button>
<pre aria-live="polite">{{ report() }}</pre>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BenchmarkPage {
private readonly translationService = inject(TranslationService);
readonly running = signal(false);
readonly report = signal('');
get tr() {
return this.translationService.tr;
}
async run(): Promise<void> {
this.running.set(true);
try {
this.report.set(benchmarkReport(await runLeavePlannerBenchmark(3)));
} finally {
this.running.set(false);
}
}
async copy(): Promise<void> {
if (this.report() && typeof navigator !== 'undefined' && navigator.clipboard)
await navigator.clipboard.writeText(this.report());
}
}
@@ -0,0 +1,37 @@
import { vi } from 'vitest';
import {
benchmarkReport,
benchmarkScenarios,
runLeavePlannerBenchmark,
} from './leave-planner-benchmark';
describe('leave planner benchmark protocol', () => {
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(5);
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
'wednesday-holidays',
'thursday-consumer',
]);
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const measurements = await runLeavePlannerBenchmark(1);
const chained = measurements.find(
(measurement) => measurement.scenario === 'chained earned entitlements',
);
const loaded = measurements.find(
(measurement) => measurement.scenario === 'loaded year',
);
expect(chained?.outcome).toBe('Plan');
expect(chained?.placements).toBeGreaterThanOrEqual(3);
expect(loaded?.outcome).toBe('Plan');
expect(loaded?.deterministic).toBe(true);
expect(benchmarkReport(measurements).split('\n')).toHaveLength(6);
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 30_000);
});
@@ -0,0 +1,207 @@
import { planLeave, PlanningOutcome, PlanningRequest } from '../services/leave-planner.service';
export interface BenchmarkScenario {
name: string;
request: PlanningRequest;
}
export interface BenchmarkMeasurement {
scenario: string;
repetition: number;
preparationMs: number;
calculationMs: number;
wallMs: number;
outcome: PlanningOutcome['kind'];
lastCompletedStage?: string;
placements: number;
deterministic: boolean;
}
const balance = (
id: string,
type: 'cp' | 'rtt' | 'other',
quantity: number,
expiryDate = '2024-12-31',
) => ({
id,
type,
quantity,
availableFrom: '2024-01-01',
expiryDate,
});
export function benchmarkScenarios(): BenchmarkScenario[] {
const horizon = { start: '2024-01-01', end: '2025-01-01' };
return [
{
name: 'representative year',
request: {
horizon,
balances: [balance('cp', 'cp', 20), balance('rtt', 'rtt', 10)],
rules: [],
},
},
{
name: 'typed obligations',
request: {
horizon,
balances: [balance('cp', 'cp', 5), balance('rtt', 'rtt', 5)],
rules: [
{
id: 'cp-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-01',
endDate: '2024-04-12',
placeDays: 2,
placeType: 'cp',
},
{
id: 'rtt-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-01',
endDate: '2024-05-12',
placeDays: 2,
placeType: 'rtt',
},
],
},
},
{
name: 'chained earned entitlements',
request: {
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
rules: [
{
id: 'wednesday-holidays',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'thursday-consumer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 4,
placeDays: 1,
placeType: 'rtt',
placement: 'before',
rewardDays: 0,
},
],
},
},
{
name: 'deliberate conflict',
request: {
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [
{
id: 'rtt-required',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-01',
endDate: '2024-04-05',
placeDays: 1,
placeType: 'rtt',
},
],
},
},
{
name: 'loaded year',
request: {
horizon,
balances: Array.from({ length: 12 }, (_, index) =>
balance(`cp-${index}`, index % 2 ? 'rtt' : 'cp', 12),
),
rules: Array.from({ length: 8 }, (_, index) => ({
id: `window-${index}`,
type: 'time_window' as const,
force: index < 3 ? ('obligation' as const) : ('preference' as const),
startDate: `2024-${String(index + 1).padStart(2, '0')}-01`,
endDate: `2024-${String(index + 1).padStart(2, '0')}-28`,
placeDays: 3,
placeType: index % 2 ? ('rtt' as const) : ('any' as const),
})),
},
},
];
}
function resultSummary(outcome: PlanningOutcome): {
outcome: PlanningOutcome['kind'];
lastCompletedStage?: string;
placements: number;
} {
if (outcome.kind === 'Plan')
return {
outcome: outcome.kind,
lastCompletedStage: outcome.lastCompletedStage,
placements: outcome.placements.length,
};
if (outcome.kind === 'PlanningIncomplete')
return {
outcome: outcome.kind,
lastCompletedStage: outcome.lastCompletedStage,
placements: outcome.incumbent?.placements.length ?? 0,
};
return { outcome: outcome.kind, placements: 0 };
}
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
const measurements: BenchmarkMeasurement[] = [];
for (const scenario of benchmarkScenarios()) {
let previous: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) {
const preparationStart = performance.now();
const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart;
const calculationStart = performance.now();
const outcome = await planLeave(request);
const calculationMs = performance.now() - calculationStart;
const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome);
measurements.push({
scenario: scenario.name,
repetition,
preparationMs,
calculationMs,
wallMs: preparationMs + calculationMs,
...summary,
deterministic: previous === undefined || previous === stable,
});
previous = stable;
}
}
return measurements;
}
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
const header =
'scenario\trepetition\tpreparation_ms\tcalculation_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic';
return [
header,
...measurements.map((item) =>
[
item.scenario,
item.repetition,
item.preparationMs.toFixed(1),
item.calculationMs.toFixed(1),
item.wallMs.toFixed(1),
item.outcome,
item.lastCompletedStage ?? '',
item.placements,
item.deterministic,
].join('\t'),
),
].join('\n');
}
@@ -0,0 +1,146 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarYear } from './calendar-year';
import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service';
import {
PlanOutcome,
PlanningConflictOutcome,
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
const invalid: InvalidInputOutcome = {
kind: 'InvalidInput',
reasons: [],
};
const conflict: PlanningConflictOutcome = {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
};
const incomplete: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [],
};
const plan: PlanOutcome = {
kind: 'Plan',
horizon: { start: '2024-01-01', end: '2025-01-01' },
placements: [],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
metrics: {
unplacedAtRisk: 0,
nonWorkingDayGain: 0,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
supportingPlacements: 0,
preferenceSatisfaction: [],
},
completedStages: ['dates'],
lastCompletedStage: 'dates',
};
describe('CalendarYear planning outcome boundary', () => {
it.each([
['InvalidInput', invalid],
['PlanningConflict', conflict],
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => outcome),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('keeps a returned incumbent/result visible only when an outcome supplies one', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => plan),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => {
throw new Error('planner exploded');
}),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const outcome = fixture.componentInstance['planningOutcome']();
expect(outcome?.kind).toBe('PlanningIncomplete');
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
});
@@ -10,6 +10,17 @@
</div>
</div>
}
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status" role="alert">{{ tr().planning.invalidInput }}</p>
} @else if (planningOutcome()?.kind === 'PlanningConflict') {
<p class="planning-status" role="alert">{{ tr().planning.conflict }}</p>
} @else if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p class="planning-status" role="status">
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status" role="status">{{ tr().planning.complete }}</p>
}
@for (month of months; track $index) {
<mat-card class="card">
<mat-card-content>
@@ -7,6 +7,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { DateCacheService } from '../../services/date-cache.service';
import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
import { SelectableDayType, VacDay } from '../../services/calendar-service';
@Component({
selector: 'app-calendar-year',
@@ -28,6 +30,30 @@ export class CalendarYear {
protected isLoading = signal(true);
protected loadingProgress = signal(0);
protected planningOutcome = signal<PlanningOutcome | null>(null);
private calculationVersion = 0;
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
);
}
private stageProgress(stage: string): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:')) return 50;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
if (stage === 'supporting-placements') return 86;
if (stage === 'expiry') return 93;
if (stage === 'dates') return 98;
return this.loadingProgress();
}
// Use DateCacheService instead of creating Date objects
private get currentMonth() {
@@ -43,37 +69,90 @@ export class CalendarYear {
this.userInput.computeTriggerSignal();
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.planningOutcome.set(null);
this.userInput.remainingVacationDaysSignal.set([]);
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
console.log('Optimizing vacations with data:', vacationData);
console.log('Using rules:', currentRules);
this.isLoading.set(true);
this.loadingProgress.set(0);
const version = ++this.calculationVersion;
// Defer execution to allow UI to update
queueMicrotask(() => {
void (async () => {
if (this.selectedDates) {
// Create a deep copy to avoid mutating the original signal data
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
const remainingDays = await this.selectedDates.optimizeVacations(
vacationDaysCopy,
currentRules,
this.calendarService,
(progress: number) => {
this.loadingProgress.set(progress);
try {
const outcome = await this.calendarService.planLeave({
horizon: this.calendarService.getDefaultPlanningHorizon(),
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate,
expiryDate: balance.expiryDate,
})),
rules: currentRules,
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
);
// Update the remaining vacation days signal
this.userInput.remainingVacationDaysSignal.set(remainingDays);
});
if (version !== this.calculationVersion) return;
this.applyOutcome(outcome);
} catch (error) {
if (version !== this.calculationVersion) return;
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
} finally {
if (version === this.calculationVersion) {
this.loadingProgress.set(100);
this.isLoading.set(false);
}
}
this.isLoading.set(false);
})();
});
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
if (plan) {
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.userInput.remainingVacationDaysSignal.set([]);
}
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
return plan.remainingBalances.map((balance) => ({
id: balance.id,
type: balance.type as SelectableDayType,
numberOfDays: balance.quantity,
availableDate: new Date(`${balance.availableFrom}T00:00:00`),
expiryDate: new Date(`${balance.expiryDate}T00:00:00`),
classification: balance.classification,
reason: balance.reason,
}));
}
protected selectedDates: SelectedDates = new SelectedDates();
getYear(monthIndex: number): number {
@@ -37,12 +37,17 @@
>
</mat-slide-toggle>
<div class="rule-actions">
<button mat-icon-button (click)="startEdit(rule, $event)">
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
@@ -59,6 +64,16 @@
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{
option.label()
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if ($any(editBuffer).type === RuleType.IF_THEN) {
<div class="editor-row">
@@ -210,6 +225,14 @@
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label() }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (newRuleType() === RuleType.IF_THEN) {
<div class="editor-row">
+11 -4
View File
@@ -101,6 +101,10 @@ export class RulesPanel {
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly forceOptions = [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
{ value: 'preference' as const, label: () => this.tr().rules.preference },
];
protected get daysOfWeek() {
const tr = this.tr();
@@ -186,6 +190,7 @@ export class RulesPanel {
this.editBuffer = {
name: this.tr().rules.defaultNewRuleName,
type: RuleType.IF_THEN,
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: SelectableDayType.CP,
@@ -197,6 +202,7 @@ export class RulesPanel {
this.editBuffer = {
name: this.tr().rules.defaultNewPeriodName,
type: RuleType.TIME_WINDOW,
force: 'preference',
startDate: new Date(),
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
placeDays: 5,
@@ -212,6 +218,7 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
const forceLabel = rule.force === 'obligation' ? tr.rules.obligation : tr.rules.preference;
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -223,13 +230,13 @@ export class RulesPanel {
.toLowerCase();
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
return tr.rules.ifThenTemplate
return `[${forceLabel}] ${tr.rules.ifThenTemplate
.replace('{day}', dayLabel)
.replace('{placeDays}', String(ifThen.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{placement}', placementLabel)
.replace('{rewardDays}', String(ifThen.rewardDays))
.replace('{rewardType}', rewardTypeLabel);
.replace('{rewardType}', rewardTypeLabel)}`;
} else {
const timeWindow = rule as TimeWindowRule;
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
@@ -240,11 +247,11 @@ export class RulesPanel {
this.translationService.locale(),
);
return tr.rules.timeWindowTemplate
return `[${forceLabel}] ${tr.rules.timeWindowTemplate
.replace('{placeDays}', String(timeWindow.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{startDate}', startDateStr)
.replace('{endDate}', endDateStr);
.replace('{endDate}', endDateStr)}`;
}
}
}
+20 -1
View File
@@ -54,10 +54,29 @@ export class UserInput {
},
);
const expiresLabel = this.tr().userInput.expires;
return `${day.numberOfDays} ${typeName} (${expiresLabel} ${expiryDate})`;
const classification = day.classification
? this.getClassificationLabel(day.classification)
: '';
const reason = day.classification ? this.getReasonLabel(day.classification) : '';
const explanation = classification ? `${classification}: ${reason}; ` : '';
return `${day.numberOfDays} ${typeName} (${explanation}${expiresLabel} ${expiryDate})`;
});
});
private getClassificationLabel(classification: NonNullable<VacDay['classification']>): string {
const labels = this.tr().userInput.remainingClassification;
if (classification === 'unavoidable-expiry') return labels.unavoidableExpiry;
if (classification === 'at-risk') return labels.atRisk;
return labels.deferred;
}
private getReasonLabel(classification: NonNullable<VacDay['classification']>): string {
const labels = this.tr().userInput.remainingReason;
if (classification === 'unavoidable-expiry') return labels.unavoidableExpiry;
if (classification === 'at-risk') return labels.atRisk;
return labels.deferred;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
protected get daysOff() {
@@ -19,6 +19,17 @@
}
</mat-select>
</mat-form-field>
<mat-form-field class="dateInput">
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
<input
matInput
[matDatepicker]="availablePicker"
[value]="day().availableDate"
(dateChange)="addAvailabilityEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="availablePicker"></mat-datepicker-toggle>
<mat-datepicker #availablePicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="dateInput">
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
<input
@@ -31,7 +42,7 @@
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<button matFab aria-label="Delete entry" (click)="deleteEntry.emit()">
<button matFab [aria-label]="tr().userInput.deleteEntry" (click)="deleteEntry.emit()">
<mat-icon>close</mat-icon>
</button>
</mat-card>
@@ -28,4 +28,15 @@ describe('DayInput', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('emits an availability-date change without mutating the input value', () => {
const emitted: VacDay[] = [];
component.dayChange.subscribe((value) => emitted.push(value));
const availableDate = new Date('2025-02-03T00:00:00');
component.addAvailabilityEvent({ value: availableDate } as never);
expect(emitted[0].availableDate).toBe(availableDate);
expect(mockVacDay.availableDate).toBeUndefined();
});
});
@@ -122,6 +122,11 @@ export class DayInput {
}
addEvent(event: MatDatepickerInputEvent<Date>) {
this.dayChange.emit({ ...this.day(), expiryDate: event.value! });
if (event.value) this.dayChange.emit({ ...this.day(), expiryDate: event.value });
}
addAvailabilityEvent(event: MatDatepickerInputEvent<Date>) {
const value = event.value;
if (value) this.dayChange.emit({ ...this.day(), availableDate: value });
}
}
+15 -268
View File
@@ -1,16 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { provideZonelessChangeDetection } from '@angular/core';
import {
CalendarService,
DayType,
SelectedDate,
SelectedDates,
SelectableDayType,
VacDay,
} from './calendar-service';
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
import { DateRange } from '@angular/material/datepicker';
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
describe('CalendarService', () => {
let service: CalendarService;
@@ -26,6 +17,20 @@ describe('CalendarService', () => {
expect(service).toBeTruthy();
});
it('derives the planning horizon from the instant in Europe/Paris', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Pacific/Auckland';
try {
expect(service.getDefaultPlanningHorizon(new Date('2026-08-28T01:00:00Z'))).toEqual({
start: '2026-08-28',
end: '2027-08-28',
});
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
});
describe('getHolidays', () => {
it('should return an array of holidays', () => {
const holidays = service.getHolidays();
@@ -188,14 +193,6 @@ describe('SelectedDates', () => {
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
});
it('should compute heuristics', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const heuristic = selectedDates.computeHeuristics();
expect(typeof heuristic).toBe('number');
});
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
@@ -211,254 +208,4 @@ describe('SelectedDates', () => {
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
});
describe('optimizeVacations and rules engine', () => {
let calendarService: CalendarService;
let selectedDates: SelectedDates;
beforeEach(() => {
calendarService = TestBed.inject(CalendarService);
selectedDates = new SelectedDates();
});
it('should execute rules sequentially in order', async () => {
const vacationDays: VacDay[] = [];
const rules: (TimeWindowRule | IfThenRule)[] = [
{ id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
{ id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule,
{ id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
];
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
await selectedDates.optimizeVacations(vacationDays, rules, calendarService);
expect(applyIfThenRulesSpy.mock.calls.length).toBe(2);
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
// First rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[0][1][0].id).toBe('1');
// Second rule is TIME_WINDOW
expect(applyTimeWindowRulesSpy.mock.calls[0][1][0].id).toBe('2');
// Third rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[1][1][0].id).toBe('3');
});
it('should apply time window rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: TimeWindowRule[] = [
{
id: 't1',
name: 'Time Window',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 11, 20),
endDate: new Date(futureYear, 11, 25),
placeDays: 3,
placeType: SelectableDayType.CP,
},
];
// Mock monthsInAYearFromNow to return a controlled range of days
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
[
[
new Date(futureYear, 11, 20),
new Date(futureYear, 11, 21),
new Date(futureYear, 11, 22),
new Date(futureYear, 11, 23),
new Date(futureYear, 11, 24),
],
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
// Should have placed 3 days
const placedCount = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCount).toBe(3);
// Should have consumed 3 days from the pool
expect(vacationDays[0].numberOfDays).toBe(2);
});
it('should apply if-then rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Samedi Malin',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 2,
placeType: SelectableDayType.CP,
placement: 'auto',
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
},
];
// Setup a mock holiday on a Saturday
// Use a date far in the future so it doesn't fail the "past date" check
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday
// To guarantee it's a Saturday for the test:
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCP).toBe(2);
// Should have consumed 2 CP days
expect(vacationDays[0].numberOfDays).toBe(3);
// Should have added 1 OTHER reward day
const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
expect(rewardDay).toBeDefined();
expect(rewardDay?.numberOfDays).toBe(1);
});
it('should apply time window rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier
},
];
const rules: TimeWindowRule[] = [
{
id: 'tw1',
name: 'Vacances',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 6, 1),
endDate: new Date(futureYear, 6, 15),
placeDays: 3,
placeType: SelectableDayType.ANY,
},
];
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
[
[
new Date(futureYear, 6, 1),
new Date(futureYear, 6, 2),
new Date(futureYear, 6, 3),
new Date(futureYear, 6, 4),
new Date(futureYear, 6, 5),
],
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
// Should have consumed 2 RTT and 1 CP
expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining
expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining
});
it('should apply if-then rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear + 2, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Rule',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 3,
placeType: SelectableDayType.ANY,
placement: 'auto',
rewardDays: 0,
rewardType: SelectableDayType.CP,
},
];
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5);
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
});
});
});
+51 -569
View File
@@ -5,7 +5,14 @@ import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import { DateCacheService } from './date-cache.service';
import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
@@ -22,6 +29,29 @@ const allTypesOptions: HolidaysTypes.Options = {
export class CalendarService {
private dateCache = inject(DateCacheService);
readonly planLeave = planLeaveOperation;
getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } {
const start = planningDateInParis(now);
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: PlanOutcome): SelectedDates {
const selected = this.getSelectedDatesForYear();
for (const placement of plan.placements) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Cache for holiday timestamps
private holidayTimestampsCache: Set<number> | null = null;
private holidayCacheYear: number | null = null;
@@ -111,10 +141,17 @@ export class CalendarService {
// Logic to return holidays for the given year
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd.getHolidays(currentYear, lang);
const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang);
const holidays = hd
.getHolidays(currentYear, lang)
.filter((holiday) => holiday.type === 'public');
const holidaysPlusOne = hd
.getHolidays(currentYear + 1, lang)
.filter((holiday) => holiday.type === 'public');
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.map((holiday) => new Date(holiday.date));
const holidaysDate: Date[] = holidays.map((holiday) => {
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date);
});
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
@@ -188,7 +225,11 @@ export type VacDay = {
id: string;
type: SelectableDayType;
numberOfDays: number;
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -211,6 +252,12 @@ export enum SelectableDayType {
ANY = 'any',
}
const dayTypeForLeaveType: Record<LeaveType, DayType> = {
cp: DayType.CP,
rtt: DayType.RTT,
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
@@ -305,10 +352,6 @@ export class SelectedDates implements SelectedDateInterface {
private minDate: Date | null = null;
private maxDate: Date | null = null;
// Constant to indicate no valid strategy was found
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
@@ -470,565 +513,4 @@ export class SelectedDates implements SelectedDateInterface {
.filter((tooltip) => tooltip)
.join(' + ');
}
computeHeuristics(): number {
if (this.datesSelected.length === 0) {
return 0;
}
let totalScore = 0;
// Sort all dates by start date to find next closest dates
const sorted = [...this.datesSelected]
.filter((date) => date.type !== null)
.sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// PRIMARY: Optimize adjacency - reduce "little holes" between vacation periods
for (let i = 0; i < sorted.length - 1; i++) {
const current = sorted[i];
const next = sorted[i + 1];
// Get dates as UTC strings to remove timezone issues (handles DST properly)
const currentEndDate = new Date(current.range.end ?? new Date());
const nextStartDate = new Date(next.range.start ?? new Date());
// Convert to date-only strings (YYYY-MM-DD) to compare calendar days
const currentEndString = currentEndDate.toISOString().split('T')[0];
const nextStartString = nextStartDate.toISOString().split('T')[0];
// Parse back to dates at UTC midnight
const currentEndDateNormalized = new Date(currentEndString + 'T00:00:00Z');
const nextStartDateNormalized = new Date(nextStartString + 'T00:00:00Z');
const currentEndTime = currentEndDateNormalized.getTime();
const nextStartTime = nextStartDateNormalized.getTime();
const oneDayInMs = 24 * 60 * 60 * 1000;
// Check if adjacent (next day) - this is the best case!
if (nextStartTime - currentEndTime === oneDayInMs) {
totalScore += 15; // 50% increased bonus for adjacency; adjacency is highly valued in the scoring system
} else if (nextStartTime > currentEndTime) {
// Penalty based on days between the two dates
const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1;
// Ensure daysBetween is non-negative before taking sqrt
totalScore -= Math.sqrt(Math.max(0, daysBetween));
}
}
// SECONDARY: Penalize uneven monthly distribution to encourage spread
const monthCounts = new Map<number, number>(); // Use year*12+month as key to avoid strings
for (const selectedDate of this.datesSelected) {
if (!selectedDate.range.start || !selectedDate.range.end) continue;
// Count days in this selected date range
const daysInRange = selectedDate.getLengthInDays();
// For each day in the range, add it to the appropriate month
const currentDate = new Date(selectedDate.range.start);
for (let i = 0; i < daysInRange; i++) {
const monthKey = currentDate.getFullYear() * 12 + currentDate.getMonth();
monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + 1);
currentDate.setDate(currentDate.getDate() + 1);
}
}
// Apply small penalty for uneven distribution (secondary priority)
for (const count of monthCounts.values()) {
totalScore -= count; // Smaller penalty than gap filling
}
return totalScore;
}
async optimizeVacations(
vacationDays: VacDay[],
rules: VacationRule[],
calendarService: CalendarService,
progressCallback?: (progress: number) => void,
): Promise<VacDay[]> {
// Calculate total days to process
const totalDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
let processedDays = 0;
// Execute custom rules sequentially in the exact order they appear in the list
for (const rule of rules) {
if (!rule.enabled) continue;
if (rule.type === RuleType.TIME_WINDOW) {
this.applyTimeWindowRules(vacationDays, [rule as TimeWindowRule], calendarService);
} else if (rule.type === RuleType.IF_THEN) {
this.applyIfThenRules(vacationDays, [rule as IfThenRule]);
}
}
// Helper function to update progress, throttled to every 5%
let lastReportedProgress = 0;
const PROGRESS_STEP = 5; // percent
const updateProgress = async () => {
if (progressCallback && totalDays > 0) {
const progress = Math.round((processedDays / totalDays) * 100);
if (progress - lastReportedProgress >= PROGRESS_STEP || progress === 100) {
progressCallback(progress);
lastReportedProgress = progress;
// Allow UI to update with a microtask
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
};
await updateProgress();
// Track VacDays that can't find valid placements
const exhaustedVacDayIds = new Set<string>();
// Process vacation days ordered by expiration date (earliest first)
while (vacationDays.some((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))) {
// Sort by expiration date to use days that expire first, excluding exhausted ones
const sortedVacDays = [...vacationDays]
.filter((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
if (sortedVacDays.length === 0) {
break;
}
const vacDayToUse = sortedVacDays[0];
const result = this.lookForVacation(vacDayToUse, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
// Mark this VacDay as exhausted and try other vacation days
exhaustedVacDayIds.add(vacDayToUse.id);
continue;
}
vacDayToUse.numberOfDays -= 1;
processedDays++;
await updateProgress();
}
// Final progress update (100%)
if (progressCallback) {
progressCallback(100);
}
// Inform user if not all days were placed
const remainingDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (remainingDays > 0) {
console.warn(
`Optimization stopped: ${remainingDays} of ${totalDays} vacation days remain unplaced. ` +
`Too many days have equivalent placement quality - please place remaining days manually.`,
);
}
return vacationDays;
}
applyTimeWindowRules(
vacationDays: VacDay[],
rules: TimeWindowRule[],
calendarService: CalendarService,
): void {
for (const rule of rules) {
let daysToPlace = rule.placeDays;
const consumeDays = (count: number): boolean => {
let needed = count;
const availableDays = vacationDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
for (const vd of availableDays) {
if (needed === 0) break;
const take = Math.min(vd.numberOfDays, needed);
vd.numberOfDays -= take;
needed -= take;
}
return needed === 0;
};
const totalAvailable = vacationDays
.filter(
(vd) => rule.placeType === SelectableDayType.ANY || vd.type === rule.placeType,
)
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (totalAvailable < daysToPlace) {
console.warn(
`Insufficient ${rule.placeType} days for Time Window Rule "${rule.name}". Needs ${daysToPlace}, has ${totalAvailable}. Placing ${totalAvailable} days instead.`,
);
daysToPlace = totalAvailable;
}
if (daysToPlace <= 0) continue;
const getNextAvailableType = (): DayType => {
const sorted = vacationDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
return sorted.length > 0
? (sorted[0].type as unknown as DayType)
: ((rule.placeType === SelectableDayType.ANY
? SelectableDayType.CP
: rule.placeType) as unknown as DayType);
};
const candidates: Date[] = [];
const daysOfYear = calendarService.monthsInAYearFromNow();
const start = new Date(rule.startDate).getTime();
const end = new Date(rule.endDate).getTime();
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
const dayTime = day.getTime();
if (dayTime >= start && dayTime <= end && !this.isSelected(day)) {
candidates.push(day);
}
}
}
}
}
for (let i = 0; i < daysToPlace; i++) {
if (candidates.length === 0) break;
const currentPlaceType = getNextAvailableType();
let bestCandidate: Date | null = null;
let bestHeuristic = -Infinity;
let bestCandidateIndex = -1;
for (let j = 0; j < candidates.length; j++) {
const day = candidates[j];
if (this.isSelected(day)) continue;
const tempDatesBackup = [...this._datesSelected];
this._datesSelected.push(
new SelectedDate(currentPlaceType, new DateRange<Date>(day, day)),
);
this.datesSelected = this._datesSelected.slice();
this.grouping();
const heuristic = this.computeHeuristics();
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
bestCandidate = day;
bestCandidateIndex = j;
}
}
if (bestCandidate) {
this.push(
new SelectedDate(
currentPlaceType,
new DateRange<Date>(bestCandidate, bestCandidate),
),
);
consumeDays(1);
candidates.splice(bestCandidateIndex, 1);
}
}
}
}
applyIfThenRules(vacationDays: VacDay[], rules: IfThenRule[]): void {
const nowTime = new Date().setHours(0, 0, 0, 0);
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
let daysToConsume = count;
const useByTime = useByDate.getTime();
const availableDays = vacationDays
.filter(
(vd) =>
(type === SelectableDayType.ANY || vd.type === type) &&
vd.numberOfDays > 0 &&
vd.expiryDate.getTime() >= useByTime,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
for (const vd of availableDays) {
if (daysToConsume === 0) break;
const take = Math.min(vd.numberOfDays, daysToConsume);
vd.numberOfDays -= take;
daysToConsume -= take;
}
return daysToConsume === 0;
};
const checkAvailableDays = (
count: number,
type: SelectableDayType,
useByDate: Date,
): boolean => {
const useByTime = useByDate.getTime();
const total = vacationDays
.filter(
(vd) =>
(type === SelectableDayType.ANY || vd.type === type) &&
vd.expiryDate.getTime() >= useByTime,
)
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
return total >= count;
};
const addRewardDays = (count: number, type: SelectableDayType): void => {
if (count <= 0) return;
const existing = vacationDays.find((vd) => vd.type === type);
if (existing) {
existing.numberOfDays += count;
} else {
vacationDays.push({
id: crypto.randomUUID(),
type: type,
numberOfDays: count,
expiryDate: new Date(new Date().getFullYear() + 10, 11, 31),
});
}
};
const getWorkingDays = (startDate: Date, direction: 1 | -1, count: number): Date[] => {
const result: Date[] = [];
const current = new Date(startDate);
while (result.length < count) {
current.setDate(current.getDate() + direction);
const isWeekend = current.getDay() === 0 || current.getDay() === 6;
const isHoliday = this._datesSelected.some(
(d) =>
d.type === DayType.CLOSED_DAY &&
d.range.start?.getTime() === current.getTime(),
);
if (!isWeekend && !isHoliday) {
result.push(new Date(current));
}
}
return result;
};
for (const rule of rules) {
const matchingHolidays = this._datesSelected.filter(
(date) =>
date.type === DayType.CLOSED_DAY &&
date.range.start?.getDay() === rule.dayOfWeek,
);
for (const holiday of matchingHolidays) {
if (!holiday.range.start) continue;
const holidayDate = new Date(holiday.range.start);
const N = rule.placeDays;
if (N <= 0) continue;
const placementOptions: Date[][] = [];
if (rule.placement === 'before' || rule.placement === 'auto') {
placementOptions.push(getWorkingDays(holidayDate, -1, N));
}
if (rule.placement === 'after' || rule.placement === 'auto') {
placementOptions.push(getWorkingDays(holidayDate, 1, N));
}
if (rule.placement === 'around' || rule.placement === 'auto') {
const beforeCount = Math.ceil(N / 2);
const afterCount = Math.floor(N / 2);
const aroundDates = [
...getWorkingDays(holidayDate, -1, beforeCount),
...getWorkingDays(holidayDate, 1, afterCount),
];
placementOptions.push(aroundDates);
}
const strategies: { apply: () => void; heuristic: number }[] = [];
const tempDatesBackup = [...this._datesSelected];
for (const dates of placementOptions) {
const allValid = dates.every(
(d) => d.getTime() >= nowTime && !this.isSelected(d),
);
if (!allValid) continue;
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
if (!checkAvailableDays(N, rule.placeType, maxDate)) continue;
const simulatedDays = vacationDays.map((vd) => ({ ...vd }));
const datesWithTypes: { date: Date; type: DayType }[] = [];
for (const d of dates) {
const sorted = simulatedDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0 &&
vd.expiryDate.getTime() >= maxDate.getTime(),
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
let chosenType = (rule.placeType === SelectableDayType.ANY
? SelectableDayType.CP
: rule.placeType) as unknown as DayType;
if (sorted.length > 0) {
sorted[0].numberOfDays--;
chosenType = sorted[0].type as unknown as DayType;
}
datesWithTypes.push({ date: d, type: chosenType });
}
for (const dt of datesWithTypes) {
this.push(new SelectedDate(dt.type, new DateRange<Date>(dt.date, dt.date)));
}
const heuristic = this.computeHeuristics();
strategies.push({
apply: () => {
this._datesSelected = [...tempDatesBackup];
for (const dt of datesWithTypes) {
this.push(
new SelectedDate(
dt.type,
new DateRange<Date>(dt.date, dt.date),
),
);
}
consumeDays(N, rule.placeType, maxDate);
addRewardDays(rule.rewardDays, rule.rewardType);
},
heuristic: heuristic,
});
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
}
if (strategies.length > 0) {
const bestStrategy = strategies.reduce((best, current) =>
current.heuristic > best.heuristic ? current : best,
);
bestStrategy.apply();
} else {
console.warn(
`Could not apply If-Then Rule "${rule.name}" for holiday on ${holidayDate.toLocaleDateString()}. Candidates invalid or insufficient days.`,
);
}
}
}
}
lookForVacation(
vacDay: VacDay,
calendarService: CalendarService,
): { apply: () => void; heuristic: number } {
const daysOfYear = calendarService.monthsInAYearFromNow();
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
const expiryTime = vacDay.expiryDate.getTime();
// Preprocess: filter out past days, already selected days, and days after expiry date
const candidateDays: Date[] = [];
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
day.setHours(0, 0, 0, 0);
const dayTime = day.getTime();
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
candidateDays.push(day);
}
}
}
}
}
// Early exit if no candidates
if (candidateDays.length === 0) {
return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC };
}
const vacType = vacDay.type as unknown as DayType;
const tempDatesBackup = [...this._datesSelected];
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
let bestHeuristic = -Infinity;
let daysWithBestHeuristic = 0;
// Evaluate each candidate day
for (const day of candidateDays) {
// Reset to backup state
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Mark this day as vacation (reuse the day object since it's already normalized)
this.datesSelected.push(new SelectedDate(vacType, new DateRange<Date>(day, day)));
const heuristic = this.computeHeuristics();
// Track only if better than current best
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
daysWithBestHeuristic = 1;
const capturedDay = day;
bestStrategy = {
apply: () => {
this._datesSelected = [...tempDatesBackup];
this.push(
new SelectedDate(
vacType,
new DateRange<Date>(capturedDay, capturedDay),
),
);
},
heuristic: heuristic,
value: capturedDay,
};
} else if (heuristic === bestHeuristic) {
daysWithBestHeuristic++;
}
}
// Check if too many days have the same best heuristic after evaluating all days
if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) {
// Restore backup before stopping
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Stop optimization - let user decide manually
return {
apply: () => {},
heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC,
};
}
// Restore backup before returning
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Apply best strategy if found
if (bestStrategy) {
bestStrategy.apply();
return bestStrategy;
}
return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC };
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
import type {
InvalidInputOutcome,
PlanOutcome,
PlanningIncompleteOutcome,
PlanningOutcome,
PlanningRequest,
} from './leave-planner.core';
export type {
IfThenPlanningRule,
InvalidInputOutcome,
LeaveBalanceInput,
LeavePlacement,
LeaveSelector,
LeaveType,
PlanOutcome,
PlacementMode,
PlanningConflictOutcome,
PlanningDate,
PlanningHorizon,
PlanningIncompleteOutcome,
PlanningMetrics,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningRule,
PreferenceResult,
RemainingBalance,
RemainingClassification,
RuleForce,
TimeOffPeriod,
TimeWindowPlanningRule,
} from './leave-planner.core';
const MAX_PLANNING_MS = 10_000;
interface UntrustedRecord {
horizon?: unknown;
start?: unknown;
end?: unknown;
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
id?: unknown;
name?: unknown;
type?: unknown;
quantity?: unknown;
availableFrom?: unknown;
expiryDate?: unknown;
enabled?: unknown;
force?: unknown;
startDate?: unknown;
endDate?: unknown;
placeDays?: unknown;
placeType?: unknown;
dayOfWeek?: unknown;
placement?: unknown;
rewardDays?: unknown;
rewardType?: unknown;
}
interface PlanningSnapshot {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
}
function asRecord(value: unknown): UntrustedRecord | undefined {
return typeof value === 'object' && value !== null ? (value as UntrustedRecord) : undefined;
}
function copyDate(value: unknown): unknown {
return value instanceof Date ? new Date(value.getTime()) : value;
}
function snapshotRequest(request: unknown): PlanningSnapshot {
const source = asRecord(request) ?? {};
const horizon = asRecord(source.horizon);
const balances = Array.isArray(source.balances)
? source.balances.map((balance) => {
const value = asRecord(balance);
return {
id: value?.id,
type: value?.type,
quantity: value?.quantity,
availableFrom:
value?.availableFrom === undefined
? undefined
: copyDate(value.availableFrom),
expiryDate: copyDate(value?.expiryDate),
};
})
: source.balances;
const rules = Array.isArray(source.rules)
? source.rules.map((rule) => {
const value = asRecord(rule);
const base = {
id: value?.id,
name: value?.name,
type: value?.type,
enabled: value?.enabled,
force: value?.force,
};
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
return {
...base,
startDate: copyDate(value.startDate),
endDate: copyDate(value.endDate),
placeDays: value.placeDays,
placeType: value.placeType,
};
}
return {
...base,
dayOfWeek: value?.dayOfWeek,
placeDays: value?.placeDays,
placeType: value?.placeType,
placement: value?.placement,
rewardDays: value?.rewardDays,
rewardType: value?.rewardType,
};
})
: source.rules;
return {
horizon: horizon
? {
start: copyDate(horizon.start),
end: copyDate(horizon.end),
}
: undefined,
balances,
rules,
deadlineMs: source.deadlineMs,
progress: typeof source.progress === 'function' ? source.progress : undefined,
};
}
function deadlineFor(request: PlanningSnapshot): number {
const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined;
return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS);
}
function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undefined {
const deadline = request.deadlineMs;
if (
deadline !== undefined &&
(typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0)
)
return {
kind: 'InvalidInput',
reasons: [
{
code: 'deadline',
message: 'The planning deadline must be a finite non-negative number.',
},
],
};
return undefined;
}
type PlannerWorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'error'; message: string };
function interruptedOutcome(
checkpoint: PlanOutcome | undefined,
deadline: number,
reasonCode = 'deadline',
message = `Planning stopped after ${deadline}ms before all quality stages completed.`,
): PlanningIncompleteOutcome {
return {
kind: 'PlanningIncomplete',
incumbent: checkpoint,
completedStages: checkpoint?.completedStages ?? [],
lastCompletedStage: checkpoint?.lastCompletedStage,
reasons: [{ code: reasonCode, message }],
};
}
function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<PlanningOutcome> {
return new Promise((resolve) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch {
resolve(
interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
),
);
return;
}
let settled = false;
let checkpoint: PlanOutcome | undefined;
const finish = (outcome: PlanningOutcome) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
resolve(outcome);
};
const timeout = setTimeout(
() => finish(interruptedOutcome(checkpoint, deadline)),
Math.max(0, deadline - 1),
);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
if (message.type === 'progress') {
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
return;
}
if (message.type === 'checkpoint') {
checkpoint = message.plan;
return;
}
if (message.type === 'result') {
finish(message.outcome);
return;
}
finish(interruptedOutcome(checkpoint, deadline, 'worker-error', message.message));
};
worker.onerror = () =>
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning worker failed before returning an outcome.',
),
);
try {
worker.postMessage({
type: 'plan',
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
});
} catch {
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning input could not be sent to the isolated worker.',
),
);
}
});
}
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
const snapshot = snapshotRequest(request);
const deadlineError = invalidDeadline(snapshot);
if (deadlineError) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return deadlineError;
}
const deadline = deadlineFor(snapshot);
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') return planInWorker(snapshot, deadline);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const core = await import('./leave-planner.core');
const validation = core.invalidInputFor(snapshot);
if (validation) return validation;
return interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const core = await import('./leave-planner.core');
return core.calculatePlanningInternal(snapshot);
}
+45
View File
@@ -0,0 +1,45 @@
/// <reference lib="webworker" />
import type { PlanningOutcome, PlanOutcome } from './leave-planner.service';
import { calculatePlanningInternal } from './leave-planner.core';
type WorkerPlanningRequest = {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest = {
type: 'plan';
request: WorkerPlanningRequest;
};
type WorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'error'; message: string };
const worker = self as DedicatedWorkerGlobalScope;
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
if (event.data?.type !== 'plan') return;
try {
const outcome = calculatePlanningInternal(
{
...event.data.request,
progress: (stage: string) =>
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
},
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
);
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
} catch (error) {
worker.postMessage({
type: 'error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
};
+18
View File
@@ -0,0 +1,18 @@
export function planningDateInParis(value: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function addPlanningYears(key: string, years: number): string {
const date = new Date(`${key}T00:00:00Z`);
const month = date.getUTCMonth();
date.setUTCFullYear(date.getUTCFullYear() + years);
if (date.getUTCMonth() !== month) date.setUTCDate(0);
return date.toISOString().slice(0, 10);
}
+37
View File
@@ -31,6 +31,43 @@ describe('RuleService', () => {
expect(christmasRule.endDate instanceof Date).toBe(true);
});
it('migrates legacy force values without changing rule order or dates', () => {
TestBed.resetTestingModule();
localStorage.setItem(
'vacationRules',
JSON.stringify([
{
id: 'legacy-first',
name: 'First',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: '2024-02-01',
endDate: '2024-02-05',
placeDays: 1,
placeType: SelectableDayType.CP,
},
{
id: 'legacy-second',
name: 'Second',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: '2024-03-01',
endDate: '2024-03-02',
placeDays: 1,
placeType: SelectableDayType.RTT,
},
]),
);
TestBed.configureTestingModule({});
service = TestBed.inject(RuleService);
expect(service.rules.map((rule) => rule.id)).toEqual(['legacy-first', 'legacy-second']);
expect(service.rules[0].force).toBe('preference');
const first = service.rules[0] as TimeWindowRule;
expect(first.startDate.toISOString().slice(0, 10)).toBe('2024-02-01');
});
it('should add a new rule', () => {
const newRule: IfThenRule = {
id: 'test-rule',
+19 -3
View File
@@ -7,11 +7,15 @@ export enum RuleType {
TIME_WINDOW = 'time_window',
}
export type RuleForce = 'obligation' | 'preference';
export interface BaseRule {
id: string;
name: string;
type: RuleType;
enabled: boolean;
/** Missing force is a legacy preference and is migrated on read. */
force?: RuleForce;
}
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
@@ -41,6 +45,7 @@ interface SerializedRule {
name: string;
type: RuleType;
enabled: boolean;
force?: RuleForce;
dayOfWeek?: number;
placeDays: number;
placeType: SelectableDayType;
@@ -63,6 +68,7 @@ export class RuleService {
name: 'Samedi Malin',
type: RuleType.IF_THEN,
enabled: true,
force: 'preference',
dayOfWeek: 6, // Saturday
placeDays: 2,
placeType: SelectableDayType.CP,
@@ -75,6 +81,7 @@ export class RuleService {
name: 'Vacances de Noël',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'preference',
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
placeDays: 5,
@@ -98,22 +105,31 @@ export class RuleService {
if (rule.type === RuleType.TIME_WINDOW) {
return {
...rule,
force: rule.force ?? 'preference',
startDate: new Date(rule.startDate as string),
endDate: new Date(rule.endDate as string),
} as TimeWindowRule;
}
return rule as IfThenRule;
return { ...rule, force: rule.force ?? 'preference' } as IfThenRule;
});
}
public addRule(rule: VacationRule): void {
this.rawRulesSignal.update((rules) => [...rules, rule as unknown as SerializedRule]);
this.rawRulesSignal.update((rules) => [
...rules,
{ ...rule, force: rule.force ?? 'preference' } as unknown as SerializedRule,
]);
}
public updateRule(updatedRule: VacationRule): void {
this.rawRulesSignal.update((rules) =>
rules.map((rule) =>
rule.id === updatedRule.id ? (updatedRule as unknown as SerializedRule) : rule,
rule.id === updatedRule.id
? ({
...updatedRule,
force: updatedRule.force ?? 'preference',
} as unknown as SerializedRule)
: rule,
),
);
}
+30
View File
@@ -13,10 +13,22 @@ export interface AppTranslations {
calculate: string;
remainingDaysWarning: string;
remainingDaysManualPlace: string;
deleteEntry: string;
chooseDate: string;
availableDate: string;
daySuffix: string;
daysSuffix: string;
expires: string;
remainingClassification: {
unavoidableExpiry: string;
atRisk: string;
deferred: string;
};
remainingReason: {
unavoidableExpiry: string;
atRisk: string;
deferred: string;
};
};
dayTypes: {
weekend: string;
@@ -41,6 +53,7 @@ export interface AppTranslations {
ifThenTemplate: string;
timeWindowTemplate: string;
editRule: string;
deleteRule: string;
addRule: string;
place: string;
ruleName: string;
@@ -49,6 +62,9 @@ export interface AppTranslations {
type: string;
position: string;
getBonus: string;
force: string;
obligation: string;
preference: string;
startDate: string;
endDate: string;
cancel: string;
@@ -75,6 +91,20 @@ export interface AppTranslations {
auto: string;
};
};
planning: {
invalidInput: string;
conflict: string;
incomplete: string;
error: string;
complete: string;
};
benchmark: {
title: string;
instructions: string;
run: string;
running: string;
copy: string;
};
loading: string;
weekdaysShort: string[];
}
+5
View File
@@ -10,11 +10,13 @@ export class UserInputService {
}
private getInitialValues(): VacDay[] {
const availableDate = new Date();
return [
{
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: this.getRandomNumber(),
availableDate,
// today + 365 days
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
@@ -22,12 +24,14 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.RTT,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.OTHER,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
];
@@ -38,6 +42,7 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
};
}