textmachine/frontend/src/showcase/format.ts

120 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ReturnType<typeof build>>();
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<Intl.LDMLPluralRule, MessageKey>): 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<typeof noteSeverity.describe>[0]) =>
noteSeverity.describe(severity).tone;
export const statusOf = (status: Parameters<typeof bookStatus.describe>[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)) });
}