Commit 2637ff27 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

EX-21 annonçait cent pour cent d'un dictionnaire, pas d'une interface

« 248 clés sur 248 traduites (100 %) ». Le chiffre était exact et se lisait à
l'envers : il mesurait le dictionnaire — toutes les chaînes confiées à « t() »
ont bien leur version anglaise — et ne disait rien de celles qu'on ne lui avait
jamais confiées. Elles étaient la grande majorité. EX-21 demande une interface
en français et en anglais dès la première version : c'est donc le texte visible
qu'il faut compter, enveloppé ou non.

Compté : 1 465 chaînes visibles, 248 traduites. Dix-sept pour cent, et tout le
cockpit de crise en français. J'avais d'abord annoncé trente-sept pour cent —
mon extracteur s'arrêtait aux fins de ligne et manquait la prose multi-lignes,
qui est justement l'essentiel du texte de ce produit.

── Un cliquet plutôt qu'un seuil

La suite ne peut pas rester rouge en permanence sans cesser d'être un signal ;
elle ne doit pas non plus laisser la couverture reculer. Le plancher se relève à
chaque lot et ne se baisse jamais. Tant qu'il n'atteint pas cent, EX-21 n'est
pas tenue, et le chiffre le dit à chaque exécution.

── Premier lot : la coque de crise

Barre d'état, rail, recherche, rembobinage, panneau de visioconférence,
consignation, conversations flottantes, tuiles du cockpit, historique. Soixante-
six chaînes, et la couverture passe à vingt-et-un pour cent.

Trois choses apprises en le faisant, et inscrites dans l'outillage :

  — «   » est une entité HTML. Déplacée telle quelle dans une chaîne
    JavaScript, elle s'afficherait littéralement. C'est l'espace insécable réel
    qui est posé, et les clés du dictionnaire le portent.
  — « t » était déjà une variable dans vingt-deux fichiers — un paramètre de
    « map », un « const t = Math.abs(…) ». Les quatre de ce lot sont renommés
    d'après ce qu'ils contiennent.
  — Une longue phrase est coupée en littéraux concaténés pour tenir dans la
    largeur du fichier. Le compteur sait désormais les recoller : sans cela il
    n'aurait vu que le premier morceau et aurait cru la clé absente.

── Sur la voix anglaise

Les 248 entrées d'origine suivent le registre du français, tranché et imagé,
comme l'annonce l'en-tête du dictionnaire. Les 66 nouvelles sont plus neutres,
sur demande : cette moitié s'adresse d'abord à des lecteurs non francophones
d'un groupe international. Les deux registres cohabitent pour l'instant ;
l'harmonisation viendra quand l'ensemble sera traduit, faute de quoi on
retoucherait deux fois les mêmes lignes.

Reste 1 160 chaînes, environ 9 000 mots, sur soixante fichiers. Lots suivants :
les écrans du temps de crise, puis le dossier de préparation, puis le reste.

Co-Authored-By: Claude (RCA)
parent f7ce428c
Loading
Loading
Loading
Loading
Loading
+50 −3
Changes for apps/api/src/verification/interface.ts: 50 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -104,8 +104,11 @@ export async function verifierInterface(r: Rapport): Promise<void> {
    // des appels en exemple, qui ne sont pas des chaînes à traduire.
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/\bt\(\s*"((?:[^"\\]|\\.)*)"/g)) {
      employees.add(m[1]!.replace(/\\"/g, '"'));
    // Une longue phrase est coupée en littéraux concaténés pour tenir dans la
    // largeur du fichier : la clé est leur recollement, pas le premier morceau.
    for (const m of texte.matchAll(/\bt\(\s*((?:"(?:[^"\\]|\\.)*"\s*\+?\s*)+)/g)) {
      const morceaux = [...m[1]!.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1]!);
      employees.add(morceaux.join("").replace(/\\"/g, '"'));
    }
  }
  const dictionnaire = await readFile(join(web, "langues/en.ts"), "utf8");
@@ -115,10 +118,54 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  const part = employees.size === 0 ? 0
    : Math.round((traduites.length / employees.size) * 100);

  r.verifie("EX-21", `${traduites.length} clés sur ${employees.size} traduites (${part} %)`,
  r.verifie("EX-21", `${traduites.length} clés sur ${employees.size} employées sont traduites`
    + ` (${part} %)`,
    manquantes.length === 0,
    manquantes.slice(0, 4).map((m) => `« ${m.slice(0, 40)} »`).join(", "));

  // Ce chiffre-là ne disait pas ce qu'il paraissait dire.
  //
  // « 100 % des clés traduites » se lit comme « l'interface est bilingue ».
  // Il mesurait le dictionnaire : toutes les chaînes confiées à « t() » ont
  // bien leur version anglaise. Il ne disait rien des chaînes qu'on ne lui a
  // jamais confiées — et elles étaient la grande majorité. EX-21 demande une
  // interface en français et en anglais dès la première version : c'est donc
  // le texte visible qu'il faut compter, enveloppé ou non.
  //
  // Le dénominateur est extrait du source : nœuds de texte JSX et attributs
  // qui s'affichent ou s'annoncent — « placeholder », « aria-label », « title ».
  // Quatre chaînes techniques en sortent, qui ne se traduisent pas : un chemin
  // d'API, un numéro de licence, un sigle.
  const NON_TRADUISIBLE = /^(?:[/#][^\s]*|[A-Z0-9][A-Z0-9.\-+_]*|\d[\d\s.,%€–-]*|[^\wÀ-ÿ]+)$/;
  const nues = new Set<string>();
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/>([^<>{}]*[A-Za-zÀ-ÿ][^<>{}]*)</g)) {
      const brut = m[1]!.replace(/\s+/g, " ").trim();
      if (brut && !NON_TRADUISIBLE.test(brut)) nues.add(brut);
    }
    for (const m of texte.matchAll(
      /(?:placeholder|aria-label|title)="([^"{}]*[A-Za-zÀ-ÿ][^"{}]*)"/g)) {
      const brut = m[1]!.trim();
      if (brut && !NON_TRADUISIBLE.test(brut)) nues.add(brut);
    }
  }
  const visibles = employees.size + nues.size;
  const couverture = visibles === 0 ? 0 : Math.round((employees.size / visibles) * 100);

  // Un cliquet, et non un seuil arbitraire. La suite ne peut pas rester rouge
  // en permanence sans cesser d'être un signal ; elle ne doit pas non plus
  // laisser la couverture reculer. Ce plancher se relève au fil des lots — il
  // ne se baisse jamais. Tant qu'il n'atteint pas 100, EX-21 n'est pas tenue,
  // et le chiffre le dit à chaque exécution.
  const PLANCHER = 21;
  r.verifie("EX-21",
    `${employees.size} chaînes visibles sur ${visibles} passent par la traduction`
    + ` (${couverture} %, plancher ${PLANCHER} %)`,
    couverture >= PLANCHER,
    couverture < PLANCHER ? `la couverture a reculé de ${PLANCHER - couverture} points` : "");

  // Un texte non traduit s'affiche en français : c'est le repli voulu, jamais
  // un identifiant technique illisible sous pression.
  const noyau = await readFile(join(web, "langues/index.tsx"), "utf8");
+4 −2
Changes for apps/web/src/coque/ChoixPorteur.tsx: 4 added lines, 2 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api.js";
import { useT } from "../langues/index.js";

interface MembreCellule { nom: string; compte_id: string; moi: boolean }
interface Porteur {
@@ -46,6 +47,7 @@ export function ChoixPorteur({ criseId, surChoix, libelleInitial }: {
  /** Le nom déjà porté, quand on réaffecte une action qui en avait un. */
  libelleInitial?: string | undefined;
}) {
  const t = useT();
  // « c:<id> » un compte, « l:<nom> » un nom du dossier, « » le nom libre.
  const [valeur, definirValeur] = useState("");
  const [libre, definirLibre] = useState(libelleInitial ?? "");
@@ -76,7 +78,7 @@ export function ChoixPorteur({ criseId, surChoix, libelleInitial }: {
  return (
    <div className="ligne" style={{ gap: 8, flexWrap: "wrap" }}>
      <select value={valeur} onChange={(e) => choisir(e.target.value)}>
        <option value="">— nom libre —</option>
        <option value="">{t("— nom libre —")}</option>
        {(data?.cellules ?? []).map((c) => (
          c.membres.length > 0 ? (
            <optgroup key={c.id} label={c.libelle}>
@@ -101,7 +103,7 @@ export function ChoixPorteur({ criseId, surChoix, libelleInitial }: {

      {!valeur.startsWith("c:") && (
        <input value={libre} maxLength={160} style={{ flex: 1, minWidth: 200 }}
          placeholder="Prestataire, élu, service d’astreinte…"
          placeholder={t("Prestataire, élu, service d’astreinte…")}
          onChange={(e) => {
            definirLibre(e.target.value);
            surChoix({ responsableId: null, responsableLibelle: e.target.value.trim() || null });
+14 −9
Changes for apps/web/src/coque/Consigner.tsx: 14 added lines, 9 removed lines.
Original line number Diff line number Diff line
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { useT } from "../langues/index.js";

/**
 * Ce que l'on consigne à la main.
@@ -37,6 +38,7 @@ export function Consigner({
}: {
  criseId: string; celluleId: string | null; prefill: Prefill; fermer: () => void;
}) {
  const t = useT();
  const client = useQueryClient();
  // Un genre prérempli hors de cette liste partait quand même, sans qu'aucun
  // bouton ne s'allume : le panneau envoyait autre chose que ce qu'il montrait.
@@ -73,10 +75,10 @@ export function Consigner({
  }

  return (
    <div className="consigner" role="dialog" aria-label="Consigner en main courante">
    <div className="consigner" role="dialog" aria-label={t("Consigner en main courante")}>
      <div className="ligne" style={{ marginBottom: 10 }}>
        <span className="etiq">Consigner</span>
        <button className="discret petit pousse" onClick={fermer} aria-label="Fermer">✕</button>
        <span className="etiq">{t("Consigner")}</span>
        <button className="discret petit pousse" onClick={fermer} aria-label={t("Fermer")}>✕</button>
      </div>

      {erreur && <div className="erreur">{erreur}</div>}
@@ -97,23 +99,26 @@ export function Consigner({
        onKeyDown={(e) => {
          if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void envoyer(); }
        }}
        placeholder="Ce qui vient d’être constaté, décidé ou communiqué…"
        placeholder={t("Ce qui vient d’être constaté, décidé ou communiqué…")}
      />

      <div className="ligne" style={{ marginTop: 10 }}>
        <select value={diffusion} onChange={(e) => definirDiffusion(e.target.value)}
          style={{ width: "auto", fontSize: 12.5, padding: "5px 8px" }}>
          <option value="cellule">Cette cellule</option>
          <option value="toutes_cellules">Toutes les cellules</option>
          <option value="direction">Direction seulement</option>
          <option value="cellule">{t("Cette cellule")}</option>
          <option value="toutes_cellules">{t("Toutes les cellules")}</option>
          <option value="direction">{t("Direction seulement")}</option>
        </select>
        <button className="accent pousse" onClick={() => void envoyer()}
          disabled={occupe || texte.trim().length === 0}>
          Consigner
          {t("Consigner")}
        </button>
      </div>
      <div className="aide" style={{ marginTop: 6 }}>
        L’horodatage est celui du serveur. Une entrée ne se modifie pas&nbsp;: elle se rectifie.
        {t("L’horodatage est celui du "
                                                                + "serveur. Une entrée ne se "
                                                                + "modifie pas : elle se "
                                                                + "rectifie.")}
      </div>
    </div>
  );
+19 −17
Changes for apps/web/src/coque/Coque.tsx: 19 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -292,12 +292,13 @@ export function Coque({ criseId }: { criseId: string }) {
    return (
      <div className="centre">
        <div className="panneau">
          <h1 className="marque">Crise inaccessible</h1>
          <h1 className="marque">{t("Crise inaccessible")}</h1>
          <p className="accroche">
            Vous n’êtes pas membre de l’organisation qui porte cette crise, ou elle
            n’existe pas.
            {t("Vous n’êtes pas membre de l’organisation "
                                                 + "qui porte cette crise, ou elle n’existe "
                                                 + "pas.")}
          </p>
          <button onClick={() => aller("/")}>Retour</button>
          <button onClick={() => aller("/")}>{t("Retour")}</button>
        </div>
      </div>
    );
@@ -319,13 +320,14 @@ export function Coque({ criseId }: { criseId: string }) {
      {/* ── Barre d’état : elle ne bouge jamais ─────────────────────────── */}
      <div className="barre-etat">
        <button className="discret petit" onClick={() => definirRailOuvert(!railOuvert)}
          aria-label="Cellules" style={{ display: "none" }} />
          aria-label={t("Cellules")} style={{ display: "none" }} />
        <button className="marque" onClick={() => aller("/")}>
          Kastell
        </button>
        {rembobinage !== null ? (
          <span className="marque-rembobinage">
            ↺ Rembobinage — lecture seule
            {t("↺ Rembobinage — lecture "
                                                              + "seule")}
          </span>
        ) : crise && (
          <>
@@ -346,8 +348,8 @@ export function Coque({ criseId }: { criseId: string }) {

        <span className="pousse ligne" style={{ gap: 8 }}>
          {!flux.connecte && rembobinage === null && (
            <span className="pastille alerte" title="Le flux temps réel est coupé ; la
              lecture reste à jour à chaque action.">{t("hors flux")}</span>
            <span className="pastille alerte" title={t("Le flux temps réel est coupé ; la "
                                                     + "lecture reste à jour à chaque action.")}>{t("hors flux")}</span>
          )}
          {presents.length > 0 && (
            <span className="pastille ok sans-point" title={
@@ -449,7 +451,7 @@ export function Coque({ criseId }: { criseId: string }) {
                        chose ». */}
                    {interpelle[s.id] && (
                      <span className="compteur interpelle"
                        title="Vous avez été nommé dans ce salon.">@</span>
                        title={t("Vous avez été nommé dans ce salon.")}>@</span>
                    )}
                    {(nonLus[s.id] ?? 0) > 0 && (
                      <span className="compteur non-lus"
@@ -684,7 +686,7 @@ export function Coque({ criseId }: { criseId: string }) {
                <span>{c.libelle.replace(/^Cellule /, "")}</span>
                <span className="valeur">
                  {c.alerte
                    ? <span className="pastille alerte sans-point">muette</span>
                    ? <span className="pastille alerte sans-point">{t("muette")}</span>
                    : `${c.anciennete_minutes} min`}
                </span>
              </div>
@@ -693,7 +695,7 @@ export function Coque({ criseId }: { criseId: string }) {

          {(cockpit.data?.arbitrages_en_attente.length ?? 0) > 0 && (
            <div className="bloc">
              <span className="etiq">Arbitrages en attente</span>
              <span className="etiq">{t("Arbitrages en attente")}</span>
              {cockpit.data!.arbitrages_en_attente.map((a) => (
                <div key={a.id} className="carte alerte" style={{ padding: "8px 10px",
                  marginBottom: 6, fontSize: 13 }}>
@@ -705,11 +707,11 @@ export function Coque({ criseId }: { criseId: string }) {

          {(cockpit.data?.tiers_en_attente.length ?? 0) > 0 && (
            <div className="bloc">
              <span className="etiq">Tiers sans retour</span>
              {cockpit.data!.tiers_en_attente.map((t) => (
                <div key={t.tiers_id} className="fait">
                  <span>{t.nom}</span>
                  <span className="valeur">{t.attente_minutes} min</span>
              <span className="etiq">{t("Tiers sans retour")}</span>
              {cockpit.data!.tiers_en_attente.map((tiers) => (
                <div key={tiers.tiers_id} className="fait">
                  <span>{tiers.nom}</span>
                  <span className="valeur">{tiers.attente_minutes} min</span>
                </div>
              ))}
            </div>
@@ -743,7 +745,7 @@ export function Coque({ criseId }: { criseId: string }) {
        lue={lueFenetre} fermer={fermerFenetre} />
      {appel && (
        <Suspense fallback={
          <div className="visio"><div className="tete">Connexion à la salle…</div></div>
          <div className="visio"><div className="tete">{t("Connexion à la salle…")}</div></div>
        }>
          <PanneauVisio criseId={criseId} appel={appel}
            raccrocher={() => definirAppel(null)} />
+20 −8
Changes for apps/web/src/coque/DockChat.tsx: 20 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { useEtat, heure } from "../etat.js";
import { useT } from "../langues/index.js";

export interface Fenetre {
  /** L'identifiant de la conversation directe. */
@@ -74,6 +75,7 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {
  basculer: (id: string) => void; reduire: (id: string) => void;
  lue: (id: string) => void; fermer: (id: string) => void;
}) {
  const t = useT();
  const client = useQueryClient();
  const { moi } = useEtat();
  const [texte, definirTexte] = useState("");
@@ -99,13 +101,13 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {

  async function envoyer(e: React.FormEvent) {
    e.preventDefault();
    const t = texte.trim();
    if (t === "") return;
    const propos = texte.trim();
    if (propos === "") return;
    definirErreur(null);
    definirOccupe(true);
    try {
      await api.poster(
        `/api/crises/${criseId}/directs/${fenetre.conversationId}/messages`, { texte: t });
        `/api/crises/${criseId}/directs/${fenetre.conversationId}/messages`, { texte: propos });
      definirTexte("");
      await client.invalidateQueries({
        queryKey: ["direct", criseId, fenetre.conversationId] });
@@ -137,7 +139,7 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {
          onClick={() => (fenetre.repliee ? basculer(id) : reduire(id))}>
          {fenetre.repliee ? "▴" : "▾"}
        </button>
        <button className="fermer" aria-label="Fermer la conversation" title="Fermer"
        <button className="fermer" aria-label={t("Fermer la conversation")} title={t("Fermer")}
          onClick={() => fermer(id)}>✕</button>
      </div>

@@ -146,13 +148,23 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {
          <div className="fil">
            {messages.length === 0 && (
              <p className="discret" style={{ fontSize: 12.5, margin: 0 }}>
                Rien encore. Ce qui doit faire trace se dit en salon.
                {t("Rien "
                                                                                              + "encore. "
                                                                                              + "Ce "
                                                                                              + "qui "
                                                                                              + "doit "
                                                                                              + "faire "
                                                                                              + "trace "
                                                                                              + "se "
                                                                                              + "dit "
                                                                                              + "en "
                                                                                              + "salon.")}
              </p>
            )}
            {messages.map((m) => (
              <div key={m.id} className={`bulle${m.auteur_id === moi?.compte.id ? " moi" : ""}`}>
                {m.supprime
                  ? <span className="discret">message retiré</span>
                  ? <span className="discret">{t("message retiré")}</span>
                  : <span>{m.texte}</span>}
                <span className="quand">{heure(m.envoye_at)}</span>
              </div>
@@ -163,11 +175,11 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {
          {erreur && <div className="erreur" style={{ margin: "0 8px 6px" }}>{erreur}</div>}

          <form className="saisie" onSubmit={envoyer}>
            <input value={texte} maxLength={4000} placeholder="Écrire…"
            <input value={texte} maxLength={4000} placeholder={t("Écrire…")}
              aria-label={`Message à ${fenetre.titre}`}
              onChange={(e) => definirTexte(e.target.value)} />
            <button className="accent petit" type="submit"
              disabled={occupe || texte.trim() === ""}>Envoyer</button>
              disabled={occupe || texte.trim() === ""}>{t("Envoyer")}</button>
          </form>
        </>
      )}
Loading