Compare commits

..
Author SHA1 Message Date
nessarandClaude Opus 5.5 f128af68af feat(planning): warn of an overrun only past the displayed range
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 34s
CI / build (push) Successful in 38s
CI / policy (pull_request) Successful in 8s
CI / benchmark (push) Successful in 1m7s
CI / validate (pull_request) Successful in 36s
CI / build (pull_request) Successful in 38s
CI / test (push) Failing after 2m0s
CI / benchmark (pull_request) Successful in 58s
CI / test (pull_request) Failing after 1m50s
CI / security (push) Successful in 56s
CI / images (push) Skipped
CI / required (push) Failing after 1s
CI / security (pull_request) Successful in 1m41s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 5s
The expected wait sits near the middle of recent waits, so about half of
all runs pass it. Between the expected wait and the top of the range
shown before Calculate, the percentage now holds at 95% with no
remaining-time line; "Taking longer than expected" appears and is
announced only past that top.

Refs #300

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:12:35 +02:00
nessarandClaude Opus 5.5 c2745ecbd6 feat(planning): follow the indicative wait in calculation progress
CI / policy (push) Successful in 22s
CI / validate (push) Successful in 35s
CI / build (push) Successful in 21s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 24s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m4s
CI / build (pull_request) Successful in 24s
CI / test (push) Failing after 2m8s
CI / images (push) Skipped
CI / required (push) Failing after 1s
CI / security (pull_request) Successful in 56s
CI / benchmark (pull_request) Successful in 1m32s
CI / test (pull_request) Failing after 1m50s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 2s
The overlay percentage now rises linearly to 95% at the expected wait
captured at Calculate, holds there, and shows 100% only when the planner
completes. Beneath the status, one line names the current planning stage
(with the rule name for rule stages) and another shows the approximate
remaining time, or "Taking longer than expected" once the wait is
exceeded, announced once through the live status. Cancel freezes the
percentage and hides both lines.

PlanningCalculationService reports the stage and submitted request
instead of fixed per-stage percentages; PlanningDurationService owns the
wait progress on a 250 ms tick.

Closes #300

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:04:47 +02:00
nessarandClaude Opus 5.5 cfa4328ddb docs(context): define planning stage and indicative wait
Both terms are user-visible in the calculation overlay that #300
revises: the stage line names the planning stage, and the remaining
time comes from the indicative wait.

Refs #300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:26:31 +02:00
nessar 7fc3457b63 perf(planning): plan from the best-ranked obligation solution and remove repeated work
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 33s
CI / build (push) Successful in 36s
CI / benchmark (push) Successful in 1m10s
CI / security (push) Successful in 56s
CI / test (push) Successful in 2m2s
CI / images (push) Successful in 53s
CI / required (push) Successful in 2s
close #302

See pull request #303
2026-10-18 23:47:13 +02:00
15 changed files with 571 additions and 51 deletions

No files matched your search

+8
View File
@@ -157,6 +157,14 @@ A deterministic explanation derived from the final displayed plan. It identifies
**Incomplete calculation**: **Incomplete calculation**:
A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict. A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict.
**Planning stage**:
One reported step of a calculation: preparation, validation, obligations, one stage per active preference rule in rule order, expiry, then dates. Stages describe what the calculation is doing, not how far it has progressed.
_Avoid_: phase, quality stage
**Indicative wait**:
The estimated duration of a calculation, shown as a range before Calculate and as an approximate remaining time during it. It is a rough guide refined by the device's own completed calculations, never a deadline.
_Avoid_: ETA, timeout, deadline
**Planning objective order**: **Planning objective order**:
The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one. The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one.
+22
View File
@@ -82,6 +82,28 @@ duration rounding to whole milliseconds prevents floating-point noise at unit
boundaries. Very fast runs can finish before the displayed lower bound. The boundaries. Very fast runs can finish before the displayed lower bound. The
range is explicitly indicative, not a confidence interval or a promise. range is explicitly indicative, not a confidence interval or a promise.
## During a calculation
The loading overlay's percentage follows the indicative wait, not the planning
stages completed. At Calculate, `PlanningDurationService` freezes the expected
duration `e` for the submitted snapshot, including the local refinement. On a
250 ms tick, it measures elapsed time from the same timestamp the calibration
uses. Progress rises linearly to 95% at `e`, holds there and shows 100% only
when the planner reports completion. Per-stage weights are not used, because
the costly stage depends on the configuration. Form edits and repeated stages
cannot move it backwards. On Cancel, the tick stops and the percentage freezes.
Below the status, one line names the current planning stage. For an
optimization or preference stage, it shows the rule's name. When `e` is at least
3 seconds, a second line shows "About N s left". This is in whole seconds under
10 seconds, 5-second steps under a minute and whole minutes after that. From
`e` to the top of the range shown before Calculate, the percentage holds at 95%
and that line is hidden. Because `e` sits near the middle of recent waits, about
half of all runs pass it, so it is no reason to warn the user. Past the top of
the range, for any `e`, the line reads "Taking longer than expected" and the
live status announces it once. Both lines are visible text, not live regions,
and are hidden after Cancel.
## What is timed ## What is timed
Every request, manual or initial, captures `performance.now()` inside Every request, manual or initial, captures `performance.now()` inside
@@ -1935,6 +1935,186 @@ describe('CalendarYear planning outcome boundary', () => {
} }
}); });
async function waitingCalendar() {
vi.useFakeTimers();
const clock = { now: 1_000 };
vi.spyOn(performance, 'now').mockImplementation(() => clock.now);
const { calendar, calculation } = calendarFor(plan);
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: new Promise<PlanningOutcome>(() => undefined),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
TestBed.inject(RuleService).addRule({
id: 'spring',
name: 'Spring break',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'preference',
startDate: new Date('2024-04-01T00:00:00'),
endDate: new Date('2024-04-30T00:00:00'),
placeDays: 5,
placeType: SelectableDayType.CP,
});
TestBed.inject(PlanningDurationService).recordCompletion(
TestBed.inject(PlanningCalculationService).request()!,
20_000,
);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
const [request] = calendar.createPlanningCalculation.mock.calls[0] as unknown as [
{ progress: (stage: string) => void },
];
const root = fixture.nativeElement as HTMLElement;
const text = (selector: string) => root.querySelector(selector)?.textContent?.trim();
const at = (elapsedMs: number) => {
clock.now = 1_000 + elapsedMs;
vi.advanceTimersByTime(250);
fixture.detectChanges();
};
return { fixture, root, text, at, progress: request.progress };
}
it('names the planning stage and remaining wait as visible text in every supported language', async () => {
try {
const { fixture, root, text, at, progress } = await waitingCalendar();
at(0);
expect(text('.spinner-text')).toBe('0%');
expect(text('.calculation-stage')).toBe('Preparing the calendar');
expect(text('.calculation-remaining')).toBe('About 20 s left');
expect(
root.querySelector('.calculation-detail')!.closest('[role], [aria-live]'),
).toBeNull();
for (const [stage, expected] of [
['validation', 'Checking your inputs'],
['obligations', 'Satisfying obligations'],
['optimization:default-holiday-week-value', 'Applying “Semaines fériées”'],
['preference:spring', 'Applying “Spring break”'],
['expiry', 'Using balances that expire first'],
['dates', 'Settling the final dates'],
]) {
progress(stage);
fixture.detectChanges();
expect(text('.calculation-stage')).toBe(expected);
}
const stages = [
'preparation',
'validation',
'obligations',
'preference:spring',
'expiry',
'dates',
];
const localized = [
[
'fr',
[
'Préparation du calendrier',
'Vérification de vos saisies',
'Respect des obligations',
'Application de « Spring break »',
'Utilisation des soldes qui expirent en premier',
'Fixation des dates définitives',
],
'Encore environ 20 s',
'Plus long que prévu',
],
[
'es',
[
'Preparando el calendario',
'Comprobando tus datos',
'Cumpliendo las obligaciones',
'Aplicando «Spring break»',
'Usando primero los saldos que vencen antes',
'Fijando las fechas definitivas',
],
'Quedan unos 20 s',
'Está tardando más de lo previsto',
],
[
'de',
[
'Kalender wird vorbereitet',
'Eingaben werden geprüft',
'Verpflichtungen werden erfüllt',
'„Spring break“ wird angewendet',
'Zuerst verfallende Guthaben werden verwendet',
'Endgültige Daten werden festgelegt',
],
'Noch etwa 20 s',
'Dauert länger als erwartet',
],
] as const;
for (const [language, texts, remaining] of localized) {
TestBed.inject(TranslationService).language.set(language);
stages.forEach((stage, index) => {
progress(stage);
fixture.detectChanges();
expect(text('.calculation-stage')).toBe(texts[index]);
});
expect(text('.calculation-remaining')).toBe(remaining);
}
at(60_000);
for (const [language, , , overrun] of localized) {
TestBed.inject(TranslationService).language.set(language);
fixture.detectChanges();
expect(text('.calculation-remaining')).toBe(overrun);
}
} finally {
vi.restoreAllMocks();
}
});
it('announces an overrun once past the displayed range while the overlay status keeps loading', async () => {
try {
const { fixture, root, text, at } = await waitingCalendar();
at(19_000);
expect(text('.calculation-remaining')).toBe('About 1 s left');
at(20_000);
expect(text('.spinner-text')).toBe('95%');
expect(root.querySelector('.calculation-remaining')).toBeNull();
expect(text('.sr-only[role="status"]')).toBe('Calculating...');
at(60_000);
expect(text('.calculation-remaining')).toBe('Taking longer than expected');
expect(text('.loading-overlay [role="status"]')).toBe('Calculating...');
expect(text('.sr-only[role="status"]')).toBe('Taking longer than expected');
fixture.componentInstance['liveMessage'].set('');
at(120_000);
expect(text('.spinner-text')).toBe('95%');
expect(text('.sr-only[role="status"]')).toBe('');
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
} finally {
vi.restoreAllMocks();
}
});
it('freezes the percentage and hides the stage and remaining wait after Cancel', async () => {
try {
const { root, text, at, progress } = await waitingCalendar();
at(10_000);
expect(text('.spinner-text')).toBe('47%');
root.querySelector<HTMLButtonElement>('.cancel-calculation')!.click();
progress('complete');
at(30_000);
expect(text('.spinner-text')).toBe('47%');
expect(root.querySelector('.calculation-detail')).toBeNull();
expect(text('.loading-overlay [role="status"]')).toBe('Cancelling…');
expect(root.querySelector<HTMLButtonElement>('.cancel-calculation')!.disabled).toBe(
true,
);
} finally {
vi.restoreAllMocks();
}
});
it.each([false, true])( it.each([false, true])(
'cancels accessibly with a valid incumbent: %s, then allows a new calculation', 'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => { async (withIncumbent) => {
@@ -10,11 +10,21 @@
<mat-spinner <mat-spinner
[diameter]="72" [diameter]="72"
[mode]="'determinate'" [mode]="'determinate'"
[value]="loadingProgress()" [value]="wait()?.percent ?? 0"
></mat-spinner> ></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div> <div class="spinner-text">{{ wait()?.percent ?? 0 }}%</div>
</div> </div>
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p> <p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
@if (!isCancelling()) {
<div class="calculation-detail">
@if (calculationStage()) {
<p class="calculation-stage">{{ calculationStage() }}</p>
}
@if (remainingWait()) {
<p class="calculation-remaining">{{ remainingWait() }}</p>
}
</div>
}
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
@@ -510,6 +510,15 @@ output {
.cancel-calculation { .cancel-calculation {
min-height: 44px; min-height: 44px;
} }
.calculation-detail {
display: grid;
justify-items: center;
gap: 0.25rem;
text-align: center;
p {
margin: 0;
}
}
.spinner-container { .spinner-container {
position: relative; position: relative;
} }
@@ -96,7 +96,26 @@ export class CalendarYear {
protected readonly isLoading = this.calculations.isCalculating; protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling; protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle'); private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = this.calculations.progress; protected readonly wait = this.duration.wait;
private readonly overrun = computed(() => this.wait()?.overrun ?? false);
protected readonly calculationStage = computed(() => {
const pending = this.calculations.pending();
if (!pending) return '';
const progress = this.tr().planning.progress;
const ruleId = /^(?:optimization|preference):(.*)$/.exec(pending.stage)?.[1];
if (ruleId === undefined) return progress.stages[pending.stage] ?? '';
const rule = pending.request.rules.find(({ id }) => id === ruleId);
return progress.rule.replace('{rule}', rule?.name ?? ruleId);
});
protected readonly remainingWait = computed(() => {
const wait = this.wait();
const progress = this.tr().planning.progress;
if (wait?.overrun) return progress.overrun;
if (!wait?.remaining) return '';
return progress.remaining
.replace('{value}', String(wait.remaining.value))
.replace('{unit}', this.tr().userInput.durationUnits[wait.remaining.unit]);
});
protected readonly planningOutcome = this.calculations.outcome; protected readonly planningOutcome = this.calculations.outcome;
protected readonly planningExplanation = this.calculations.explanation; protected readonly planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError; protected readonly explanationError = this.calculations.explanationError;
@@ -319,6 +338,10 @@ export class CalendarYear {
if (this.calculations.pendingId() !== null) if (this.calculations.pendingId() !== null)
untracked(() => this.liveMessage.set(this.tr().loading)); untracked(() => this.liveMessage.set(this.tr().loading));
}); });
effect(() => {
if (this.overrun())
untracked(() => this.liveMessage.set(this.tr().planning.progress.overrun));
});
effect(() => { effect(() => {
const pending = this.calculations.pendingId() !== null; const pending = this.calculations.pendingId() !== null;
const cancelling = this.isCancelling(); const cancelling = this.isCancelling();
@@ -119,18 +119,23 @@ describe('PlanningCalculationService', () => {
expect(submission.labels).toBeDefined(); expect(submission.labels).toBeDefined();
}); });
it('reports progress of the latest calculation only', () => { it('reports the stage and submission of the latest calculation only, until Cancel', () => {
queue(fakeCalculation('old'), fakeCalculation('latest')); queue(fakeCalculation('old'), fakeCalculation('latest'));
service.start(); service.start();
const oldProgress = create.mock.calls[0][0].progress; const oldProgress = create.mock.calls[0][0].progress;
service.start(); service.start();
const latestProgress = create.mock.calls[1][0].progress; const latestProgress = create.mock.calls[1][0].progress;
const submitted = service.request();
expect(service.pending()).toMatchObject({ stage: 'preparation', request: submitted });
oldProgress('obligations'); oldProgress('obligations');
expect(service.progress()).toBe(0); expect(service.pending()!.stage).toBe('preparation');
latestProgress('at-risk'); latestProgress('preference:spring');
expect(service.progress()).toBe(35); expect(service.pending()!.stage).toBe('preference:spring');
latestProgress('unknown'); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
expect(service.progress()).toBe(35); expect(service.pending()!.request).toBe(submitted);
service.cancel();
latestProgress('complete');
expect(service.pending()).toMatchObject({ stage: 'preference:spring', cancelling: true });
}); });
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => { it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
@@ -8,6 +8,7 @@ import type {
PlanningExplanation, PlanningExplanation,
PlanningOutcome, PlanningOutcome,
PlanningReason, PlanningReason,
PlanningRequest,
PlanningSnapshot, PlanningSnapshot,
} from './leave-planner.service'; } from './leave-planner.service';
import { RuleService } from './rule-service'; import { RuleService } from './rule-service';
@@ -16,10 +17,14 @@ import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string }; type Horizon = { start: string; end: string };
interface PendingCalculation { export interface PendingCalculation {
id: number; id: number;
horizon: Horizon; horizon: Horizon;
progress: number; /** The configuration submitted at Calculate, unaffected by later form edits. */
request: PlanningRequest;
requestedAt: number;
/** The latest planning stage reported by the planner, or 'complete'. */
stage: string;
cancelling: boolean; cancelling: boolean;
} }
@@ -34,22 +39,6 @@ export interface AcceptedCalculation {
successful: boolean; successful: boolean;
} }
function stageProgress(stage: string, current: number): 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:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
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 current;
}
function plannerError(error: unknown): PlanningReason { function plannerError(error: unknown): PlanningReason {
return { return {
code: 'planner-error', code: 'planner-error',
@@ -64,7 +53,7 @@ function explanationFailure(error: unknown): PlanningReason {
}; };
} }
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */ /** Single owner of planning calculations: submission, planning stages, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlanningCalculationService { export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService); private readonly settings = inject(CalendarSettingsService);
@@ -107,7 +96,7 @@ export class PlanningCalculationService {
readonly pendingId = computed(() => this.pendingState()?.id ?? null); readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null); readonly isCalculating = computed(() => this.pendingState() !== null);
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false); readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
readonly progress = computed(() => this.pendingState()?.progress ?? 0); readonly pending = this.pendingState.asReadonly();
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null); readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null); readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => { readonly displayedPlan = computed(() => {
@@ -131,7 +120,9 @@ export class PlanningCalculationService {
this.pendingState.set({ this.pendingState.set({
id: version, id: version,
horizon: request.horizon, horizon: request.horizon,
progress: 0, request,
requestedAt,
stage: 'preparation',
cancelling: false, cancelling: false,
}); });
let calculation: PlanningCalculationContext; let calculation: PlanningCalculationContext;
@@ -147,12 +138,8 @@ export class PlanningCalculationService {
), ),
progress: (stage) => { progress: (stage) => {
if (version === this.version) if (version === this.version)
this.pendingState.update( this.pendingState.update((pending) =>
(pending) => pending && !pending.cancelling ? { ...pending, stage } : pending,
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
); );
}, },
}); });
@@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat';
import { CalendarSettingsService } from './calendar-settings-service'; import { CalendarSettingsService } from './calendar-settings-service';
import { SelectableDayType } from './calendar-service'; import { CalendarService, SelectableDayType } from './calendar-service';
import { RuleService, RuleType } from './rule-service'; import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service'; import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service'; import { createPlanningCalculation, type PlanningOutcome } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service'; import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service'; import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives'; import { OPTIMIZATION_PRESETS } from './planning-objectives';
@@ -145,3 +146,157 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(initial); expect(duration.estimate()).toEqual(initial);
}); });
}); });
describe('Indicative wait progress', () => {
let now: number;
let startedAt: number;
let progress: (stage: string) => void;
let resolve: (outcome: PlanningOutcome) => void;
let reject: (error: Error) => void;
let duration: PlanningDurationService;
let calculations: PlanningCalculationService;
/** Starts a calculation whose expected wait is exactly `expectedMs`. */
function start(expectedMs: number): void {
duration['calibration'].set([]);
duration.recordCompletion(calculations.request()!, expectedMs);
startedAt = now;
calculations.start();
TestBed.tick();
}
function at(elapsedMs: number) {
now = startedAt + elapsedMs;
vi.advanceTimersByTime(250);
return duration.wait();
}
beforeEach(() => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.useFakeTimers();
now = 1_000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
TestBed.configureTestingModule({
providers: [
{
provide: CalendarService,
useValue: {
createPlanningCalculation: (request: { progress: typeof progress }) => {
progress = request.progress;
return {
cancel: () => undefined,
snapshot: { rules: [], labels: {}, declaredDays: [] },
outcome: new Promise<PlanningOutcome>((res, rej) => {
resolve = res;
reject = rej;
}),
explanation: new Promise(() => undefined),
explanationError: new Promise(() => undefined),
};
},
},
},
],
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
duration = TestBed.inject(PlanningDurationService);
calculations = TestBed.inject(PlanningCalculationService);
});
afterEach(() => {
TestBed.resetTestingModule();
vi.useRealTimers();
vi.restoreAllMocks();
localStorage.clear();
});
it('climbs linearly to 95% at the expected wait, holds, then shows 100% on completion', () => {
start(20_000);
expect(at(0)).toEqual({
percent: 0,
remaining: { value: 20, unit: 'seconds' },
overrun: false,
});
expect(at(10_000)).toMatchObject({ percent: 47, overrun: false });
expect(at(19_999)).toMatchObject({ percent: 94, overrun: false });
expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: false });
expect(at(600_000)).toEqual({ percent: 95, remaining: null, overrun: true });
progress('complete');
expect(duration.wait()).toEqual({ percent: 100, remaining: null, overrun: false });
});
it('shows the remaining time only for expected waits of at least 3 seconds', () => {
start(2_999);
expect(at(1_000)).toEqual({ percent: 31, remaining: null, overrun: false });
expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: false });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
});
it.each([
[2_999, { minimum: 1, maximum: 9, unit: 'seconds' }, 9_000],
[20_000, { minimum: 10, maximum: 60, unit: 'seconds' }, 60_000],
[300_000, { minimum: 2, maximum: 15, unit: 'minutes' }, 900_000],
])(
'warns of an overrun of a %i ms expected wait only past the top of the range shown before Calculate',
(expectedMs, shown, topMs) => {
start(expectedMs);
expect(duration.estimate()).toEqual(shown);
expect(at(expectedMs)).toEqual({ percent: 95, remaining: null, overrun: false });
expect(at(topMs - 1)!.overrun).toBe(false);
expect(at(topMs)!.overrun).toBe(true);
},
);
it('rounds the remaining time to seconds, then 5-second steps, then minutes', () => {
start(3_000);
expect(at(0)!.remaining).toEqual({ value: 3, unit: 'seconds' });
expect(at(2_100)!.remaining).toEqual({ value: 1, unit: 'seconds' });
start(300_000);
const left = (seconds: number) => at(300_000 - seconds * 1_000)!.remaining;
expect(left(9.2)).toEqual({ value: 10, unit: 'seconds' });
expect(left(12.4)).toEqual({ value: 10, unit: 'seconds' });
expect(left(12.6)).toEqual({ value: 15, unit: 'seconds' });
expect(left(57.4)).toEqual({ value: 55, unit: 'seconds' });
expect(left(57.6)).toEqual({ value: 1, unit: 'minutes' });
expect(left(149)).toEqual({ value: 2, unit: 'minutes' });
expect(left(300)).toEqual({ value: 5, unit: 'minutes' });
});
it('keeps the expected wait frozen when the form or calibration change mid-calculation', () => {
start(20_000);
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2028-12-31');
duration.recordCompletion(calculations.request()!, 900_000);
expect(at(10_000)).toMatchObject({ percent: 47, remaining: { value: 10 } });
});
it('never moves backwards when the planner repeats stages', () => {
start(20_000);
const percents = ['obligations', 'preference:a', 'dates', 'preference:a', 'dates'].map(
(stage, index) => {
progress(stage);
return at(index * 5_000)!.percent;
},
);
expect(percents).toEqual([...percents].sort((a, b) => a - b));
expect(percents.at(-1)).toBe(95);
});
it('freezes on Cancel and never shows 100% after cancellation, incomplete outcomes or errors', async () => {
start(20_000);
at(10_000);
calculations.cancel();
TestBed.tick();
progress('complete');
expect(at(30_000)).toMatchObject({ percent: 47 });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
await Promise.resolve();
expect(duration.wait()).toBeNull();
start(20_000);
at(25_000);
reject(new Error('Worker failed'));
await Promise.resolve();
await Promise.resolve();
expect(duration.wait()).toBeNull();
});
});
+80 -13
View File
@@ -1,4 +1,4 @@
import { computed, inject, Injectable, signal } from '@angular/core'; import { computed, effect, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { PlanningDate, PlanningRequest } from './leave-planner.service'; import type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service'; import { PlanningCalculationService } from './planning-calculation.service';
@@ -9,6 +9,37 @@ export interface PlanningDurationRange {
unit: 'seconds' | 'minutes'; unit: 'seconds' | 'minutes';
} }
const UNIT_MS = { seconds: 1_000, minutes: 60_000 } as const;
function durationRange(duration: number): PlanningDurationRange {
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
const minimum = Math.max(1, Math.floor(duration / 2 / UNIT_MS[unit]));
return {
minimum,
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / UNIT_MS[unit])),
unit,
};
}
/** Progress through the indicative wait of the pending calculation. */
export interface PlanningWait {
percent: number;
/** Approximate remaining time; null when the expected wait is short, elapsed or complete. */
remaining: { value: number; unit: PlanningDurationRange['unit'] } | null;
/** True past the top of the range shown before Calculate. */
overrun: boolean;
}
function remainingTime(ms: number): NonNullable<PlanningWait['remaining']> {
const seconds = ms / 1_000;
// Rounded up under 10 s so the line never reads "0 s" while time remains.
if (seconds < 10) return { value: Math.ceil(seconds), unit: 'seconds' };
const nearestFive = Math.round(seconds / 5) * 5;
return nearestFive < 60
? { value: nearestFive, unit: 'seconds' }
: { value: Math.round(seconds / 60), unit: 'minutes' };
}
function dateTime(value: PlanningDate): number { function dateTime(value: PlanningDate): number {
const key = const key =
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value; value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
@@ -54,29 +85,65 @@ function initialDurationMs(request: PlanningRequest): number {
export class PlanningDurationService { export class PlanningDurationService {
private readonly calculations = inject(PlanningCalculationService); private readonly calculations = inject(PlanningCalculationService);
private readonly calibration = signal<readonly number[]>([]); private readonly calibration = signal<readonly number[]>([]);
private readonly clock = signal(0);
// Equal while the calculation id is unchanged, so e is captured once at Calculate.
private readonly expected = computed(
() => {
const pending = this.calculations.pending();
if (!pending) return null;
const ms = Math.max(1, this.expectedMs(pending.request));
const { maximum, unit } = durationRange(ms);
return {
id: pending.id,
requestedAt: pending.requestedAt,
ms,
overrunMs: maximum * UNIT_MS[unit],
};
},
{ equal: (a, b) => a?.id === b?.id },
);
readonly estimate = computed<PlanningDurationRange | null>(() => { readonly estimate = computed<PlanningDurationRange | null>(() => {
const request = this.calculations.request(); const request = this.calculations.request();
if (!request) return null; if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b); return durationRange(this.expectedMs(request));
const middle = Math.floor(factors.length / 2); });
const factor = factors.length
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2 readonly wait = computed<PlanningWait | null>(() => {
: 1; const expected = this.expected();
const duration = Math.round(initialDurationMs(request) * factor); if (!expected) return null;
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds'; if (this.calculations.pending()?.stage === 'complete')
const divisor = unit === 'minutes' ? 60_000 : 1_000; return { percent: 100, remaining: null, overrun: false };
const minimum = Math.max(1, Math.floor(duration / 2 / divisor)); const elapsed = Math.max(0, this.clock() - expected.requestedAt);
const left = expected.ms - elapsed;
return { return {
minimum, percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / divisor)), remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
unit, overrun: elapsed >= expected.overrunMs,
}; };
}); });
constructor() {
// Reading e here captures it at Calculate; stopping the clock on Cancel freezes the percentage.
effect((onCleanup) => {
if (!this.expected() || this.calculations.isCancelling()) return;
const timer = setInterval(() => this.clock.set(performance.now()), 250);
onCleanup(() => clearInterval(timer));
});
}
recordCompletion(request: PlanningRequest, elapsedMs: number): void { recordCompletion(request: PlanningRequest, elapsedMs: number): void {
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return; if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
const factor = elapsedMs / initialDurationMs(request); const factor = elapsedMs / initialDurationMs(request);
this.calibration.update((factors) => [...factors.slice(-7), factor]); this.calibration.update((factors) => [...factors.slice(-7), factor]);
} }
private expectedMs(request: PlanningRequest): number {
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
const factor = factors.length
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2
: 1;
return Math.round(initialDurationMs(request) * factor);
}
} }
+6
View File
@@ -243,6 +243,12 @@ export interface AppTranslations {
incomplete: string; incomplete: string;
cancel: string; cancel: string;
cancelling: string; cancelling: string;
progress: {
stages: Record<string, string>;
rule: string;
remaining: string;
overrun: string;
};
cancelled: string; cancelled: string;
cancelledPartial: string; cancelledPartial: string;
error: string; error: string;
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.", "incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"cancelling": "Berechnung wird abgebrochen…", "cancelling": "Berechnung wird abgebrochen…",
"progress": {
"stages": {
"preparation": "Kalender wird vorbereitet",
"validation": "Eingaben werden geprüft",
"obligations": "Verpflichtungen werden erfüllt",
"expiry": "Zuerst verfallende Guthaben werden verwendet",
"dates": "Endgültige Daten werden festgelegt"
},
"rule": "„{rule}“ wird angewendet",
"remaining": "Noch etwa {value} {unit}",
"overrun": "Dauert länger als erwartet"
},
"cancelled": "Berechnung abgebrochen", "cancelled": "Berechnung abgebrochen",
"cancelledPartial": "Berechnung abgebrochen — Teilplan", "cancelledPartial": "Berechnung abgebrochen — Teilplan",
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.", "error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "Calculation stopped before all quality stages completed.", "incomplete": "Calculation stopped before all quality stages completed.",
"cancel": "Cancel", "cancel": "Cancel",
"cancelling": "Cancelling…", "cancelling": "Cancelling…",
"progress": {
"stages": {
"preparation": "Preparing the calendar",
"validation": "Checking your inputs",
"obligations": "Satisfying obligations",
"expiry": "Using balances that expire first",
"dates": "Settling the final dates"
},
"rule": "Applying “{rule}”",
"remaining": "About {value} {unit} left",
"overrun": "Taking longer than expected"
},
"cancelled": "Calculation cancelled", "cancelled": "Calculation cancelled",
"cancelledPartial": "Calculation cancelled — partial plan", "cancelledPartial": "Calculation cancelled — partial plan",
"error": "The planner encountered an unexpected error.", "error": "The planner encountered an unexpected error.",
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.", "incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
"cancel": "Cancelar", "cancel": "Cancelar",
"cancelling": "Cancelando…", "cancelling": "Cancelando…",
"progress": {
"stages": {
"preparation": "Preparando el calendario",
"validation": "Comprobando tus datos",
"obligations": "Cumpliendo las obligaciones",
"expiry": "Usando primero los saldos que vencen antes",
"dates": "Fijando las fechas definitivas"
},
"rule": "Aplicando «{rule}»",
"remaining": "Quedan unos {value} {unit}",
"overrun": "Está tardando más de lo previsto"
},
"cancelled": "Cálculo cancelado", "cancelled": "Cálculo cancelado",
"cancelledPartial": "Cálculo cancelado — plan parcial", "cancelledPartial": "Cálculo cancelado — plan parcial",
"error": "El planificador encontró un error inesperado.", "error": "El planificador encontró un error inesperado.",
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.", "incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
"cancel": "Annuler", "cancel": "Annuler",
"cancelling": "Annulation en cours…", "cancelling": "Annulation en cours…",
"progress": {
"stages": {
"preparation": "Préparation du calendrier",
"validation": "Vérification de vos saisies",
"obligations": "Respect des obligations",
"expiry": "Utilisation des soldes qui expirent en premier",
"dates": "Fixation des dates définitives"
},
"rule": "Application de « {rule} »",
"remaining": "Encore environ {value} {unit}",
"overrun": "Plus long que prévu"
},
"cancelled": "Calcul annulé", "cancelled": "Calcul annulé",
"cancelledPartial": "Calcul annulé — plan partiel", "cancelledPartial": "Calcul annulé — plan partiel",
"error": "Le planificateur a rencontré une erreur inattendue.", "error": "Le planificateur a rencontré une erreur inattendue.",