/* RFC-1156: ISO 8601 duration parse/format helpers for the `duration` pipe formatter — Intl.DurationFormat primary backend, Intl.PluralRules-based Werktage/робочі дні business-day rendering, grammatical-case forms (nominative/accusative/genitive/dative) for de/uk prose bindings. Do not register pipe formatters here — registration lives in formula-eval.ts next to "money" (AC-1). Do not handle recurrence suffixes — formatRecurrence (RFC-0730) owns "/ Monat"-style output. Do not add locales beyond de/uk — other locales fall back to de word forms. Do not inflect articles or adjectives — only the duration unit noun declines (CLDR unit case data). RFC-1156: add duration.ts — parseIsoDuration, formatDuration (Intl.DurationFormat + comma/short-abbrev normalization), formatBusinessDays via Intl.PluralRules. RFC-1156: duration-case extension — `case=` param (nominative|accusative|genitive|dative) with vendored CLDR-grammar unit forms for de/uk — "nach 5 Werktagen", "bis zu 30 Tagen", "за 24 години". */ export interface DurationParts { years: number; months: number; weeks: number; days: number; hours: number; minutes: number; seconds: number; } // Full ISO 8601 duration, multi-component allowed (P1Y6M). Canonical order only. const ISO_DURATION_PATTERN = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; export function parseIsoDuration(value: unknown): DurationParts | null { if (typeof value !== "string") return null; const m = ISO_DURATION_PATTERN.exec(value.trim()); if (!m) return null; const [years, months, weeks, days, hours, minutes, seconds] = m .slice(1) .map((x) => (x === undefined ? 0 : Number(x))); const parts: DurationParts = { years: years!, months: months!, weeks: weeks!, days: days!, hours: hours!, minutes: minutes!, seconds: seconds!, }; // Reject bare "P"/"PT" and all-zero durations (P0D, PT0S) — no prose form. if (Object.values(parts).every((v) => v === 0)) return null; return parts; } export type DurationStyle = "long" | "short"; /** * Grammatical case of the rendered duration phrase (prose context like * "nach 5 Werktagen" / "за 24 години"). Applied only in `long` style — `short` * abbreviations (T., Std.) do not inflect. Cases without a dedicated row fall * back to `nominative` (de accusative/genitive plural of duration nouns equals * nominative; the table only lists forms that actually differ). */ export type DurationCase = "nominative" | "accusative" | "genitive" | "dative"; // Only de/uk are in scope (site languages). Any other locale falls back to de — // applied uniformly on the Intl path and the PluralRules fallback so both // backends render identically. const SUPPORTED_BASE_LOCALES = new Set(["de", "uk"]); export function resolveSupportedLocale(locale: string): string { const base = locale.split("-")[0] ?? ""; return SUPPORTED_BASE_LOCALES.has(base) ? base : "de"; } // RFC-1156 output table normalizations applied to Intl.DurationFormat output: // list separators and locale-specific abbreviations that diverge from the // documented contract rows (e.g. Intl renders de short days as "14 Tg.", the // contract is "14 T."). const SHORT_OVERRIDES: Record> = { de: [[/\bTg\./g, "T."]], }; export function formatDuration( parts: DurationParts, locale: string, style: DurationStyle, grammaticalCase: DurationCase = "nominative", ): string { const effective = resolveSupportedLocale(locale); // Intl.DurationFormat renders nominative only — any other case falls back to // the word-table backend whose per-case forms come from CLDR unit-grammar data. const input: Record = {}; for (const [key, v] of Object.entries(parts)) { if (v > 0) input[key] = v; } const DurationFormatCtor = ( Intl as unknown as { DurationFormat: new ( locales: string, options: { style: DurationStyle }, ) => { format(value: Record): string }; } ).DurationFormat; let out = grammaticalCase === "nominative" && DurationFormatCtor ? new DurationFormatCtor(effective, { style }).format(input) : formatDurationFallback(parts, effective, grammaticalCase, style); // "1 Jahr, 6 Monate" → "1 Jahr 6 Monate" (RFC-1156 output table, compound form). out = out.replace(/,\s*/g, " "); if (style === "short") { const base = effective; for (const [pattern, replacement] of SHORT_OVERRIDES[base] ?? []) { out = out.replace(pattern, replacement); } } return out; } // Word forms per plural category; optional per-case overrides vendored from // CLDR unit-grammar data (unicode.org/cldr/charts — de/uk duration units). // Missing case → nominative (de acc/gen plural equal nominative for these nouns). interface UnitForms { nominative: Partial>; accusative?: Partial>; genitive?: Partial>; dative?: Partial>; } const UNIT_WORDS: Record> = { years: { de: { nominative: { one: "Jahr", other: "Jahre" }, genitive: { one: "Jahres", other: "Jahre" }, dative: { one: "Jahr", other: "Jahren" }, }, uk: { nominative: { one: "рік", few: "роки", many: "років", other: "років" }, genitive: { one: "року", few: "років", many: "років", other: "року" }, dative: { one: "рокові", few: "рокам", many: "рокам", other: "року" }, }, }, months: { de: { nominative: { one: "Monat", other: "Monate" }, genitive: { one: "Monats", other: "Monate" }, dative: { one: "Monat", other: "Monaten" }, }, uk: { nominative: { one: "місяць", few: "місяці", many: "місяців", other: "місяців" }, genitive: { one: "місяця", few: "місяців", many: "місяців", other: "місяця" }, dative: { one: "місяцю", few: "місяцям", many: "місяцям", other: "місяцю" }, }, }, weeks: { de: { nominative: { one: "Woche", other: "Wochen" }, dative: { one: "Woche", other: "Wochen" }, }, uk: { nominative: { one: "тиждень", few: "тижні", many: "тижнів", other: "тижнів" }, genitive: { one: "тижня", few: "тижнів", many: "тижнів", other: "тижня" }, dative: { one: "тижню", few: "тижням", many: "тижням", other: "тижню" }, }, }, days: { de: { nominative: { one: "Tag", other: "Tage" }, genitive: { one: "Tages", other: "Tage" }, dative: { one: "Tag", other: "Tagen" }, }, uk: { nominative: { one: "день", few: "дні", many: "днів", other: "днів" }, genitive: { one: "дня", few: "днів", many: "днів", other: "дня" }, dative: { one: "дню", few: "дням", many: "дням", other: "дню" }, }, }, hours: { de: { nominative: { one: "Stunde", other: "Stunden" }, dative: { one: "Stunde", other: "Stunden" }, }, uk: { nominative: { one: "година", few: "години", many: "годин", other: "годин" }, accusative: { one: "годину", few: "години", many: "годин", other: "години" }, genitive: { one: "години", few: "годин", many: "годин", other: "години" }, dative: { one: "годині", few: "годинам", many: "годинам", other: "годині" }, }, }, minutes: { de: { nominative: { one: "Minute", other: "Minuten" }, dative: { one: "Minute", other: "Minuten" }, }, uk: { nominative: { one: "хвилина", few: "хвилини", many: "хвилин", other: "хвилин" }, accusative: { one: "хвилину", few: "хвилини", many: "хвилин", other: "хвилини" }, genitive: { one: "хвилини", few: "хвилин", many: "хвилин", other: "хвилини" }, dative: { one: "хвилині", few: "хвилинам", many: "хвилинам", other: "хвилині" }, }, }, seconds: { de: { nominative: { one: "Sekunde", other: "Sekunden" }, dative: { one: "Sekunde", other: "Sekunden" }, }, uk: { nominative: { one: "секунда", few: "секунди", many: "секунд", other: "секунд" }, accusative: { one: "секунду", few: "секунди", many: "секунд", other: "секунди" }, genitive: { one: "секунди", few: "секунд", many: "секунд", other: "секунди" }, dative: { one: "секунді", few: "секундам", many: "секундам", other: "секунді" }, }, }, }; const SHORT_WORDS: Record> = { years: { de: "J.", uk: "р." }, months: { de: "Mon.", uk: "міс." }, weeks: { de: "Wo.", uk: "тиж." }, days: { de: "T.", uk: "дн." }, hours: { de: "Std.", uk: "год." }, minutes: { de: "Min.", uk: "хв." }, seconds: { de: "Sek.", uk: "с." }, }; // Contingency backend when Intl.DurationFormat is unavailable (or a non-nominative // case is requested): per-locale unit words + Intl.PluralRules (never hand-rolled // modulo — RFC-1156 risks section). function formatDurationFallback( parts: DurationParts, locale: string, grammaticalCase: DurationCase = "nominative", style: DurationStyle = "long", ): string { const base = locale.split("-")[0] ?? "de"; const pluralRules = new Intl.PluralRules(locale); const segments: string[] = []; for (const [key, v] of Object.entries(parts) as Array<[keyof DurationParts, number]>) { if (v <= 0) continue; if (style === "short") { // Abbreviations do not inflect — grammatical case is a no-op in short style. segments.push(`${v} ${SHORT_WORDS[key][base] ?? SHORT_WORDS[key]["de"]!}`); continue; } const forms = UNIT_WORDS[key][base] ?? UNIT_WORDS[key]["de"]!; const words = forms[grammaticalCase] ?? forms["nominative"]; segments.push(`${v} ${words[pluralRules.select(v)] ?? words["other"]}`); } return segments.join(" "); } // dayType=business: business-day word forms per locale (Werktage semantics), // with the same per-case structure as UNIT_WORDS. const BUSINESS_DAY_WORDS: Record = { de: { nominative: { one: "Werktag", other: "Werktage" }, genitive: { one: "Werktags", other: "Werktage" }, dative: { one: "Werktag", other: "Werktagen" }, }, uk: { nominative: { one: "робочий день", few: "робочі дні", many: "робочих днів", other: "робочих днів", }, genitive: { one: "робочого дня", few: "робочих днів", many: "робочих днів", other: "робочого дня", }, dative: { one: "робочому дню", few: "робочим дням", many: "робочим дням", other: "робочому дню", }, }, }; export function formatBusinessDays( days: number, locale: string, grammaticalCase: DurationCase = "nominative", ): string { const base = resolveSupportedLocale(locale); const forms = BUSINESS_DAY_WORDS[base] ?? BUSINESS_DAY_WORDS["de"]!; const words = forms[grammaticalCase] ?? forms["nominative"]; const category = new Intl.PluralRules(base).select(days); return `${days} ${words[category] ?? words["other"]}`; }