Compare commits

..
Author SHA1 Message Date
nessarandClaude Opus 5.5 5d343dc94b ci: run push workflows only on develop, main and release tags
CI / policy (pull_request) Successful in 11s
CI / validate (pull_request) Successful in 32s
CI / build (pull_request) Successful in 26s
CI / security (pull_request) Successful in 1m4s
CI / benchmark (pull_request) Successful in 1m39s
CI / test (pull_request) Failing after 2m18s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 1s
Every commit on a branch with an open PR ran the whole pipeline twice, once
for the push and once for the pull_request event, in separate concurrency
groups. Filter the push trigger to develop, main and v* tags so other
branches run CI and publish their images only through their PR. Tags are
listed explicitly because a branch filter alone stops tag pushes.

Update the trigger, benchmark and image prose in the README and the Gitea
Actions guide to match.

Refs #309

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:54:09 +02:00
18 changed files with 80 additions and 594 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches: [develop, main]
tags: ['v*']
pull_request:
types: [opened, reopened, synchronize, edited]
workflow_dispatch:
-8
View File
@@ -157,14 +157,6 @@ A deterministic explanation derived from the final displayed plan. It identifies
**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.
**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**:
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.
+3 -3
View File
@@ -249,13 +249,13 @@ The server answers `GET` and `HEAD` only (other methods get 405). It serves file
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
The workflow runs on pushes to `develop` and `main`, `v*` tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Other branches run CI only through their PR, so a branch without a PR gets no automatic CI or image. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 240-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restored this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). After [#302](docs/benchmark/issue-302.md) the local protocol takes about 41 seconds. Benchmark success is required for image publication and the aggregate `required` job. The lowered hang guard and tightened multipliers still need confirmation on the CI runners.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes to `develop`, `main` and release tags, and same-repository PRs. Registry credentials are confined to the publishing step.
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
@@ -271,7 +271,7 @@ The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Successful push to `develop` or `main`, or same-repository PR | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
+24 -20
View File
@@ -11,10 +11,13 @@ message.
## Jobs and triggers
Every branch and tag push runs CI. Pull requests run on opening, reopening,
synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
Pushes to `develop` and `main` and `v*` tag pushes run CI. Other branches
(feature, renovate, release, hotfix) run it only through their pull request; a
branch without a PR gets no automatic CI or image, so open a draft PR to get
one. Pull requests run on opening, reopening, synchronization, and title/body
edits. Manual dispatch runs validation without publishing images. There is no
schedule or deployment job in the preserved configuration; the Compose files
remain manual deployment options.
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -27,18 +30,19 @@ configuration; the Compose files remain manual deployment options.
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
The benchmark runs on both push and pull_request events: the push run gates image
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
The relative gate absorbs a slower runner and load that lasts through the whole
job, because calibration runs in the same job. It calibrates before and after the
protocol and judges against the slower value, so it also absorbs load that starts
during the protocol, such as an overlapping push and pull_request run of the same
commit. It does not absorb load that ends before the second calibration; the
240-second hang guard still bounds those runs.
The benchmark gates image publication (`images.needs` includes `benchmark`) and
the `required` aggregate. The relative gate absorbs a slower runner and load
that lasts through the whole job, because calibration runs in the same job. It
calibrates before and after the protocol and judges against the slower value,
so it also absorbs load that starts during the protocol, such as another run
sharing the runner. It does not absorb load that ends before the second
calibration; the 240-second hang guard still bounds those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
branch slug; stable promotions share one group and are not cancelled in progress.
Because branch pushes outside `develop` and `main` do not trigger CI, a commit
on a PR branch runs only on PR events, which carry the PR title and rebase
checks. New runs cancel older runs for the same event/ref. Image jobs serialize
per branch slug; stable promotions share one group and are not cancelled in
progress.
## Runners and permissions
@@ -112,10 +116,10 @@ only one place fails CI instead of drifting.
## Images and releases
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
at `gitea.nessar.fr/nessar/vac-optimizer`. Branch pushes and same-repository PRs
publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs retain the previous
lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
branch slugs.
at `gitea.nessar.fr/nessar/vac-optimizer`. Pushes to `develop` and `main` and
same-repository PRs publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs
retain the previous lowercase, non-alphanumeric-to-hyphen, 63-byte
normalization; choose distinct branch slugs.
Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
and reachable from `main` may promote images. Promotion first pulls the existing
@@ -148,7 +152,7 @@ secrets. Complete the remaining activation checks in this order:
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
2. Publish the reviewed feature branch and open its PR to register the workflow.
Once registered, manual dispatch verifies validation and artifacts without
publishing images. Push and PR validation can also run now; their image and
publishing images. PR validation can also run now; its image and
aggregate checks remain blocked until publisher secrets are provisioned.
3. Verify runner isolation and Docker service support, then provision the two
publisher secrets. Review the package publisher's permissions and registry
-22
View File
@@ -82,28 +82,6 @@ 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. 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
Every request, manual or initial, captures `performance.now()` inside
@@ -1935,186 +1935,6 @@ 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])(
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => {
@@ -10,21 +10,11 @@
<mat-spinner
[diameter]="72"
[mode]="'determinate'"
[value]="wait()?.percent ?? 0"
[value]="loadingProgress()"
></mat-spinner>
<div class="spinner-text">{{ wait()?.percent ?? 0 }}%</div>
<div class="spinner-text">{{ loadingProgress() }}%</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,15 +510,6 @@ 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,26 +96,7 @@ export class CalendarYear {
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
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 loadingProgress = this.calculations.progress;
protected readonly planningOutcome = this.calculations.outcome;
protected readonly planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError;
@@ -338,10 +319,6 @@ 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,23 +119,18 @@ describe('PlanningCalculationService', () => {
expect(submission.labels).toBeDefined();
});
it('reports the stage and submission of the latest calculation only, until Cancel', () => {
it('reports progress of the latest calculation only', () => {
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.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 });
expect(service.progress()).toBe(0);
latestProgress('at-risk');
expect(service.progress()).toBe(35);
latestProgress('unknown');
expect(service.progress()).toBe(35);
});
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
@@ -8,7 +8,6 @@ import type {
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
@@ -17,14 +16,10 @@ import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
export interface PendingCalculation {
interface PendingCalculation {
id: number;
horizon: Horizon;
/** 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;
progress: number;
cancelling: boolean;
}
@@ -39,6 +34,22 @@ 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',
@@ -53,7 +64,7 @@ function explanationFailure(error: unknown): PlanningReason {
};
}
/** Single owner of planning calculations: submission, planning stages, cancellation and the accepted result. */
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
@@ -96,7 +107,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 pending = this.pendingState.asReadonly();
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
@@ -120,9 +131,7 @@ export class PlanningCalculationService {
this.pendingState.set({
id: version,
horizon: request.horizon,
request,
requestedAt,
stage: 'preparation',
progress: 0,
cancelling: false,
});
let calculation: PlanningCalculationContext;
@@ -138,8 +147,12 @@ export class PlanningCalculationService {
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update((pending) =>
pending && !pending.cancelling ? { ...pending, stage } : pending,
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
);
},
});
@@ -1,10 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat';
import { CalendarSettingsService } from './calendar-settings-service';
import { CalendarService, SelectableDayType } from './calendar-service';
import { SelectableDayType } from './calendar-service';
import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation, type PlanningOutcome } from './leave-planner.service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
@@ -146,157 +145,3 @@ 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: 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();
});
});
+13 -80
View File
@@ -1,4 +1,4 @@
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { computed, 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,37 +9,6 @@ export interface PlanningDurationRange {
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 {
const key =
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
@@ -85,65 +54,29 @@ 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();
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>(() => {
const request = this.calculations.request();
if (!request) return null;
return durationRange(this.expectedMs(request));
});
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;
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 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));
return {
percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
overrun: elapsed >= expected.overrunMs,
minimum,
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / divisor)),
unit,
};
});
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,12 +243,6 @@ 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,18 +200,6 @@
"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,18 +200,6 @@
"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,18 +200,6 @@
"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,18 +200,6 @@
"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.",