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>
443 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|