gestion_table_tournante_libre/src/application/format.js
Mathieu Benoit 71076afe4d [ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to
format and name, or each component grows its own. horloge.js is the only
code that reads time and arms timers, injected so tests drive it.
traduction.js keys texts by domain, in two families of tables, raises on a
missing key and never shows a raw key; libelles/ splits the label table by
domain. format.js formats numbers and dates in French, and personnes.js
composes the displayed name the frozen labels use. Nothing persists
between sessions.
Checked: 1773 node and 43 browser tests.

--- FR ---

[ADD] application : horloge, traduction, mise en forme, nom affiché

Les écrans exigent une horloge, un mécanisme de traduction et une façon de
mettre en forme et de nommer, sans quoi chaque composant ferait les siens.
horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté
pour que les épreuves le pilotent. traduction.js range les textes par
domaine, en deux familles de tables, lève sur une clé absente et ne montre
jamais une clé brute ; libelles/ découpe la table des libellés par domaine.
format.js met en forme nombres et dates en français, et personnes.js
compose le nom affiché qu'emploient les libellés figés. Rien ne persiste
d'une séance à l'autre.
Vérifié : 1773 node et 43 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 02:33:39 -04:00

149 lines
6.6 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La mise en forme des nombres, des durées, des dates et des horodatages
// (§ 5.4, § 5.10, § 11.7, § 18.4). Aucune fonction ne lit la locale du
// système : les chiffres se groupent par trois, séparés d'une espace fine
// insécable (U+202F), la décimale suit une virgule, un négatif porte le signe
// moins (U+2212). Tout texte rendu qui porte une lettre — unité, mois, ordre
// de grandeur — vient d'une clé format.… par t, en un seul appel : en langue
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
// aucune lettre, n'y passe pas.
const ESPACE_FINE = '\u{202F}';
const MOINS = '\u{2212}';
// L'échelle des ordres de grandeur d'une durée (§ 5.10) : chaque pas en
// secondes et sa clé. Sous le premier seuil, moins d'une minute ; dès le
// dernier, plusieurs heures ; entre les deux, le pas le plus proche en
// rapport : la frontière de deux pas voisins a et b est √(a·b), comparée
// exactement, s² contre a·b.
const MOINS_D_UNE_MINUTE = 60;
const PLUSIEURS_HEURES = 5400;
const PAS = [
[120, 'format.duree.deuxMinutes'],
[300, 'format.duree.cinqMinutes'],
[600, 'format.duree.dixMinutes'],
[1200, 'format.duree.vingtMinutes'],
[1800, 'format.duree.demiHeure'],
[3600, 'format.duree.uneHeure'],
];
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/;
// Le nombre de jours d'un mois du calendrier grégorien.
function joursDuMois(annee, mois) {
if (mois === 2) return annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0) ? 29 : 28;
return [4, 6, 9, 11].includes(mois) ? 30 : 31;
}
// Année, mois et jour d'une date AAAA-MM-JJ du calendrier, ou RangeError.
function lireDate(annee, mois, jour, texte) {
const lue = { annee: Number(annee), mois: Number(mois), jour: Number(jour) };
if (lue.mois < 1 || lue.mois > 12 || lue.jour < 1 || lue.jour > joursDuMois(lue.annee, lue.mois)) {
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas un jour du calendrier`);
}
return lue;
}
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
function exigerFini(valeur, quoi) {
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
if (!Number.isFinite(valeur)) throw new RangeError(`format : ${quoi} non fini, ${valeur}`);
}
/**
* Les fonctions de mise en forme, dont chaque texte vient de t.
*
* @param {(cle: string, details?: Object) => string} t la traduction
* @returns {{
* nombre: (valeur: number|null, options?: {decimales?: number}) => string,
* duree: (ms: number) => string,
* ordreDeGrandeur: (secondes: number) => string,
* date: (date: string) => string,
* horodatage: (horodatage: string) => string,
* }}
*/
export function creerFormat(t) {
// Les chiffres d'un nombre fini, arrondi à decimales chiffres après la
// virgule, sans passer par la traduction.
function chiffres(valeur, decimales) {
if (!Number.isInteger(decimales) || decimales < 0 || decimales > 20) {
throw new RangeError(`format : décimales hors de 0 à 20, ${decimales}`);
}
const ecrit = Math.abs(valeur).toFixed(decimales);
if (ecrit.includes('e')) throw new RangeError(`format : ${valeur} dépasse ce qui s'écrit en chiffres`);
const [entiere, fraction] = ecrit.split('.');
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ESPACE_FINE);
const nul = /^[0.]*$/.test(ecrit);
const signe = valeur < 0 && !nul ? MOINS : '';
return `${signe}${groupee}${fraction === undefined ? '' : `,${fraction}`}`;
}
/** Un nombre ; null, une valeur absente, s'écrit format.vide, jamais 0. */
function nombre(valeur, { decimales = 0 } = {}) {
if (valeur === null) return t('format.vide');
exigerFini(valeur, 'nombre');
return chiffres(valeur, decimales);
}
/**
* Une durée en millisecondes : sous la minute, au dixième de seconde, un
* compte rond sans décimale ; sous l'heure, minutes et secondes entières ;
* au-delà, heures et minutes entières. Un arrondi qui atteint l'unité
* suivante s'écrit dans elle.
*/
function duree(ms) {
exigerFini(ms, 'durée');
if (ms < 0) throw new RangeError(`format : durée négative, ${ms}`);
const dixiemes = Math.round(ms / 100);
if (dixiemes < 600) {
const secondes = dixiemes % 10 === 0 ? chiffres(dixiemes / 10, 0) : chiffres(dixiemes / 10, 1);
return t('format.duree.secondes', { secondes });
}
const secondes = Math.round(ms / 1000);
if (secondes < 3600) {
return t('format.duree.minutes', { minutes: Math.floor(secondes / 60), secondes: secondes % 60 });
}
const minutes = Math.round(ms / 60_000);
return t('format.duree.heures', { heures: chiffres(Math.floor(minutes / 60), 0), minutes: minutes % 60 });
}
/** L'ordre de grandeur d'une durée en secondes (§ 5.10). */
function ordreDeGrandeur(secondes) {
exigerFini(secondes, 'durée');
if (secondes < 0) throw new RangeError(`format : durée négative, ${secondes}`);
if (secondes < MOINS_D_UNE_MINUTE) return t('format.duree.moinsDUneMinute');
if (secondes >= PLUSIEURS_HEURES) return t('format.duree.plusieursHeures');
const carre = secondes * secondes;
let rang = 0;
while (rang + 1 < PAS.length && carre >= PAS[rang][0] * PAS[rang + 1][0]) rang += 1;
return t(PAS[rang][1]);
}
/** Une date AAAA-MM-JJ en lettres, « 1er » pour le premier du mois. */
function date(texte) {
const morceaux = DATE.exec(String(texte));
if (morceaux === null) throw new RangeError(`format : date AAAA-MM-JJ attendue, reçu ${JSON.stringify(texte)}`);
return t('format.date', lireDate(...morceaux.slice(1), texte));
}
/**
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en lettres, à l'heure locale
* qu'il porte : la date, puis l'heure et la minute.
*/
function horodatage(texte) {
const morceaux = HORODATAGE.exec(String(texte));
if (morceaux === null) {
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
}
const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number);
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
}
return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute });
}
return { nombre, duree, ordreDeGrandeur, date, horodatage };
}