Files
vac-optimizer/src/app/services/translation-service.ts
T
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
Replace Vitest with bun test across the whole suite. A preload compiles
components through Angular's JIT transform with inlined templates, sets
up jsdom, TestBed and zoneless change detection, and a small shim covers
the Vitest APIs bun:test lacks. Files run isolated, real-worker specs
spawn the running Bun, and line coverage from lcov is converted to the
existing HTML and Cobertura reports. The CI benchmark replaces its heap
cap with a container memory limit. Vitest, Jest, Nx and their Vite
configuration are removed.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:42 +02:00

443 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;
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;
}
}
}