feat(planning): make optimization rules composable

Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
nessar committed 2026-10-10 16:07:34 +02:00
1 parent 209bf05f36
commit 6811142299
45 files changed
+8795 -3075

No files matched your search

+54 -1
View File
@@ -3,6 +3,16 @@ import { Injectable, inject, Injector, computed } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
import { addPlanningDays } from './planning-date';
import {
optimizationUnit,
validateOptimizationConfiguration,
type OptimizationConfiguration,
type OptimizationElements,
type OptimizationCondition,
type OptimizationMeasure,
type OptimizationAggregation,
type OptimizationDirection,
} from './planning-objectives';
export interface AppTranslations {
locale: string;
@@ -77,6 +87,25 @@ export interface AppTranslations {
invalidHorizon: string;
};
rules: {
optimization: {
newRule: string;
defaultName: string;
elementsLabel: string;
conditionsLabel: string;
measureLabel: string;
aggregationLabel: string;
directionLabel: string;
thresholdLabel: string;
thresholdSummary: string;
summary: string;
invalidConfiguration: string;
nameRequired: string;
elements: Record<OptimizationElements, string>;
conditions: Record<OptimizationCondition, string>;
measures: Record<OptimizationMeasure, string>;
aggregations: Record<OptimizationAggregation, string>;
directions: Record<OptimizationDirection, string>;
};
title: string;
subtitle: string;
active: string;
@@ -390,6 +419,29 @@ export class TranslationService {
}
}
public optimizationSummary(configuration: OptimizationConfiguration): string {
const tr = this.tr().rules.optimization;
if (!validateOptimizationConfiguration(configuration).valid) return tr.invalidConfiguration;
const unit = optimizationUnit(configuration);
const unitLabel =
unit === 'count'
? tr.aggregations.count
: this.tr().planning.statistics[
unit === 'working-days' ? 'workingDays' : 'calendarDays'
];
const conditions = configuration.conditions.map((condition) => tr.conditions[condition]);
if (configuration.measure === 'work-sequence-deficit')
conditions.push(
tr.thresholdSummary.replace('{count}', String(configuration.thresholdDays)),
);
return tr.summary
.replace('{direction}', tr.directions[configuration.direction])
.replace('{aggregation}', tr.aggregations[configuration.aggregation])
.replace('{measure}', `${tr.measures[configuration.measure]} (${unitLabel})`)
.replace('{elements}', tr.elements[configuration.elements])
.replace('{conditions}', conditions.length ? ` (${conditions.join(', ')})` : '');
}
public getLanguageName(langCode: string): string {
try {
// Using native Intl.DisplayNames to get name of language in its own language
@@ -421,7 +473,8 @@ export class TranslationService {
const reasons = this.tr().planning.details.remainingReasons;
if (!plan) return reasons['not-established'];
const code =
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
incomplete &&
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
? 'calculation-incomplete'
: balance.reason.code;
return (reasons[code] ?? reasons['not-established'])