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>
449 lines
11 KiB
TypeScript
449 lines
11 KiB
TypeScript
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;
|
|
bookedDays: {
|
|
enter: string;
|
|
booked: string;
|
|
help: string;
|
|
fixed: string;
|
|
removed: string;
|
|
erase: string;
|
|
rangeSaved: string;
|
|
rangeRemoved: string;
|
|
};
|
|
app: {
|
|
title: string;
|
|
description: string;
|
|
skipToPlan: string;
|
|
};
|
|
userInput: {
|
|
title: string;
|
|
description: string;
|
|
addVacationDay: string;
|
|
calculate: string;
|
|
durationEstimate: string;
|
|
durationUnits: { seconds: string; minutes: string };
|
|
remainingDaysWarning: string;
|
|
remainingValidity: string;
|
|
remainingHorizon: string;
|
|
remainingEarnedFrom: string;
|
|
remainingSuggestions: Record<string, string>;
|
|
deleteEntry: string;
|
|
chooseDate: string;
|
|
availableDate: string;
|
|
days: string;
|
|
type: string;
|
|
daySuffix: string;
|
|
daysSuffix: string;
|
|
expires: string;
|
|
};
|
|
dayTypes: {
|
|
weekend: string;
|
|
closedDay: string;
|
|
rtt: string;
|
|
cp: string;
|
|
other: string;
|
|
today: string;
|
|
any: string;
|
|
};
|
|
settings: {
|
|
title: string;
|
|
description: string;
|
|
showWeekNumbers: string;
|
|
close: string;
|
|
selectLanguage: string;
|
|
selectTheme: string;
|
|
themeOptions: {
|
|
system: string;
|
|
light: string;
|
|
dark: string;
|
|
};
|
|
systemAppearance: string;
|
|
planningHorizon: string;
|
|
planningStart: string;
|
|
lastPlanningDate: string;
|
|
planningDatesHelp: string;
|
|
invalidDate: string;
|
|
horizonOrder: string;
|
|
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;
|
|
moveRuleUp: string;
|
|
moveRuleDown: string;
|
|
dragRule: string;
|
|
ruleMoved: string;
|
|
priorityInfo: string;
|
|
ifThenTemplate: string;
|
|
timeWindowTemplate: string;
|
|
editRule: string;
|
|
deleteRule: string;
|
|
addRule: string;
|
|
place: string;
|
|
ruleName: string;
|
|
holidayFalls: string;
|
|
days: string;
|
|
type: string;
|
|
position: string;
|
|
getBonus: string;
|
|
force: string;
|
|
obligation: string;
|
|
preference: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
cancel: string;
|
|
save: string;
|
|
add: string;
|
|
addRuleButton: string;
|
|
newIfThenRule: string;
|
|
newTimeWindowRule: string;
|
|
defaultNewRuleName: string;
|
|
defaultNewPeriodName: string;
|
|
daysOfWeek: {
|
|
1: string;
|
|
2: string;
|
|
3: string;
|
|
4: string;
|
|
5: string;
|
|
6: string;
|
|
0: string;
|
|
};
|
|
placement: {
|
|
before: string;
|
|
after: string;
|
|
around: string;
|
|
auto: string;
|
|
};
|
|
};
|
|
planning: {
|
|
statistics: {
|
|
open: string;
|
|
title: string;
|
|
leaveDays: string;
|
|
averageWorkSequence: string;
|
|
averageTimeOffPeriod: string;
|
|
longestTimeOffPeriod: string;
|
|
weekendConnectedHolidays: string;
|
|
earnedLeave: string;
|
|
earnedLeaveByRule: string;
|
|
leaveDaysUnit: string;
|
|
workingDays: string;
|
|
calendarDays: string;
|
|
partialPlan: string;
|
|
help: string;
|
|
};
|
|
calculationDate: string;
|
|
futureEntitlement: string;
|
|
includedEntitlement: string;
|
|
comparison: {
|
|
title: string;
|
|
preview: string;
|
|
nearby: string;
|
|
noAlternatives: string;
|
|
chooseDate: string;
|
|
dateRange: string;
|
|
invalidDate: string;
|
|
changes: string;
|
|
available: string;
|
|
unavailable: string;
|
|
backToPlan: string;
|
|
};
|
|
details: {
|
|
intro: string;
|
|
balance: string;
|
|
available: string;
|
|
expires: string;
|
|
criteria: string;
|
|
criteriaHelp: string;
|
|
firstDifference: string;
|
|
higher: string;
|
|
lower: string;
|
|
tieBreaks: string;
|
|
audit: string;
|
|
auditHelp: string;
|
|
supporting: string;
|
|
remainingReasons: Record<string, string>;
|
|
};
|
|
explanation: {
|
|
breakTitle: string;
|
|
leaveDays: string;
|
|
freeDays: string;
|
|
daysOff: string;
|
|
wholeBreak: string;
|
|
leaveUsed: string;
|
|
expires: string;
|
|
linkedRules: string;
|
|
atRiskContribution: string;
|
|
impact: string;
|
|
impactHelp: string;
|
|
criterion: string;
|
|
withoutDay: string;
|
|
withDay: string;
|
|
noChange: string;
|
|
priorityHelp: string;
|
|
tryDate: string;
|
|
plannedDate: string;
|
|
currentPlan: string;
|
|
alternative: string;
|
|
sameDate: string;
|
|
moveHelp: string;
|
|
comparisonUnavailable: string;
|
|
objectives: Record<string, { label: string; help: string }>;
|
|
preferenceHelp: Record<string, string | undefined>;
|
|
stages: Record<string, string>;
|
|
unavailable: Record<string, string | undefined>;
|
|
};
|
|
title: string;
|
|
calendarHelp: string;
|
|
explanationHelp: string;
|
|
technicalDetails: string;
|
|
compareDates: string;
|
|
stopComparing: string;
|
|
daysPlaced: string;
|
|
daysGained: string;
|
|
longestWorkSequence: string;
|
|
legend: string;
|
|
invalidInput: string;
|
|
conflict: string;
|
|
incomplete: string;
|
|
cancel: string;
|
|
cancelling: string;
|
|
progress: {
|
|
stages: Record<string, string>;
|
|
rule: string;
|
|
remaining: string;
|
|
overrun: string;
|
|
};
|
|
cancelled: string;
|
|
cancelledPartial: string;
|
|
error: string;
|
|
complete: string;
|
|
whyPlacement: string;
|
|
placementNavigation: string;
|
|
placement: string;
|
|
placements: string;
|
|
of: string;
|
|
first: string;
|
|
previous: string;
|
|
next: string;
|
|
last: string;
|
|
remaining: string;
|
|
balanceRemaining: string;
|
|
counterfactual: string;
|
|
counterfactualLoading: string;
|
|
counterfactualReady: string;
|
|
counterfactualError: string;
|
|
retryCounterfactual: string;
|
|
eligible: string;
|
|
ineligible: string;
|
|
reason: string;
|
|
explanationError: string;
|
|
conflictDetails: string;
|
|
conflictingRules: string;
|
|
bottleneck: string;
|
|
incompleteDetails: string;
|
|
incumbentExplanation: string;
|
|
completedStages: string;
|
|
uncompletedStages: string;
|
|
none: string;
|
|
currentPlacement: string;
|
|
explainedPeriod: string;
|
|
outsideHorizon: string;
|
|
};
|
|
benchmark: {
|
|
title: string;
|
|
instructions: string;
|
|
run: string;
|
|
running: string;
|
|
copy: string;
|
|
};
|
|
loading: string;
|
|
weekdaysShort: string[];
|
|
}
|
|
|
|
import { translationsMap, availableLanguagesList } from '../i18n-registry';
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class TranslationService {
|
|
private readonly injector = inject(Injector);
|
|
|
|
public readonly availableLanguages = availableLanguagesList;
|
|
|
|
// Detect browser language, fallback to the first available language
|
|
private getBrowserLanguage(): string {
|
|
if (typeof navigator !== 'undefined' && navigator.language) {
|
|
const primary = navigator.language.split('-')[0].toLowerCase();
|
|
if (this.availableLanguages.includes(primary)) {
|
|
return primary;
|
|
}
|
|
}
|
|
return this.availableLanguages[0] || '';
|
|
}
|
|
|
|
public readonly language = storedSignal<string>(
|
|
'app.language',
|
|
this.getBrowserLanguage(),
|
|
this.injector,
|
|
0,
|
|
);
|
|
|
|
public readonly tr = computed<AppTranslations>(() => {
|
|
const currentLang = this.language();
|
|
const fallbackLang = this.availableLanguages[0] || '';
|
|
return (
|
|
(translationsMap[currentLang] as AppTranslations) ||
|
|
(translationsMap[fallbackLang] as AppTranslations) ||
|
|
({} as AppTranslations)
|
|
);
|
|
});
|
|
|
|
public readonly locale = computed<string>(() => {
|
|
const tr = this.tr();
|
|
if (tr && tr.locale) {
|
|
return tr.locale;
|
|
}
|
|
const lang = this.language();
|
|
if (lang) {
|
|
return `${lang}-${lang.toUpperCase()}`;
|
|
}
|
|
const fallbackLang = this.availableLanguages[0];
|
|
if (fallbackLang) {
|
|
return `${fallbackLang}-${fallbackLang.toUpperCase()}`;
|
|
}
|
|
return '';
|
|
});
|
|
|
|
public setLanguage(lang: string): void {
|
|
if (this.availableLanguages.includes(lang)) {
|
|
this.language.set(lang);
|
|
}
|
|
}
|
|
|
|
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
|
|
const displayNames = new Intl.DisplayNames([langCode], { type: 'language' });
|
|
const name = displayNames.of(langCode);
|
|
if (name) {
|
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
}
|
|
} catch {
|
|
// Fallback to uppercase language code
|
|
}
|
|
return langCode.toUpperCase();
|
|
}
|
|
|
|
public formatPlanningDate(date: string): string {
|
|
return new Date(`${date}T00:00:00Z`).toLocaleDateString(this.locale(), {
|
|
timeZone: 'UTC',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
public getRemainingBalanceReason(
|
|
balance: RemainingBalance,
|
|
plan: PlanOutcome | undefined,
|
|
incomplete = false,
|
|
): string {
|
|
const reasons = this.tr().planning.details.remainingReasons;
|
|
if (!plan) return reasons['not-established'];
|
|
const code =
|
|
incomplete &&
|
|
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
|
|
? 'calculation-incomplete'
|
|
: balance.reason.code;
|
|
return (reasons[code] ?? reasons['not-established'])
|
|
.replace('{available}', this.formatPlanningDate(balance.availableFrom))
|
|
.replace('{expiry}', this.formatPlanningDate(balance.expiryDate))
|
|
.replace('{start}', this.formatPlanningDate(plan.horizon.start))
|
|
.replace('{end}', this.formatPlanningDate(addPlanningDays(plan.horizon.end, -1)));
|
|
}
|
|
|
|
public getDayTypeLabel(type: string): string {
|
|
const tr = this.tr();
|
|
if (!tr || !tr.dayTypes) {
|
|
return type;
|
|
}
|
|
switch (type) {
|
|
case 'weekend':
|
|
return tr.dayTypes.weekend || type;
|
|
case 'closed-day':
|
|
return tr.dayTypes.closedDay || type;
|
|
case 'rtt':
|
|
return tr.dayTypes.rtt || type;
|
|
case 'cp':
|
|
return tr.dayTypes.cp || type;
|
|
case 'other':
|
|
return tr.dayTypes.other || type;
|
|
case 'today':
|
|
return tr.dayTypes.today || type;
|
|
case 'any':
|
|
return tr.dayTypes.any || type;
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
}
|