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; 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; conditions: Record; measures: Record; aggregations: Record; directions: Record; }; 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; }; 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; preferenceHelp: Record; stages: Record; unavailable: Record; }; 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; 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( 'app.language', this.getBrowserLanguage(), this.injector, 0, ); public readonly tr = computed(() => { const currentLang = this.language(); const fallbackLang = this.availableLanguages[0] || ''; return ( (translationsMap[currentLang] as AppTranslations) || (translationsMap[fallbackLang] as AppTranslations) || ({} as AppTranslations) ); }); public readonly locale = computed(() => { 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; } } }