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>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-19 01:04:47 +02:00
1 parent cfa4328ddb
commit c2745ecbd6
14 files changed
+529 -45

No files matched your search

+19
View File
@@ -82,6 +82,25 @@ duration rounding to whole milliseconds prevents floating-point noise at unit
boundaries. Very fast runs can finish before the displayed lower bound. The
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. Once
the elapsed time reaches `e`, the line reads "Taking longer than expected" for
any `e`, and the live status announces it once. Both lines are visible text,
not live regions, and are hidden after Cancel.
## What is timed
Every request, manual or initial, captures `performance.now()` inside
@@ -1935,6 +1935,183 @@ 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(20_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 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(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(60_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])(
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => {
@@ -10,11 +10,21 @@
<mat-spinner
[diameter]="72"
[mode]="'determinate'"
[value]="loadingProgress()"
[value]="wait()?.percent ?? 0"
></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
<div class="spinner-text">{{ wait()?.percent ?? 0 }}%</div>
</div>
<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
mat-stroked-button
type="button"
@@ -510,6 +510,15 @@ output {
.cancel-calculation {
min-height: 44px;
}
.calculation-detail {
display: grid;
justify-items: center;
gap: 0.25rem;
text-align: center;
p {
margin: 0;
}
}
.spinner-container {
position: relative;
}
@@ -96,7 +96,26 @@ export class CalendarYear {
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
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 planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError;
@@ -319,6 +338,10 @@ export class CalendarYear {
if (this.calculations.pendingId() !== null)
untracked(() => this.liveMessage.set(this.tr().loading));
});
effect(() => {
if (this.overrun())
untracked(() => this.liveMessage.set(this.tr().planning.progress.overrun));
});
effect(() => {
const pending = this.calculations.pendingId() !== null;
const cancelling = this.isCancelling();
@@ -119,18 +119,23 @@ describe('PlanningCalculationService', () => {
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'));
service.start();
const oldProgress = create.mock.calls[0][0].progress;
service.start();
const latestProgress = create.mock.calls[1][0].progress;
const submitted = service.request();
expect(service.pending()).toMatchObject({ stage: 'preparation', request: submitted });
oldProgress('obligations');
expect(service.progress()).toBe(0);
latestProgress('at-risk');
expect(service.progress()).toBe(35);
latestProgress('unknown');
expect(service.progress()).toBe(35);
expect(service.pending()!.stage).toBe('preparation');
latestProgress('preference:spring');
expect(service.pending()!.stage).toBe('preference:spring');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
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 () => {
@@ -8,6 +8,7 @@ import type {
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
@@ -16,10 +17,14 @@ import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
interface PendingCalculation {
export interface PendingCalculation {
id: number;
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;
}
@@ -34,22 +39,6 @@ export interface AcceptedCalculation {
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 {
return {
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' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
@@ -107,7 +96,7 @@ export class PlanningCalculationService {
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null);
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 snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
@@ -131,7 +120,9 @@ export class PlanningCalculationService {
this.pendingState.set({
id: version,
horizon: request.horizon,
progress: 0,
request,
requestedAt,
stage: 'preparation',
cancelling: false,
});
let calculation: PlanningCalculationContext;
@@ -147,12 +138,8 @@ export class PlanningCalculationService {
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
this.pendingState.update((pending) =>
pending && !pending.cancelling ? { ...pending, stage } : pending,
);
},
});
@@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat';
import { CalendarSettingsService } from './calendar-settings-service';
import { SelectableDayType } from './calendar-service';
import { CalendarService, SelectableDayType } from './calendar-service';
import { RuleService, RuleType } from './rule-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 { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
@@ -145,3 +146,142 @@ describe('Planning duration estimate', () => {
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: true });
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: true });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
});
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();
});
});
+67 -7
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 type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service';
@@ -9,6 +9,24 @@ export interface PlanningDurationRange {
unit: 'seconds' | 'minutes';
}
/** Progress through the indicative wait of the pending calculation. */
export interface PlanningWait {
percent: number;
/** Approximate remaining time; null when the expected wait is short, overrun or complete. */
remaining: { value: number; unit: PlanningDurationRange['unit'] } | null;
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 {
const key =
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
@@ -54,16 +72,26 @@ function initialDurationMs(request: PlanningRequest): number {
export class PlanningDurationService {
private readonly calculations = inject(PlanningCalculationService);
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();
return (
pending && {
id: pending.id,
requestedAt: pending.requestedAt,
ms: Math.max(1, this.expectedMs(pending.request)),
}
);
},
{ equal: (a, b) => a?.id === b?.id },
);
readonly estimate = computed<PlanningDurationRange | null>(() => {
const request = this.calculations.request();
if (!request) return null;
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;
const duration = Math.round(initialDurationMs(request) * factor);
const duration = this.expectedMs(request);
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
const divisor = unit === 'minutes' ? 60_000 : 1_000;
const minimum = Math.max(1, Math.floor(duration / 2 / divisor));
@@ -74,9 +102,41 @@ export class PlanningDurationService {
};
});
readonly wait = computed<PlanningWait | null>(() => {
const expected = this.expected();
if (!expected) return null;
if (this.calculations.pending()?.stage === 'complete')
return { percent: 100, remaining: null, overrun: false };
const elapsed = Math.max(0, this.clock() - expected.requestedAt);
const left = expected.ms - elapsed;
return {
percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
overrun: left <= 0,
};
});
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 {
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
const factor = elapsedMs / initialDurationMs(request);
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;
cancel: string;
cancelling: string;
progress: {
stages: Record<string, string>;
rule: string;
remaining: string;
overrun: string;
};
cancelled: string;
cancelledPartial: string;
error: string;
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
"cancel": "Abbrechen",
"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",
"cancelledPartial": "Berechnung abgebrochen — Teilplan",
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
+12
View File
@@ -200,6 +200,18 @@
"incomplete": "Calculation stopped before all quality stages completed.",
"cancel": "Cancel",
"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",
"cancelledPartial": "Calculation cancelled — partial plan",
"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.",
"cancel": "Cancelar",
"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",
"cancelledPartial": "Cálculo cancelado — plan parcial",
"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é.",
"cancel": "Annuler",
"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é",
"cancelledPartial": "Calcul annulé — plan partiel",
"error": "Le planificateur a rencontré une erreur inattendue.",