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
+4835 -873

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Mobile leave-planner benchmark
Open the deployed application at `/benchmark` on the reference phone. Tap
**Run benchmark**, wait for the five scenarios to finish, then tap **Copy
report** and save the tab-separated output with the date and build identifier.
The harness measures preparation, asynchronous calculation, and total wall
time separately. It records the outcome, last completed quality stage,
placement count, and determinism between repetitions. `PlanningIncomplete` is
not a conflict: its last completed stage and incumbent are retained.
## Protocol
- Record phone model, OS version, browser/version, application build, and
battery level.
- Use a cool phone, unplugged, with other applications closed. Record whether
the device became warm or throttled during the run.
- Run the benchmark three times after one warm-up run. Do not interact with
the page while a run is active.
- Representative and loaded scenarios should normally complete below five
seconds; every scenario must return within ten seconds. These are release
measurements, not CI timing assertions.
- Keep the copied report unchanged, including any incomplete outcomes or
hardware limitations. No mobile-device measurement is claimed by the source
tree until a human records it here.
The browser page uses the same `planLeave(request)` operation as the calendar
and adds no solver or backend dependency.
+5 -2
View File
@@ -1,5 +1,6 @@
<app-dark-light-mode-button class="modeButton"></app-dark-light-mode-button>
<main class="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>
@@ -7,4 +8,6 @@
<mat-divider />
<app-calendar-year></app-calendar-year>
</div>
</main>
</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);
}
}
})();
});
});
}
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)),
};
}
+31 -1
View File
@@ -9,10 +9,22 @@
"calculate": "Berechnen",
"remainingDaysWarning": "{count} nicht platziert:",
"remainingDaysManualPlace": "Bitte manuell platzieren.",
"deleteEntry": "Eintrag löschen",
"chooseDate": "Datum auswählen",
"availableDate": "Verfügbar ab",
"daySuffix": "Tag",
"daysSuffix": "Tage",
"expires": "abl."
"expires": "abl.",
"remainingClassification": {
"unavoidableExpiry": "Unvermeidbarer Ablauf",
"atRisk": "Gefährdet",
"deferred": "Aufgeschoben"
},
"remainingReason": {
"unavoidableExpiry": "vor dem Planungshorizont abgelaufen",
"atRisk": "läuft im Planungshorizont ab",
"deferred": "nach dem Planungshorizont verfügbar"
}
},
"dayTypes": {
"weekend": "Wochenende",
@@ -37,6 +49,7 @@
"ifThenTemplate": "Wenn {type} dann {placement}",
"timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}",
"editRule": "Regel bearbeiten",
"deleteRule": "Regel löschen",
"addRule": "Regel hinzufügen",
"place": "platzieren",
"ruleName": "Regelname",
@@ -45,6 +58,9 @@
"type": "Typ",
"position": "Position",
"getBonus": "Bonus erhalten",
"force": "Regelverbindlichkeit",
"obligation": "Verpflichtung",
"preference": "Präferenz",
"startDate": "Startdatum",
"endDate": "Enddatum",
"cancel": "Abbrechen",
@@ -71,6 +87,20 @@
"auto": "automatisch"
}
},
"planning": {
"invalidInput": "Die Planungsdaten sind ungültig.",
"conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.",
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
"complete": "Plan berechnet."
},
"benchmark": {
"title": "Benchmark der Urlaubsplanung",
"instructions": "Führen Sie die fünf festen Szenarien aus und kopieren Sie den Bericht.",
"run": "Benchmark starten",
"running": "Läuft…",
"copy": "Bericht kopieren"
},
"loading": "Wird geladen...",
"weekdaysShort": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]
}
+31 -1
View File
@@ -9,10 +9,22 @@
"calculate": "Calculate",
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
"remainingDaysManualPlace": "Please place them manually.",
"deleteEntry": "Delete entry",
"chooseDate": "Choose a date",
"availableDate": "Available from",
"daySuffix": "day",
"daysSuffix": "days",
"expires": "exp."
"expires": "exp.",
"remainingClassification": {
"unavoidableExpiry": "Unavoidable expiry",
"atRisk": "At risk",
"deferred": "Deferred"
},
"remainingReason": {
"unavoidableExpiry": "expired before the horizon",
"atRisk": "expires within the horizon",
"deferred": "available after the horizon"
}
},
"dayTypes": {
"weekend": "Weekend",
@@ -37,6 +49,7 @@
"ifThenTemplate": "If a holiday falls on a <strong>{day}</strong>, place <strong>{placeDays}</strong> day(s) <strong>{placeType}</strong> <strong>{placement}</strong> and get <strong>{rewardDays}</strong> day(s) <strong>{rewardType}</strong> as a bonus.",
"timeWindowTemplate": "Place <strong>{placeDays}</strong> day(s) <strong>{placeType}</strong> between <strong>{startDate}</strong> and <strong>{endDate}</strong>.",
"editRule": "Edit rule",
"deleteRule": "Delete rule",
"addRule": "Add a rule",
"place": "Place",
"ruleName": "Rule name",
@@ -45,6 +58,9 @@
"type": "Type",
"position": "Position",
"getBonus": "Get as bonus",
"force": "Rule force",
"obligation": "Obligation",
"preference": "Preference",
"startDate": "Start date",
"endDate": "End date",
"cancel": "Cancel",
@@ -71,6 +87,20 @@
"auto": "Auto (Best choice)"
}
},
"planning": {
"invalidInput": "The planning input is invalid.",
"conflict": "The obligations cannot be satisfied together.",
"incomplete": "Calculation stopped before all quality stages completed.",
"error": "The planner encountered an unexpected error.",
"complete": "Plan calculated."
},
"benchmark": {
"title": "Leave planner benchmark",
"instructions": "Run the five fixed scenarios and copy the resulting report.",
"run": "Run benchmark",
"running": "Running…",
"copy": "Copy report"
},
"loading": "Calculating...",
"weekdaysShort": ["M", "T", "W", "T", "F", "S", "S"]
}
+31 -1
View File
@@ -9,10 +9,22 @@
"calculate": "Calcular",
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
"deleteEntry": "Eliminar entrada",
"chooseDate": "Elija una fecha",
"availableDate": "Disponible desde",
"daySuffix": "día",
"daysSuffix": "días",
"expires": "exp."
"expires": "exp.",
"remainingClassification": {
"unavoidableExpiry": "Vencimiento inevitable",
"atRisk": "En riesgo",
"deferred": "Aplazado"
},
"remainingReason": {
"unavoidableExpiry": "caducó antes del horizonte",
"atRisk": "caduca dentro del horizonte",
"deferred": "disponible después del horizonte"
}
},
"dayTypes": {
"weekend": "Fin de semana",
@@ -37,6 +49,7 @@
"ifThenTemplate": "Si un día festivo cae en <strong>{day}</strong>, colocar <strong>{placeDays}</strong> día(s) <strong>{placeType}</strong> <strong>{placement}</strong> y obtener <strong>{rewardDays}</strong> día(s) <strong>{rewardType}</strong> de bonificación.",
"timeWindowTemplate": "Colocar <strong>{placeDays}</strong> día(s) <strong>{placeType}</strong> entre el <strong>{startDate}</strong> y el <strong>{endDate}</strong>.",
"editRule": "Editar regla",
"deleteRule": "Eliminar regla",
"addRule": "Añadir una regla",
"place": "Colocar",
"ruleName": "Nombre de la regla",
@@ -45,6 +58,9 @@
"type": "Tipo",
"position": "Posición",
"getBonus": "Obtener en bonificación",
"force": "Fuerza de la regla",
"obligation": "Obligación",
"preference": "Preferencia",
"startDate": "Fecha de inicio",
"endDate": "Fecha de fin",
"cancel": "Cancelar",
@@ -71,6 +87,20 @@
"auto": "Auto (Mejor opción)"
}
},
"planning": {
"invalidInput": "Los datos de planificación no son válidos.",
"conflict": "Las obligaciones no se pueden satisfacer juntas.",
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
"error": "El planificador encontró un error inesperado.",
"complete": "Plan calculado."
},
"benchmark": {
"title": "Prueba del planificador de vacaciones",
"instructions": "Ejecuta los cinco escenarios fijos y copia el informe resultante.",
"run": "Ejecutar prueba",
"running": "En curso…",
"copy": "Copiar informe"
},
"loading": "Calculando...",
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
}
+31 -1
View File
@@ -9,10 +9,22 @@
"calculate": "Calculer",
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
"deleteEntry": "Supprimer l’entrée",
"chooseDate": "Choisissez une date",
"availableDate": "Disponible à partir du",
"daySuffix": "jour",
"daysSuffix": "jours",
"expires": "exp."
"expires": "exp.",
"remainingClassification": {
"unavoidableExpiry": "Expiration inévitable",
"atRisk": "À risque",
"deferred": "Différé"
},
"remainingReason": {
"unavoidableExpiry": "expiré avant l’horizon",
"atRisk": "expire pendant l’horizon",
"deferred": "disponible après l’horizon"
}
},
"dayTypes": {
"weekend": "Weekend",
@@ -37,6 +49,7 @@
"ifThenTemplate": "Si un jour férié tombe un <strong>{day}</strong>, placer <strong>{placeDays}</strong> jour(s) <strong>{placeType}</strong> <strong>{placement}</strong> et obtenir <strong>{rewardDays}</strong> jour(s) <strong>{rewardType}</strong> en bonus.",
"timeWindowTemplate": "Placer <strong>{placeDays}</strong> jour(s) <strong>{placeType}</strong> entre le <strong>{startDate}</strong> et le <strong>{endDate}</strong>.",
"editRule": "Modifier la règle",
"deleteRule": "Supprimer la règle",
"addRule": "Ajouter une règle",
"place": "Placer",
"ruleName": "Nom de la règle",
@@ -45,6 +58,9 @@
"type": "Type",
"position": "Position",
"getBonus": "Obtenir en bonus",
"force": "Force de la règle",
"obligation": "Obligation",
"preference": "Préférence",
"startDate": "Date de début",
"endDate": "Date de fin",
"cancel": "Annuler",
@@ -71,6 +87,20 @@
"auto": "Auto (Meilleur choix)"
}
},
"planning": {
"invalidInput": "Les données de planification sont invalides.",
"conflict": "Les obligations ne peuvent pas être satisfaites ensemble.",
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
"error": "Le planificateur a rencontré une erreur inattendue.",
"complete": "Plan calculé."
},
"benchmark": {
"title": "Benchmark du planificateur de congés",
"instructions": "Lancez les cinq scénarios fixes et copiez le rapport obtenu.",
"run": "Lancer le benchmark",
"running": "En cours…",
"copy": "Copier le rapport"
},
"loading": "Calcul en cours...",
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
}