import { bookStatus, noteSeverity, type BookStatus, type Chapter, type Progress } from '../api'; import { currentLocale } from '../i18n/language'; import { text, type MessageKey } from '../i18n/text'; // Language arrives as a code, never as a word: the second pair of the product has to work without // an edit to the markup (the canon's review question, CLAUDE.md §2). Names, numbers and dates are // computed for the language of the INTERFACE, which is a choice and therefore not a constant here — // one `Intl` set per locale, built once and kept. const sets = new Map>(); const build = (locale: string) => ({ languages: new Intl.DisplayNames([locale], { type: 'language' }), numbers: new Intl.NumberFormat(locale), plurals: new Intl.PluralRules(locale), // The unit is formatted by Intl and not by us: "MB" is a word, and a word written in the code is // a word no translation file can reach. One formatter per magnitude, because the unit is part of // the formatter rather than of the value. bytes: new Map( (['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const).map((unit) => [ unit, new Intl.NumberFormat(locale, { style: 'unit', unit, maximumFractionDigits: 1 }), ]), ), }); function intl() { const locale = currentLocale(); let set = sets.get(locale); if (set === undefined) { set = build(locale); sets.set(locale, set); } return set; } /** A date is a format of the interface, not of the data: the contract's string is always ISO. */ export const date = (value: string) => new Date(value).toLocaleDateString(currentLocale()); export const languageName = (code: string) => intl().languages.of(code) ?? code; export const number = (value: number) => intl().numbers.format(value); /** * A size of a file, in the unit that suits it. Decimal steps of a thousand and not 1024: that is * what the operating system's file manager shows the same file as, and a size the interface states * differently from the folder the file was picked from reads as a defect. */ export function fileSize(bytes: number): string { const bare = { unit: 'byte', factor: 1 } as const; const steps = [ { unit: 'gigabyte', factor: 1e9 }, { unit: 'megabyte', factor: 1e6 }, { unit: 'kilobyte', factor: 1e3 }, bare, ] as const; const step = steps.find(({ factor }) => bytes >= factor) ?? bare; return ( intl() .bytes.get(step.unit) ?.format(bytes / step.factor) ?? String(bytes) ); } /** * A number with the word it counts: "1 term", "2 terms". The caller names the forms — as catalogue * keys, so the words stay in the catalogue — and `Intl` picks the category; no table of endings is * written by hand. */ export function counted(value: number, forms: Record): string { return `${number(value)} ${text(forms[intl().plurals.select(value)])}`; } /** * Severity of a note → look of the callout. The map itself lives on the `src/api/` seam together * with the vocabulary, so a new step is added in one file; this is only the call. */ export const noteTone = (severity: Parameters[0]) => noteSeverity.describe(severity).tone; export const statusOf = (status: Parameters[0]) => bookStatus.describe(status); /** * A draft until the run has brought the book to `ready`: both the translation and the chunking are * legally rebuilt. An unknown state is a draft too (the safe side); no book means no mark. */ export const isDraft = (status: BookStatus | null | undefined) => status !== undefined && status !== 'ready'; /** * What a chapter is called in the tree. * * The label comes from the DATA of the book, and a template such as "Chapter {n}" is never * synthesized here: no such form exists, and a book legally has neither headings nor numbers * (D39.100 K-3). What is left when the book gives neither is the position in reading order, which * the contract does guarantee — a bare ordinal, not an invented word. Which word, if any, an * unnamed chapter should carry is the owner's question В-4. */ export function chapterLabel(chapter: Chapter, position: number): string { if (chapter.heading !== null && chapter.heading !== '') return chapter.heading; return String(chapter.number ?? position); } /** * Progress as ONE number with no phase names: how the pipeline is built is not the user's business * (prompt §4.1). Counted over both waves at once, because a per-phase fraction would sit at zero * for the whole draft wave — the very trap that makes the DATA phased. */ export const translatedPercent = (progress: Progress) => { const total = progress.draft.total + progress.edit.total; return total === 0 ? 0 : Math.round(((progress.draft.done + progress.edit.done) / total) * 100); }; /** Rough remaining time, in whole minutes or hours. Absent whenever there is nothing to estimate. */ export function remaining(seconds: number | null | undefined): string | null { if (seconds === null || seconds === undefined) return null; const minutes = Math.round(seconds / 60); if (minutes < 1) return text('time.underMinute'); if (minutes < 60) return text('time.minutes', { count: number(minutes) }); return text('time.hours', { count: number(Math.round(minutes / 60)) }); }