gestion_table_tournante_libre/src/interface/App.svelte
Mathieu Benoit 94ea937ae4 [IMP] home: list shown at once, computed columns loaded per event
The event list waited until every event's ranking was evaluated, about
10 ms per event with propositions, before showing anything. It now shows
as soon as the folder is read; each line's retained-plan columns and best
proposition load one event at a time, yielding to the browser between
events, behind a rotating SVG spinner, and show as each result arrives.
Work for a list no longer shown is dropped. The spinner is the one named
exception to the no-animation rule: still under reduced motion, cancelled
for captures, announced once. Checked: 2935 node, 616 browser, 62 long.

--- FR ---

[IMP] accueil : liste aussitôt montrée, colonnes calculées par événement

La liste des événements attendait l'évaluation du classement de chaque
événement, environ 10 ms par événement qui a des propositions, avant de
rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du
retenu et la meilleure proposition de chaque ligne se chargent un
événement à la fois, le navigateur reprenant la main entre deux, derrière
une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée
est abandonné. L'icône est la seule exception nommée à l'interdit
d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues.

Assisted-by: Claude Opus 5.5
2026-10-08 17:07:31 -04:00

335 lines
13 KiB
Svelte

<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
// l'application — système de fichiers, horloge, exécuteur de génération,
// presse-papiers, identifiants, langue, provenance, démonstrations, format
// courant, version du produit et sa forme Windows, et en option le thème
// de départ — que src/main.js construit pour la machine et monterApp pour
// une épreuve : App ne choisit aucune plateforme, et ne lit la version que
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
// le bloc « À propos ».
//
// À son initialisation, App crée une fois chacun la séance, sans racine,
// le contrôleur de génération et la façade, et les donne aux composants par
// le contexte de Svelte — clés 'seance', 'facade', 'generation', 'ui', 't',
// 'format', 'horloge' et 'relireListe' —, posé de façon synchrone avant que
// la détermination du dossier de travail, asynchrone, ne rende. La façade
// pose la racine de la séance par changerRacine : aussitôt sans question,
// après la réponse sinon. App lit la liste du dossier de travail par la
// façade et la donne à l'accueil, une fois la détermination réglée — sans
// question au démarrage, après la réponse sinon : pret se résout quand la
// détermination est faite, la liste lue quand aucune question n'attend, et
// le premier écran — les lignes de la liste, ou la question — rendu. Les
// résumés des lignes n'en sont pas : ils se calculent ensuite, un
// événement après l'autre, et l'accueil montre chacun dès qu'il arrive.
//
// L'écran suit la séance : la liste sans événement ouvert, l'événement
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque
// ouverture d'un événement remet l'état de navigation à son départ. Le
// bandeau, présent sur tout écran, ne défile pas : un écran plus haut que
// la fenêtre défile sous lui.
//
// Le thème part de la propriété themeInitial, ou de la préférence du
// système lue au montage ; le réglage de l'opérateur le change pour la
// séance, en mémoire, posé en data-theme sur <html> (§ 19.2). À la sortie
// de la page (pagehide), la séance ferme l'événement et rend son verrou,
// sans question ; aucun beforeunload ne retient la page (§ 8.4).
import './jetons.css';
import { onDestroy, setContext, tick, untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { creerFacade } from '../application/evenements.js';
import { creerFormat } from '../application/format.js';
import { creerControleurGeneration } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerSeance } from '../application/seance.js';
import { creerTraduction } from '../application/traduction.js';
import APropos from './APropos.svelte';
import Accueil from './accueil/Accueil.svelte';
import { creerPas } from './accueil/chargement.js';
import Bandeau from './Bandeau.svelte';
import Evenement from './evenement/Evenement.svelte';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { creerEtatUi } from './etat_ui.js';
import { TABLES_INTERFACE } from './libelles.js';
import { themeInitial as themeDuSysteme } from './theme.js';
// Les dépendances se lisent une fois, au montage : App crée ses services
// une seule fois, et un changement de propriété ne les recrée pas.
const dependances = $props();
const {
fs,
horloge,
executeur,
pressePapiers,
identifiant,
langue = 'fr',
provenance,
demonstrations,
formatCourant,
produitVersion,
versionWindows,
themeInitial,
} = untrack(() => dependances);
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
const format = creerFormat(t);
// Le bloc « À propos » se compose au montage, avant toute séance : une
// forme de la version que les dépendances ne portent pas lève RangeError
// ici, et App ne se monte pas avec un bloc copiable faux (§ 18.6).
const bloc = blocAPropos(
{ version: { affichee: produitVersion, windows: versionWindows }, plateforme: fs.nature, provenance, format: formatCourant },
t,
);
const seanceApp = creerSeance({
fs,
horloge,
identifiant,
produitVersion,
planifier: horloge.planifier,
annuler: horloge.annuler,
formatCourant,
});
const generationApp = creerControleurGeneration({ seance: seanceApp, executeur, horloge, produitVersion });
const facadeApp = creerFacade({
fs,
horloge,
identifiant,
produitVersion,
demonstrations,
ouvert: () => seanceApp.etat().evenement?.base ?? null,
ouvrir: (base) => seanceApp.ouvrir(base),
fermer: () => seanceApp.fermer(),
creer: (champs) => seanceApp.creer(champs),
racine: () => seanceApp.etat().racine,
changerRacine: (racine) => seanceApp.changerRacine(racine),
});
// La façade que reçoivent les composants, et que rend l'export facade :
// celle de creerFacade, dont demarrer et repondre relisent la liste une
// fois la détermination réglée sans question. La façade appelle
// changerRacine avant de retirer sa question, si bien qu'une lecture
// lancée de ce rappel serait refusée (QUESTION_EN_ATTENTE). Une
// détermination refusée ne laisse rien : le demarrer suivant la refait, et
// relit alors la liste.
const facadeContexte = {
...facadeApp,
demarrer: async () => {
const issue = await facadeApp.demarrer();
if (issue.dossier.question === null) await relireListe();
return issue;
},
repondre: async (choix) => {
const issue = await facadeApp.repondre(choix);
if (issue.dossier.question === null) await relireListe();
return issue;
},
};
const uiApp = creerEtatUi();
setContext('seance', seanceApp);
setContext('facade', facadeContexte);
setContext('relireListe', () => relireListe());
setContext('generation', generationApp);
setContext('ui', uiApp);
setContext('t', t);
setContext('format', format);
setContext('horloge', horloge);
// Un refus de la séance ou de la façade porte un code, que l'écran
// traduit ; toute autre erreur est une faute, qui remonte.
const estRefus = (erreur) => typeof erreur?.code === 'string';
const refusDe = (erreur) => ({ code: erreur.code, details: erreur.details });
// La liste du dossier de travail, que l'accueil montre, et le refus qui en
// tient lieu quand la façade refuse de la lire : une liste refusée ne
// montre plus les lignes d'une lecture d'avant. lecture est la promesse de
// la dernière lecture lancée, que preparer attend ; quand deux lectures se
// chevauchent, seule la dernière pose ce qu'elle lit. La lecture passe par
// facadeContexte, la façade que voient les composants et les épreuves.
let lignes = $state.raw([]);
let refusLecture = $state.raw(null);
let lecture = Promise.resolve();
let lectures = 0;
// Les résumés des lignes lues, par base, que facade.resumer rend un
// événement après l'autre, chaque pas attendant l'image puis l'horloge
// (creerPas) ; chargement est son flux en cours, ou null. Une lecture qui
// commence abandonne le flux de la précédente, comme l'ouverture d'un
// événement et le démontage : un résumé calculé pour une liste ne se pose
// jamais sur une plus récente. Une lecture qui s'achève sous un événement
// ouvert pose ses lignes sans lancer de flux : la fermeture relit la
// liste, et ses résumés avec elle. Le flux lit dans le dossier de travail de
// sa liste, et un changement de dossier relit la liste. Une faute d'un
// calcul rejette fin, que rien ne retient : la page la reçoit comme toute
// faute, et la ligne écrit « — ».
const resumes = new SvelteMap();
let chargement = null;
function abandonnerResumes() {
chargement?.abandonner();
chargement = null;
}
function chargerResumes(lues) {
resumes.clear();
chargement = facadeContexte.resumer(lues, {
ceder: creerPas(horloge, (rappel) => requestAnimationFrame(rappel)),
recevoir: (base, resume) => resumes.set(base, resume),
});
}
onDestroy(abandonnerResumes);
async function lireListe() {
lectures += 1;
const rang = lectures;
abandonnerResumes();
try {
const lues = await facadeContexte.lister();
if (rang !== lectures) return;
lignes = lues;
refusLecture = null;
if (seanceApp.etat().evenement === null) chargerResumes(lues);
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
if (rang !== lectures) return;
lignes = [];
refusLecture = refusDe(erreur);
}
}
// Lance une lecture de la liste, qui devient la dernière, et rend sa
// promesse. L'accueil l'appelle par le contexte après toute opération qui
// change le dossier de travail.
function relireListe() {
lecture = lireListe();
return lecture;
}
// L'état observable de la séance, et la base de l'événement ouvert : une
// base qui change vers un événement remet la navigation à son départ. Le
// refus de « Liste des événements » tient jusqu'au changement suivant.
// La liste se relit à chaque fermeture d'un événement, qui a pu en créer,
// en changer ou écrire ailleurs ; elle se lit aussi quand la détermination
// se règle — preparer sans question, repondre de facadeContexte après la
// réponse.
let etatSeance = $state.raw(seanceApp.etat());
let refusListe = $state.raw(null);
let baseOuverte = null;
const desabonner = seanceApp.subscribe((etat) => {
const base = etat.evenement?.base ?? null;
if (base !== null) abandonnerResumes();
if (base !== null && base !== baseOuverte) uiApp.reinitialiser();
const fermeture = baseOuverte !== null && base === null;
baseOuverte = base;
etatSeance = etat;
refusListe = null;
if (fermeture) relireListe();
});
onDestroy(desabonner);
let theme = $state(themeInitial ?? themeDuSysteme(window.matchMedia('(prefers-color-scheme: dark)').matches));
$effect(() => {
document.documentElement.setAttribute('data-theme', theme);
});
const titre = t('coquille.titre', { version: produitVersion });
const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, t);
// « Liste des événements » : ferme l'événement, sans question ; sous un
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
// commande.
async function revenirALaListe() {
try {
await seanceApp.fermer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusListe = refusDe(erreur);
}
}
// La page se retire : la séance ferme l'événement et rend son verrou. Sous
// un support rompu, elle le refuse et rien ne s'écrit.
function quitterLaPage() {
seanceApp.fermer().catch((erreur) => {
if (!estRefus(erreur)) throw erreur;
});
}
// La détermination faite sans question, la liste du dossier retenu se
// lit ; une question laisse l'accueil sans lignes, jusqu'à la réponse. Un
// refus de la détermination s'affiche à la place de la liste.
async function preparer() {
try {
await facadeContexte.demarrer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusLecture = refusDe(erreur);
}
await lecture;
await tick();
}
/** Résolue quand la détermination du dossier de travail est faite et le
* premier écran rendu : les lignes de la liste lue, ou la question. */
export const pret = preparer();
/** La séance de ce montage. */
export function seance() {
return seanceApp;
}
/** Le contrôleur de génération de ce montage. */
export function generation() {
return generationApp;
}
/** La façade des événements de ce montage. */
export const facade = facadeContexte;
/** L'état de navigation de ce montage. */
export const ui = uiApp;
</script>
<svelte:window onpagehide={quitterLaPage} />
<svelte:head>
<title>{titre}</title>
</svelte:head>
<div class="app">
<Bandeau
etat={etatSeance}
{version}
provenance={provenanceAffichee}
{theme}
{refusListe}
choisirTheme={(choix) => (theme = choix)}
ouvrirApropos={() => uiApp.ouvrirApropos()}
{revenirALaListe}
/>
<main class="ecran">
{#if $uiApp.apropos}
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
{:else if etatSeance.evenement === null}
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {resumes} {refusLecture} />
{:else}
<Evenement />
{/if}
</main>
</div>
<style>
:global(body) {
margin: 0;
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font-family: var(--police-interface);
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.ecran {
flex: 1;
padding: var(--espace-4);
}
</style>