feat(planning): implement constraint-first leave planner
This commit is contained in:
1 parent
ba8dbe92d9
commit
7ceaf1a57a
32 files changed
+4835
-873
No files matched your search
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
<app-dark-light-mode-button class="modeButton"></app-dark-light-mode-button>
|
<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>
|
<h1 style="display: flex; gap: 1rem"><mat-icon>beach_access</mat-icon> {{ title() }}</h1>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<app-user-input></app-user-input>
|
<app-user-input></app-user-input>
|
||||||
@@ -7,4 +8,6 @@
|
|||||||
<mat-divider />
|
<mat-divider />
|
||||||
<app-calendar-year></app-calendar-year>
|
<app-calendar-year></app-calendar-year>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
}
|
||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'benchmark',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./benchmark/benchmark-page').then((module) => module.BenchmarkPage),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -22,4 +22,16 @@ describe('App', () => {
|
|||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('vac-optimizer');
|
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
@@ -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 { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
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 { CalendarYear } from './components/calendar-year/calendar-year';
|
||||||
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
|
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
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({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -15,8 +20,8 @@ import { MatIcon } from '@angular/material/icon';
|
|||||||
UserInput,
|
UserInput,
|
||||||
CalendarYear,
|
CalendarYear,
|
||||||
DarkLightModeButton,
|
DarkLightModeButton,
|
||||||
DarkLightModeButton,
|
|
||||||
MatIcon,
|
MatIcon,
|
||||||
|
RouterOutlet,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
@@ -24,5 +29,16 @@ import { MatIcon } from '@angular/material/icon';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
|
private readonly router = inject(Router);
|
||||||
protected readonly title = signal('vac-optimizer');
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
</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) {
|
@for (month of months; track $index) {
|
||||||
<mat-card class="card">
|
<mat-card class="card">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|||||||
import { DateCacheService } from '../../services/date-cache.service';
|
import { DateCacheService } from '../../services/date-cache.service';
|
||||||
import { RuleService } from '../../services/rule-service';
|
import { RuleService } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
|
||||||
|
import { SelectableDayType, VacDay } from '../../services/calendar-service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-calendar-year',
|
selector: 'app-calendar-year',
|
||||||
@@ -28,6 +30,30 @@ export class CalendarYear {
|
|||||||
protected isLoading = signal(true);
|
protected isLoading = signal(true);
|
||||||
|
|
||||||
protected loadingProgress = signal(0);
|
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
|
// Use DateCacheService instead of creating Date objects
|
||||||
private get currentMonth() {
|
private get currentMonth() {
|
||||||
@@ -43,37 +69,90 @@ export class CalendarYear {
|
|||||||
this.userInput.computeTriggerSignal();
|
this.userInput.computeTriggerSignal();
|
||||||
|
|
||||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||||
|
this.planningOutcome.set(null);
|
||||||
|
this.userInput.remainingVacationDaysSignal.set([]);
|
||||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||||
const currentRules = untracked(() => this.ruleService.rules);
|
const currentRules = untracked(() => this.ruleService.rules);
|
||||||
|
|
||||||
console.log('Optimizing vacations with data:', vacationData);
|
|
||||||
console.log('Using rules:', currentRules);
|
|
||||||
|
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.loadingProgress.set(0);
|
this.loadingProgress.set(0);
|
||||||
|
const version = ++this.calculationVersion;
|
||||||
// Defer execution to allow UI to update
|
// Defer execution to allow UI to update
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (this.selectedDates) {
|
try {
|
||||||
// Create a deep copy to avoid mutating the original signal data
|
const outcome = await this.calendarService.planLeave({
|
||||||
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
|
horizon: this.calendarService.getDefaultPlanningHorizon(),
|
||||||
const remainingDays = await this.selectedDates.optimizeVacations(
|
balances: vacationData.map((balance) => ({
|
||||||
vacationDaysCopy,
|
id: balance.id,
|
||||||
currentRules,
|
type: balance.type,
|
||||||
this.calendarService,
|
quantity: balance.numberOfDays,
|
||||||
(progress: number) => {
|
availableFrom: balance.availableDate,
|
||||||
this.loadingProgress.set(progress);
|
expiryDate: balance.expiryDate,
|
||||||
|
})),
|
||||||
|
rules: currentRules,
|
||||||
|
progress: (stage) => {
|
||||||
|
if (version === this.calculationVersion)
|
||||||
|
this.loadingProgress.set(this.stageProgress(stage));
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
// Update the remaining vacation days signal
|
if (version !== this.calculationVersion) return;
|
||||||
this.userInput.remainingVacationDaysSignal.set(remainingDays);
|
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();
|
protected selectedDates: SelectedDates = new SelectedDates();
|
||||||
|
|
||||||
getYear(monthIndex: number): number {
|
getYear(monthIndex: number): number {
|
||||||
|
|||||||
@@ -37,12 +37,17 @@
|
|||||||
>
|
>
|
||||||
</mat-slide-toggle>
|
</mat-slide-toggle>
|
||||||
<div class="rule-actions">
|
<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>
|
<mat-icon>edit</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
color="warn"
|
color="warn"
|
||||||
|
[aria-label]="tr().rules.deleteRule"
|
||||||
(click)="deleteRule(rule.id, $event)"
|
(click)="deleteRule(rule.id, $event)"
|
||||||
>
|
>
|
||||||
<mat-icon>delete</mat-icon>
|
<mat-icon>delete</mat-icon>
|
||||||
@@ -59,6 +64,16 @@
|
|||||||
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
||||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||||
</mat-form-field>
|
</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) {
|
@if ($any(editBuffer).type === RuleType.IF_THEN) {
|
||||||
<div class="editor-row">
|
<div class="editor-row">
|
||||||
@@ -210,6 +225,14 @@
|
|||||||
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
||||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||||
</mat-form-field>
|
</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) {
|
@if (newRuleType() === RuleType.IF_THEN) {
|
||||||
<div class="editor-row">
|
<div class="editor-row">
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export class RulesPanel {
|
|||||||
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
|
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
|
||||||
(t) => t !== SelectableDayType.ANY,
|
(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() {
|
protected get daysOfWeek() {
|
||||||
const tr = this.tr();
|
const tr = this.tr();
|
||||||
@@ -186,6 +190,7 @@ export class RulesPanel {
|
|||||||
this.editBuffer = {
|
this.editBuffer = {
|
||||||
name: this.tr().rules.defaultNewRuleName,
|
name: this.tr().rules.defaultNewRuleName,
|
||||||
type: RuleType.IF_THEN,
|
type: RuleType.IF_THEN,
|
||||||
|
force: 'preference',
|
||||||
dayOfWeek: 6,
|
dayOfWeek: 6,
|
||||||
placeDays: 2,
|
placeDays: 2,
|
||||||
placeType: SelectableDayType.CP,
|
placeType: SelectableDayType.CP,
|
||||||
@@ -197,6 +202,7 @@ export class RulesPanel {
|
|||||||
this.editBuffer = {
|
this.editBuffer = {
|
||||||
name: this.tr().rules.defaultNewPeriodName,
|
name: this.tr().rules.defaultNewPeriodName,
|
||||||
type: RuleType.TIME_WINDOW,
|
type: RuleType.TIME_WINDOW,
|
||||||
|
force: 'preference',
|
||||||
startDate: new Date(),
|
startDate: new Date(),
|
||||||
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
|
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||||
placeDays: 5,
|
placeDays: 5,
|
||||||
@@ -212,6 +218,7 @@ export class RulesPanel {
|
|||||||
|
|
||||||
public getRuleSummary(rule: VacationRule): string {
|
public getRuleSummary(rule: VacationRule): string {
|
||||||
const tr = this.tr();
|
const tr = this.tr();
|
||||||
|
const forceLabel = rule.force === 'obligation' ? tr.rules.obligation : tr.rules.preference;
|
||||||
if (rule.type === RuleType.IF_THEN) {
|
if (rule.type === RuleType.IF_THEN) {
|
||||||
const ifThen = rule as IfThenRule;
|
const ifThen = rule as IfThenRule;
|
||||||
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
|
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
|
||||||
@@ -223,13 +230,13 @@ export class RulesPanel {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
|
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
|
||||||
|
|
||||||
return tr.rules.ifThenTemplate
|
return `[${forceLabel}] ${tr.rules.ifThenTemplate
|
||||||
.replace('{day}', dayLabel)
|
.replace('{day}', dayLabel)
|
||||||
.replace('{placeDays}', String(ifThen.placeDays))
|
.replace('{placeDays}', String(ifThen.placeDays))
|
||||||
.replace('{placeType}', placeTypeLabel)
|
.replace('{placeType}', placeTypeLabel)
|
||||||
.replace('{placement}', placementLabel)
|
.replace('{placement}', placementLabel)
|
||||||
.replace('{rewardDays}', String(ifThen.rewardDays))
|
.replace('{rewardDays}', String(ifThen.rewardDays))
|
||||||
.replace('{rewardType}', rewardTypeLabel);
|
.replace('{rewardType}', rewardTypeLabel)}`;
|
||||||
} else {
|
} else {
|
||||||
const timeWindow = rule as TimeWindowRule;
|
const timeWindow = rule as TimeWindowRule;
|
||||||
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
|
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
|
||||||
@@ -240,11 +247,11 @@ export class RulesPanel {
|
|||||||
this.translationService.locale(),
|
this.translationService.locale(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return tr.rules.timeWindowTemplate
|
return `[${forceLabel}] ${tr.rules.timeWindowTemplate
|
||||||
.replace('{placeDays}', String(timeWindow.placeDays))
|
.replace('{placeDays}', String(timeWindow.placeDays))
|
||||||
.replace('{placeType}', placeTypeLabel)
|
.replace('{placeType}', placeTypeLabel)
|
||||||
.replace('{startDate}', startDateStr)
|
.replace('{startDate}', startDateStr)
|
||||||
.replace('{endDate}', endDateStr);
|
.replace('{endDate}', endDateStr)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,10 +54,29 @@ export class UserInput {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const expiresLabel = this.tr().userInput.expires;
|
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 hasRemainingDays = computed(() => this.remainingDays() > 0);
|
||||||
|
|
||||||
protected get daysOff() {
|
protected get daysOff() {
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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-form-field class="dateInput">
|
||||||
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
|
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
@@ -31,7 +42,7 @@
|
|||||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #picker></mat-datepicker>
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
</mat-form-field>
|
</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>
|
<mat-icon>close</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -28,4 +28,15 @@ describe('DayInput', () => {
|
|||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
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>) {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import {
|
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
|
||||||
CalendarService,
|
|
||||||
DayType,
|
|
||||||
SelectedDate,
|
|
||||||
SelectedDates,
|
|
||||||
SelectableDayType,
|
|
||||||
VacDay,
|
|
||||||
} from './calendar-service';
|
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
|
|
||||||
|
|
||||||
describe('CalendarService', () => {
|
describe('CalendarService', () => {
|
||||||
let service: CalendarService;
|
let service: CalendarService;
|
||||||
@@ -26,6 +17,20 @@ describe('CalendarService', () => {
|
|||||||
expect(service).toBeTruthy();
|
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', () => {
|
describe('getHolidays', () => {
|
||||||
it('should return an array of holidays', () => {
|
it('should return an array of holidays', () => {
|
||||||
const holidays = service.getHolidays();
|
const holidays = service.getHolidays();
|
||||||
@@ -188,14 +193,6 @@ describe('SelectedDates', () => {
|
|||||||
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
|
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', () => {
|
it('should return correct classes', () => {
|
||||||
const selectedDates = new SelectedDates();
|
const selectedDates = new SelectedDates();
|
||||||
const date = new Date(2024, 0, 1);
|
const date = new Date(2024, 0, 1);
|
||||||
@@ -211,254 +208,4 @@ describe('SelectedDates', () => {
|
|||||||
const tooltip = selectedDates.getTooltip(date);
|
const tooltip = selectedDates.getTooltip(date);
|
||||||
expect(tooltip).toBeTruthy();
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,14 @@ import dayjs from 'dayjs';
|
|||||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||||
import { DateCacheService } from './date-cache.service';
|
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(weekOfYear);
|
||||||
dayjs.extend(isoWeek);
|
dayjs.extend(isoWeek);
|
||||||
@@ -22,6 +29,29 @@ const allTypesOptions: HolidaysTypes.Options = {
|
|||||||
export class CalendarService {
|
export class CalendarService {
|
||||||
private dateCache = inject(DateCacheService);
|
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
|
// Cache for holiday timestamps
|
||||||
private holidayTimestampsCache: Set<number> | null = null;
|
private holidayTimestampsCache: Set<number> | null = null;
|
||||||
private holidayCacheYear: number | null = null;
|
private holidayCacheYear: number | null = null;
|
||||||
@@ -111,10 +141,17 @@ export class CalendarService {
|
|||||||
|
|
||||||
// Logic to return holidays for the given year
|
// Logic to return holidays for the given year
|
||||||
const hd = new Holidays('FR', allTypesOptions);
|
const hd = new Holidays('FR', allTypesOptions);
|
||||||
const holidays = hd.getHolidays(currentYear, lang);
|
const holidays = hd
|
||||||
const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang);
|
.getHolidays(currentYear, lang)
|
||||||
|
.filter((holiday) => holiday.type === 'public');
|
||||||
|
const holidaysPlusOne = hd
|
||||||
|
.getHolidays(currentYear + 1, lang)
|
||||||
|
.filter((holiday) => holiday.type === 'public');
|
||||||
holidays.push(...holidaysPlusOne);
|
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
|
// Update cache
|
||||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
||||||
@@ -188,7 +225,11 @@ export type VacDay = {
|
|||||||
id: string;
|
id: string;
|
||||||
type: SelectableDayType;
|
type: SelectableDayType;
|
||||||
numberOfDays: number;
|
numberOfDays: number;
|
||||||
|
/** Legacy balances omit this value; the planner then uses the horizon start. */
|
||||||
|
availableDate?: Date;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
|
classification?: RemainingClassification;
|
||||||
|
reason?: PlanningReason;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Week = (Date | null)[];
|
export type Week = (Date | null)[];
|
||||||
@@ -211,6 +252,12 @@ export enum SelectableDayType {
|
|||||||
ANY = 'any',
|
ANY = 'any',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dayTypeForLeaveType: Record<LeaveType, DayType> = {
|
||||||
|
cp: DayType.CP,
|
||||||
|
rtt: DayType.RTT,
|
||||||
|
other: DayType.OTHER,
|
||||||
|
};
|
||||||
|
|
||||||
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
||||||
[DayType.WEEKEND]: 'Weekend',
|
[DayType.WEEKEND]: 'Weekend',
|
||||||
[DayType.CLOSED_DAY]: 'Jour Férié',
|
[DayType.CLOSED_DAY]: 'Jour Férié',
|
||||||
@@ -305,10 +352,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
private minDate: Date | null = null;
|
private minDate: Date | null = null;
|
||||||
private maxDate: 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 {
|
update(): void {
|
||||||
this.datesSelected = this._datesSelected.slice();
|
this.datesSelected = this._datesSelected.slice();
|
||||||
this.grouping();
|
this.grouping();
|
||||||
@@ -470,565 +513,4 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
.filter((tooltip) => tooltip)
|
.filter((tooltip) => tooltip)
|
||||||
.join(' + ');
|
.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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -31,6 +31,43 @@ describe('RuleService', () => {
|
|||||||
expect(christmasRule.endDate instanceof Date).toBe(true);
|
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', () => {
|
it('should add a new rule', () => {
|
||||||
const newRule: IfThenRule = {
|
const newRule: IfThenRule = {
|
||||||
id: 'test-rule',
|
id: 'test-rule',
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ export enum RuleType {
|
|||||||
TIME_WINDOW = 'time_window',
|
TIME_WINDOW = 'time_window',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RuleForce = 'obligation' | 'preference';
|
||||||
|
|
||||||
export interface BaseRule {
|
export interface BaseRule {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: RuleType;
|
type: RuleType;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Missing force is a legacy preference and is migrated on read. */
|
||||||
|
force?: RuleForce;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
|
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
|
||||||
@@ -41,6 +45,7 @@ interface SerializedRule {
|
|||||||
name: string;
|
name: string;
|
||||||
type: RuleType;
|
type: RuleType;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
force?: RuleForce;
|
||||||
dayOfWeek?: number;
|
dayOfWeek?: number;
|
||||||
placeDays: number;
|
placeDays: number;
|
||||||
placeType: SelectableDayType;
|
placeType: SelectableDayType;
|
||||||
@@ -63,6 +68,7 @@ export class RuleService {
|
|||||||
name: 'Samedi Malin',
|
name: 'Samedi Malin',
|
||||||
type: RuleType.IF_THEN,
|
type: RuleType.IF_THEN,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
force: 'preference',
|
||||||
dayOfWeek: 6, // Saturday
|
dayOfWeek: 6, // Saturday
|
||||||
placeDays: 2,
|
placeDays: 2,
|
||||||
placeType: SelectableDayType.CP,
|
placeType: SelectableDayType.CP,
|
||||||
@@ -75,6 +81,7 @@ export class RuleService {
|
|||||||
name: 'Vacances de Noël',
|
name: 'Vacances de Noël',
|
||||||
type: RuleType.TIME_WINDOW,
|
type: RuleType.TIME_WINDOW,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
force: 'preference',
|
||||||
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
|
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
|
||||||
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
|
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
|
||||||
placeDays: 5,
|
placeDays: 5,
|
||||||
@@ -98,22 +105,31 @@ export class RuleService {
|
|||||||
if (rule.type === RuleType.TIME_WINDOW) {
|
if (rule.type === RuleType.TIME_WINDOW) {
|
||||||
return {
|
return {
|
||||||
...rule,
|
...rule,
|
||||||
|
force: rule.force ?? 'preference',
|
||||||
startDate: new Date(rule.startDate as string),
|
startDate: new Date(rule.startDate as string),
|
||||||
endDate: new Date(rule.endDate as string),
|
endDate: new Date(rule.endDate as string),
|
||||||
} as TimeWindowRule;
|
} as TimeWindowRule;
|
||||||
}
|
}
|
||||||
return rule as IfThenRule;
|
return { ...rule, force: rule.force ?? 'preference' } as IfThenRule;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public addRule(rule: VacationRule): void {
|
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 {
|
public updateRule(updatedRule: VacationRule): void {
|
||||||
this.rawRulesSignal.update((rules) =>
|
this.rawRulesSignal.update((rules) =>
|
||||||
rules.map((rule) =>
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ export interface AppTranslations {
|
|||||||
calculate: string;
|
calculate: string;
|
||||||
remainingDaysWarning: string;
|
remainingDaysWarning: string;
|
||||||
remainingDaysManualPlace: string;
|
remainingDaysManualPlace: string;
|
||||||
|
deleteEntry: string;
|
||||||
chooseDate: string;
|
chooseDate: string;
|
||||||
|
availableDate: string;
|
||||||
daySuffix: string;
|
daySuffix: string;
|
||||||
daysSuffix: string;
|
daysSuffix: string;
|
||||||
expires: string;
|
expires: string;
|
||||||
|
remainingClassification: {
|
||||||
|
unavoidableExpiry: string;
|
||||||
|
atRisk: string;
|
||||||
|
deferred: string;
|
||||||
|
};
|
||||||
|
remainingReason: {
|
||||||
|
unavoidableExpiry: string;
|
||||||
|
atRisk: string;
|
||||||
|
deferred: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
dayTypes: {
|
dayTypes: {
|
||||||
weekend: string;
|
weekend: string;
|
||||||
@@ -41,6 +53,7 @@ export interface AppTranslations {
|
|||||||
ifThenTemplate: string;
|
ifThenTemplate: string;
|
||||||
timeWindowTemplate: string;
|
timeWindowTemplate: string;
|
||||||
editRule: string;
|
editRule: string;
|
||||||
|
deleteRule: string;
|
||||||
addRule: string;
|
addRule: string;
|
||||||
place: string;
|
place: string;
|
||||||
ruleName: string;
|
ruleName: string;
|
||||||
@@ -49,6 +62,9 @@ export interface AppTranslations {
|
|||||||
type: string;
|
type: string;
|
||||||
position: string;
|
position: string;
|
||||||
getBonus: string;
|
getBonus: string;
|
||||||
|
force: string;
|
||||||
|
obligation: string;
|
||||||
|
preference: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
@@ -75,6 +91,20 @@ export interface AppTranslations {
|
|||||||
auto: string;
|
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;
|
loading: string;
|
||||||
weekdaysShort: string[];
|
weekdaysShort: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ export class UserInputService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getInitialValues(): VacDay[] {
|
private getInitialValues(): VacDay[] {
|
||||||
|
const availableDate = new Date();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.CP,
|
type: SelectableDayType.CP,
|
||||||
numberOfDays: this.getRandomNumber(),
|
numberOfDays: this.getRandomNumber(),
|
||||||
|
availableDate,
|
||||||
// today + 365 days
|
// today + 365 days
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||||
},
|
},
|
||||||
@@ -22,12 +24,14 @@ export class UserInputService {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.RTT,
|
type: SelectableDayType.RTT,
|
||||||
numberOfDays: this.getRandomNumber(),
|
numberOfDays: this.getRandomNumber(),
|
||||||
|
availableDate: new Date(availableDate),
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.OTHER,
|
type: SelectableDayType.OTHER,
|
||||||
numberOfDays: this.getRandomNumber(),
|
numberOfDays: this.getRandomNumber(),
|
||||||
|
availableDate: new Date(availableDate),
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -38,6 +42,7 @@ export class UserInputService {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: SelectableDayType.CP,
|
type: SelectableDayType.CP,
|
||||||
numberOfDays: 1,
|
numberOfDays: 1,
|
||||||
|
availableDate: new Date(),
|
||||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -9,10 +9,22 @@
|
|||||||
"calculate": "Berechnen",
|
"calculate": "Berechnen",
|
||||||
"remainingDaysWarning": "{count} nicht platziert:",
|
"remainingDaysWarning": "{count} nicht platziert:",
|
||||||
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
||||||
|
"deleteEntry": "Eintrag löschen",
|
||||||
"chooseDate": "Datum auswählen",
|
"chooseDate": "Datum auswählen",
|
||||||
|
"availableDate": "Verfügbar ab",
|
||||||
"daySuffix": "Tag",
|
"daySuffix": "Tag",
|
||||||
"daysSuffix": "Tage",
|
"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": {
|
"dayTypes": {
|
||||||
"weekend": "Wochenende",
|
"weekend": "Wochenende",
|
||||||
@@ -37,6 +49,7 @@
|
|||||||
"ifThenTemplate": "Wenn {type} dann {placement}",
|
"ifThenTemplate": "Wenn {type} dann {placement}",
|
||||||
"timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}",
|
"timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}",
|
||||||
"editRule": "Regel bearbeiten",
|
"editRule": "Regel bearbeiten",
|
||||||
|
"deleteRule": "Regel löschen",
|
||||||
"addRule": "Regel hinzufügen",
|
"addRule": "Regel hinzufügen",
|
||||||
"place": "platzieren",
|
"place": "platzieren",
|
||||||
"ruleName": "Regelname",
|
"ruleName": "Regelname",
|
||||||
@@ -45,6 +58,9 @@
|
|||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"position": "Position",
|
"position": "Position",
|
||||||
"getBonus": "Bonus erhalten",
|
"getBonus": "Bonus erhalten",
|
||||||
|
"force": "Regelverbindlichkeit",
|
||||||
|
"obligation": "Verpflichtung",
|
||||||
|
"preference": "Präferenz",
|
||||||
"startDate": "Startdatum",
|
"startDate": "Startdatum",
|
||||||
"endDate": "Enddatum",
|
"endDate": "Enddatum",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -71,6 +87,20 @@
|
|||||||
"auto": "automatisch"
|
"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...",
|
"loading": "Wird geladen...",
|
||||||
"weekdaysShort": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]
|
"weekdaysShort": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]
|
||||||
}
|
}
|
||||||
+31
-1
@@ -9,10 +9,22 @@
|
|||||||
"calculate": "Calculate",
|
"calculate": "Calculate",
|
||||||
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
||||||
"remainingDaysManualPlace": "Please place them manually.",
|
"remainingDaysManualPlace": "Please place them manually.",
|
||||||
|
"deleteEntry": "Delete entry",
|
||||||
"chooseDate": "Choose a date",
|
"chooseDate": "Choose a date",
|
||||||
|
"availableDate": "Available from",
|
||||||
"daySuffix": "day",
|
"daySuffix": "day",
|
||||||
"daysSuffix": "days",
|
"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": {
|
"dayTypes": {
|
||||||
"weekend": "Weekend",
|
"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.",
|
"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>.",
|
"timeWindowTemplate": "Place <strong>{placeDays}</strong> day(s) <strong>{placeType}</strong> between <strong>{startDate}</strong> and <strong>{endDate}</strong>.",
|
||||||
"editRule": "Edit rule",
|
"editRule": "Edit rule",
|
||||||
|
"deleteRule": "Delete rule",
|
||||||
"addRule": "Add a rule",
|
"addRule": "Add a rule",
|
||||||
"place": "Place",
|
"place": "Place",
|
||||||
"ruleName": "Rule name",
|
"ruleName": "Rule name",
|
||||||
@@ -45,6 +58,9 @@
|
|||||||
"type": "Type",
|
"type": "Type",
|
||||||
"position": "Position",
|
"position": "Position",
|
||||||
"getBonus": "Get as bonus",
|
"getBonus": "Get as bonus",
|
||||||
|
"force": "Rule force",
|
||||||
|
"obligation": "Obligation",
|
||||||
|
"preference": "Preference",
|
||||||
"startDate": "Start date",
|
"startDate": "Start date",
|
||||||
"endDate": "End date",
|
"endDate": "End date",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -71,6 +87,20 @@
|
|||||||
"auto": "Auto (Best choice)"
|
"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...",
|
"loading": "Calculating...",
|
||||||
"weekdaysShort": ["M", "T", "W", "T", "F", "S", "S"]
|
"weekdaysShort": ["M", "T", "W", "T", "F", "S", "S"]
|
||||||
}
|
}
|
||||||
+31
-1
@@ -9,10 +9,22 @@
|
|||||||
"calculate": "Calcular",
|
"calculate": "Calcular",
|
||||||
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
||||||
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
||||||
|
"deleteEntry": "Eliminar entrada",
|
||||||
"chooseDate": "Elija una fecha",
|
"chooseDate": "Elija una fecha",
|
||||||
|
"availableDate": "Disponible desde",
|
||||||
"daySuffix": "día",
|
"daySuffix": "día",
|
||||||
"daysSuffix": "días",
|
"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": {
|
"dayTypes": {
|
||||||
"weekend": "Fin de semana",
|
"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.",
|
"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>.",
|
"timeWindowTemplate": "Colocar <strong>{placeDays}</strong> día(s) <strong>{placeType}</strong> entre el <strong>{startDate}</strong> y el <strong>{endDate}</strong>.",
|
||||||
"editRule": "Editar regla",
|
"editRule": "Editar regla",
|
||||||
|
"deleteRule": "Eliminar regla",
|
||||||
"addRule": "Añadir una regla",
|
"addRule": "Añadir una regla",
|
||||||
"place": "Colocar",
|
"place": "Colocar",
|
||||||
"ruleName": "Nombre de la regla",
|
"ruleName": "Nombre de la regla",
|
||||||
@@ -45,6 +58,9 @@
|
|||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"position": "Posición",
|
"position": "Posición",
|
||||||
"getBonus": "Obtener en bonificación",
|
"getBonus": "Obtener en bonificación",
|
||||||
|
"force": "Fuerza de la regla",
|
||||||
|
"obligation": "Obligación",
|
||||||
|
"preference": "Preferencia",
|
||||||
"startDate": "Fecha de inicio",
|
"startDate": "Fecha de inicio",
|
||||||
"endDate": "Fecha de fin",
|
"endDate": "Fecha de fin",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -71,6 +87,20 @@
|
|||||||
"auto": "Auto (Mejor opción)"
|
"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...",
|
"loading": "Calculando...",
|
||||||
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
||||||
}
|
}
|
||||||
+31
-1
@@ -9,10 +9,22 @@
|
|||||||
"calculate": "Calculer",
|
"calculate": "Calculer",
|
||||||
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
||||||
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
||||||
|
"deleteEntry": "Supprimer l’entrée",
|
||||||
"chooseDate": "Choisissez une date",
|
"chooseDate": "Choisissez une date",
|
||||||
|
"availableDate": "Disponible à partir du",
|
||||||
"daySuffix": "jour",
|
"daySuffix": "jour",
|
||||||
"daysSuffix": "jours",
|
"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": {
|
"dayTypes": {
|
||||||
"weekend": "Weekend",
|
"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.",
|
"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>.",
|
"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",
|
"editRule": "Modifier la règle",
|
||||||
|
"deleteRule": "Supprimer la règle",
|
||||||
"addRule": "Ajouter une règle",
|
"addRule": "Ajouter une règle",
|
||||||
"place": "Placer",
|
"place": "Placer",
|
||||||
"ruleName": "Nom de la règle",
|
"ruleName": "Nom de la règle",
|
||||||
@@ -45,6 +58,9 @@
|
|||||||
"type": "Type",
|
"type": "Type",
|
||||||
"position": "Position",
|
"position": "Position",
|
||||||
"getBonus": "Obtenir en bonus",
|
"getBonus": "Obtenir en bonus",
|
||||||
|
"force": "Force de la règle",
|
||||||
|
"obligation": "Obligation",
|
||||||
|
"preference": "Préférence",
|
||||||
"startDate": "Date de début",
|
"startDate": "Date de début",
|
||||||
"endDate": "Date de fin",
|
"endDate": "Date de fin",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
@@ -71,6 +87,20 @@
|
|||||||
"auto": "Auto (Meilleur choix)"
|
"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...",
|
"loading": "Calcul en cours...",
|
||||||
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user