DOCS/kanban.html
Thomas Fransolet f099dbbc5f K5 livré, et deux corrections que la vérification a sorties
K5 — les 3 flavors de mymuseum-visitapp construisent. La prémisse du lot
était fausse : sa config Android était déjà à niveau, ce sont les dix
crans du 12/08 qui ont amené tablet-app jusqu'à lui. Restaient deux
alignements (NDK 28.2.13676358 réclamé par speech_to_text, Kotlin 2.3.10).
Un cran de plus existe — Gradle 8.14.0 / AGP 8.11.1 — délibérément non
pris : terrain neuf pour les deux repos, le faire ici seul les désaligne.

Correction 1 — tablet-app couvre 10 des 13 types, pas 11. Le switch de
main_view.getContent traite Map, Slider, Video, Web, Menu, Quiz, Pdf,
Game, Agenda, Weather. SectionArticle (type 6) a son case commenté
« TODO » et un article_view.dart d'1 Ko : un article rend « Ce type n'est
pas supporté ». Contrairement à SectionEvent et SectionParcours, ce type
existait dans Mongo — c'est un vrai retard, et il sortira de la migration
avec du contenu réel. Ajouté au plan en K7.

Le travail de K7 n'est pas la copie mais le passage en paysage : les
écrans de mymuseum sont pensés pour un téléphone debout, une borne est
large. K3 (SectionEvent) pose la même question — une seule passe de
design pour les deux, sinon deux mises en page sur la même borne.

Correction 2 — le travail V1 des deux apps visiteur est committé sur des
branches qui ne sont pas master : Meta-Rayban-Test pour mymuseum-visitapp,
AI-Assistant-test pour tablet-app. Le §5bis décrit pourtant la première
comme « un POC jamais mergé ». À trancher avant K6 (publier les APK).
Corollaire relevé au §5bis : l'APK se construit sans le POC dedans, les
4 erreurs Ray-Ban étant hors du graphe de main.dart.

kanban.html : carte build mymuseum retirée d'Urgent (3 -> 2), done-item
K5 ajouté (39 -> 40), bandeau du haut réaligné, carte périmètre kiosk
corrigée. Compteurs de colonnes revérifiés un à un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:18:55 +02:00

1145 lines
95 KiB
HTML
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.

<title>MyInfoMate — Tableau de chantiers</title>
<style>
:root {
--ground: #F4F6F8;
--surface: #FFFFFF;
--surface-2: #EBEEF2;
--ink: #14202B;
--ink-2: #4A5A6B;
--ink-3: #7C8B9A;
--line: #D6DDE5;
--line-soft: #E4EAF0;
--brand: #264863;
--brand-soft: #C2C9D6;
--critical: #A8322A;
--warn: #9A6608;
--good: #1F6B4D;
--info: #2B5F86;
--gate: #6B3F8C;
--shadow: 0 1px 2px rgba(20, 32, 43, .06), 0 4px 12px rgba(20, 32, 43, .04);
--sans: ui-sans-serif, -apple-system, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "Consolas", "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--ground: #0F161D;
--surface: #18222C;
--surface-2: #212D39;
--ink: #E6ECF2;
--ink-2: #A3B2C0;
--ink-3: #74879A;
--line: #2C3A48;
--line-soft: #243140;
--brand: #7FA8C9;
--brand-soft: #35506B;
--critical: #E08078;
--warn: #D6A343;
--good: #6FBF97;
--info: #7CB2DA;
--gate: #B48BD6;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 4px 14px rgba(0, 0, 0, .22);
}
}
:root[data-theme="dark"] {
--ground: #0F161D;
--surface: #18222C;
--surface-2: #212D39;
--ink: #E6ECF2;
--ink-2: #A3B2C0;
--ink-3: #74879A;
--line: #2C3A48;
--line-soft: #243140;
--brand: #7FA8C9;
--brand-soft: #35506B;
--critical: #E08078;
--warn: #D6A343;
--good: #6FBF97;
--info: #7CB2DA;
--gate: #B48BD6;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 4px 14px rgba(0, 0, 0, .22);
}
:root[data-theme="light"] {
--ground: #F4F6F8;
--surface: #FFFFFF;
--surface-2: #EBEEF2;
--ink: #14202B;
--ink-2: #4A5A6B;
--ink-3: #7C8B9A;
--line: #D6DDE5;
--line-soft: #E4EAF0;
--brand: #264863;
--brand-soft: #C2C9D6;
--critical: #A8322A;
--warn: #9A6608;
--good: #1F6B4D;
--info: #2B5F86;
--shadow: 0 1px 2px rgba(20, 32, 43, .06), 0 4px 12px rgba(20, 32, 43, .04);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1500px;
margin: 0 auto;
padding: 40px 28px 72px;
}
/* ---------- header ---------- */
.masthead {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding-bottom: 22px;
border-bottom: 2px solid var(--brand);
}
.masthead h1 {
margin: 0;
font-size: clamp(26px, 3.4vw, 38px);
font-weight: 700;
letter-spacing: -0.022em;
text-wrap: balance;
}
.masthead p {
margin: 8px 0 0;
color: var(--ink-2);
max-width: 62ch;
}
.stamp {
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--ink-3);
text-align: right;
line-height: 1.7;
}
/* ---------- summary ---------- */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
margin: 26px 0 0;
}
.stat {
background: var(--surface);
padding: 14px 16px 15px;
}
.stat .n {
font-family: var(--mono);
font-size: 27px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
display: block;
line-height: 1.1;
}
.stat .k {
display: block;
margin-top: 5px;
font-size: 11.5px;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--ink-3);
}
.n-critical { color: var(--critical); }
.n-warn { color: var(--warn); }
.n-good { color: var(--good); }
.n-info { color: var(--info); }
.n-gate { color: var(--gate); }
/* ---------- filters ---------- */
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 26px 0 20px;
}
.filters .lead {
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--ink-3);
margin-right: 4px;
}
.chip-btn {
font-family: var(--mono);
font-size: 12px;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 13px;
cursor: pointer;
transition: background .13s ease, color .13s ease, border-color .13s ease;
}
.chip-btn:hover { border-color: var(--brand); color: var(--ink); }
.chip-btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.chip-btn[aria-pressed="true"] {
background: var(--brand);
border-color: var(--brand);
color: var(--surface);
}
:root[data-theme="dark"] .chip-btn[aria-pressed="true"],
.chip-btn[aria-pressed="true"] { color: #fff; }
@media (prefers-color-scheme: dark) {
.chip-btn[aria-pressed="true"] { color: #0F161D; }
}
:root[data-theme="dark"] .chip-btn[aria-pressed="true"] { color: #0F161D; }
:root[data-theme="light"] .chip-btn[aria-pressed="true"] { color: #fff; }
/* ---------- board ---------- */
.board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(288px, 1fr);
gap: 18px;
overflow-x: auto;
padding-bottom: 12px;
align-items: start;
}
.col { min-width: 0; }
.col-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 0 2px 10px;
border-bottom: 2px solid var(--stripe, var(--line));
margin-bottom: 14px;
}
.col-head h2 {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--stripe, var(--ink));
}
.col-head .count {
font-family: var(--mono);
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ink-3);
}
.stack {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: var(--surface);
border: 1px solid var(--line-soft);
border-left: 3px solid var(--stripe, var(--line));
border-radius: 5px;
padding: 12px 14px 13px;
box-shadow: var(--shadow);
}
.card h3 {
margin: 0 0 5px;
font-size: 14.5px;
font-weight: 650;
line-height: 1.35;
letter-spacing: -0.008em;
text-wrap: balance;
}
.card p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--ink-2);
}
.card-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 7px;
}
.tag {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: .04em;
color: var(--ink-3);
background: var(--surface-2);
border-radius: 3px;
padding: 2px 6px;
white-space: nowrap;
}
.flag {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: .05em;
text-transform: uppercase;
border-radius: 3px;
padding: 2px 6px;
white-space: nowrap;
border: 1px solid currentColor;
}
.f-critical { color: var(--critical); }
.f-warn { color: var(--warn); }
.f-good { color: var(--good); }
.src {
display: block;
margin-top: 9px;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-3);
word-break: break-word;
}
/* ---------- done ---------- */
.done {
margin-top: 46px;
padding-top: 26px;
border-top: 1px solid var(--line);
}
.done h2 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--good);
}
.done > p {
margin: 0 0 18px;
color: var(--ink-3);
font-size: 13px;
}
.done-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
gap: 10px;
}
.done-item {
background: var(--surface);
border: 1px solid var(--line-soft);
border-left: 3px solid var(--good);
border-radius: 5px;
padding: 11px 13px;
}
.done-item strong {
display: block;
font-size: 13.5px;
font-weight: 650;
margin-bottom: 3px;
}
.done-item span {
font-size: 12.5px;
color: var(--ink-2);
}
/* ---------- footer ---------- */
.foot {
margin-top: 44px;
padding-top: 20px;
border-top: 1px solid var(--line);
font-size: 13px;
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
gap: 8px 22px;
}
.foot code {
font-family: var(--mono);
font-size: 12px;
color: var(--ink-2);
}
.hidden { display: none !important; }
.filters-horizon { margin-top: -8px; }
.stat-muted .n { color: var(--ink-3); }
.empty-msg {
margin: 0 0 20px;
padding: 18px 20px;
background: var(--surface);
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--ink-3);
font-size: 13.5px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
@media (max-width: 720px) {
.wrap { padding: 28px 16px 56px; }
.stamp { text-align: left; }
}
</style>
<div class="wrap">
<header class="masthead">
<div>
<h1>MyInfoMate — tableau de chantiers</h1>
<p>Vue d'état consolidée depuis <code>DOCS/</code>. Source de vérité : <code>STATUS.md</code> — cette page en est le reflet, pas le remplaçant. Filtrez sur <strong>V1</strong> pour ne voir que ce qui reste avant la mise en prod. <strong>L'ordre d'exécution</strong>, lui, est dans <code>v1-plan.md</code> : ce tableau dit où en est chaque chantier, pas lequel bloque lequel.</p>
</div>
<div class="stamp">
Mise à jour · 2026-08-11<br>
Postgres v3 · pas encore en prod
</div>
</header>
<section class="summary" aria-label="Chiffres clés">
<div class="stat"><span class="n n-critical">2</span><span class="k">Urgent</span></div>
<div class="stat"><span class="n n-info">2</span><span class="k">Migration v3</span></div>
<div class="stat"><span class="n n-warn">5</span><span class="k">Bugs ouverts</span></div>
<div class="stat"><span class="n">6</span><span class="k">À tester</span></div>
<div class="stat"><span class="n">20</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n n-gate">8</span><span class="k">Bascule prod</span></div>
<div class="stat"><span class="n n-good">40</span><span class="k">Fait récemment</span></div>
</section>
<div class="filters" role="group" aria-label="Filtrer par domaine">
<span class="lead">Domaine</span>
<button class="chip-btn" type="button" data-filter="all" aria-pressed="true">Tout</button>
<button class="chip-btn" type="button" data-filter="backend" aria-pressed="false">manager-service</button>
<button class="chip-btn" type="button" data-filter="manager" aria-pressed="false">manager-app</button>
<button class="chip-btn" type="button" data-filter="visitapp" aria-pressed="false">visitapp</button>
<button class="chip-btn" type="button" data-filter="infra" aria-pressed="false">infra &amp; ops</button>
<button class="chip-btn" type="button" data-filter="doc" aria-pressed="false">doc &amp; commercial</button>
</div>
<div class="filters filters-horizon" role="group" aria-label="Filtrer par horizon">
<span class="lead">Horizon</span>
<button class="chip-btn" type="button" data-horizon-filter="all" aria-pressed="true">Tout</button>
<button class="chip-btn" type="button" data-horizon-filter="v1" aria-pressed="false">V1 — reste à faire</button>
<button class="chip-btn" type="button" data-horizon-filter="v2" aria-pressed="false">V2 — reporté</button>
</div>
<p class="empty-msg hidden" role="status">Aucun chantier ne correspond à ces deux filtres.</p>
<div class="board">
<!-- URGENT -->
<section class="col" style="--stripe: var(--critical)">
<div class="col-head"><h2>Urgent</h2><span class="count">2</span></div>
<div class="stack">
<article class="card" data-area="infra">
<div class="card-meta"><span class="tag">infra</span><span class="flag f-critical">Sécurité</span></div>
<h3>Port PostgreSQL exposé publiquement</h3>
<p>À fermer via Traefik/Docker. Accès DBeaver par tunnel SSH uniquement.</p>
<span class="src">todo-features.md — Sécurité réseau</span>
</article>
<article class="card" data-area="manager" data-horizon="v1">
<div class="card-meta"><span class="tag">tablet-app</span><span class="flag f-warn">Lot K · périmètre kiosk</span></div>
<h3>Types de section manquants sur le kiosk</h3>
<p><code>tablet-app</code> couvre <strong>10 des 13</strong> types — <em>corrigé le 12/08, les docs disaient 11</em>. Le <code>switch</code> de <code>main_view.getContent</code> traite Map, Slider, Video, Web, Menu, Quiz, Pdf, Game, Agenda et Weather. <code>SectionEvent</code> et <code>SectionParcours</code> ne sont pas un retard — ils <strong>sont nés avec Postgres v3</strong> et n'ont jamais existé dans Mongo. <strong>Mais <code>SectionArticle</code> (type 6) en est un</strong> : son <code>case</code> est commenté « TODO » depuis l'origine et <code>Screens/Article/article_view.dart</code> est un fichier d'1 Ko. Un article tombe donc dans le <code>default</code> — « Ce type n'est pas supporté » — alors que l'article existait dans Mongo et est du contenu kiosk parfaitement légitime. <strong>Décidé le 12/08 : le supporter, c'est K7 au plan.</strong></p>
<p><strong>Le travail n'est pas la copie, c'est le paysage.</strong> Reprendre <code>mymuseum-visitapp/lib/Screens/Sections/Article/</code> est mécanique ; les écrans de mymuseum sont pensés pour un <strong>téléphone tenu debout</strong>, alors qu'une borne est large — un article en colonne unique sur 1280 px est illisible. <strong>K3 (<code>SectionEvent</code>) pose exactement la même question</strong> — héros, programme, carte — et la trancher deux fois donnerait deux mises en page différentes sur la même borne. <strong>Une seule passe de design pour les deux</strong>, même raisonnement d'économie que L5 et L15.</p>
<p><strong>Tranché le 12/08</strong><code>SectionParcours</code> : <strong>exclu définitivement</strong>, un parcours guidé fait marcher le visiteur avec géodéclenchement, ça n'a pas de sens sur borne fixe. <code>SectionEvent</code> : <strong>à supporter</strong>, une borne d'accueil qui affiche le programme du jour et le plan est le cas d'usage kiosk par excellence (§19.13 cas E).</p>
<p>Plus le renommage <code>SectionPuzzle</code><code>SectionGame</code> et le nouveau <code>SlidingPuzzle</code> : <strong>reprendre <code>mymuseum-visitapp/lib/Screens/Sections/Game/</code></strong> (<code>game_page.dart</code>, <code>sliding_puzzle_piece.dart</code>), dont la version est <strong>moins buguée</strong> que le <code>Puzzle/</code> de tablet-app. Récupération, pas réécriture.</p>
<span class="src">v1-plan.md lot K — K3, K4</span>
</article>
</div>
</section>
<!-- MIGRATION -->
<section class="col" style="--stripe: var(--info)">
<div class="col-head"><h2>Migration v3</h2><span class="count">2</span></div>
<div class="stack">
<article class="card" data-area="manager">
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-good">Gros gain</span></div>
<h3>Compression images — 2560 px / JPEG q82</h3>
<p>Aucune compression aujourd'hui : une photo 4K part entière. ~12× de gain sur le stockage <em>et</em> sur chaque consultation visiteur.</p>
<span class="src">v2/media-storage-plan.md</span>
</article>
<article class="card" data-area="manager">
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-warn">code mort depuis C3</span></div>
<h3>Retirer la suppression de blob de manager-app</h3>
<p><code>show_resource_popup.dart:130-137</code> supprime l'objet du bucket <em>après</em> avoir supprimé la ligne, et avale l'échec dans un <code>print</code> — un échec laissait un orphelin définitif que le quota ne comptait plus. Depuis C3 (12/08) le serveur supprime le blob <strong>avant</strong> la ligne : le client tombe désormais sur un objet déjà absent. Ce n'est plus dangereux, c'est devenu inutile. ⚠️ <strong>Ne pas le retirer avant d'avoir renseigné <code>Firebase:StorageBucket</code> en prod</strong> (carte « Bascule prod ») : la clé est vide par défaut, et sans elle le serveur ne supprime rien — retirer le code client ferait perdre la fonction au lieu de la déplacer.</p>
<span class="src">v1-plan.md lot C — C6 · STATUS.md §1sexies</span>
</article>
</div>
</section>
<!-- BUGS -->
<section class="col" style="--stripe: var(--warn)">
<div class="col-head"><h2>Bugs ouverts</h2><span class="count">5</span></div>
<div class="stack">
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">visitapp</span><span class="flag f-warn">Fraîcheur</span></div>
<h3>Ressource modifiée jamais re-téléchargée</h3>
<p>Le filtre incrémental teste la présence du fichier, pas sa version. Une image remplacée dans le CMS ne remonte jamais sur le device. Invisible pour le client.</p>
<span class="src">v2/offline-visit-plan.md</span>
</article>
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">visitapp</span></div>
<h3>audio/mpeg absent de la table d'extensions</h3>
<p>La table mappe <code>audio/mp3</code>, qui n'est pas un MIME standard. Les MP3 atterrissent probablement en <code>.unknown</code>. À vérifier sur device.</p>
<span class="src">v2/offline-visit-plan.md</span>
</article>
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">visitapp</span></div>
<h3>Purge des fichiers obsolètes désactivée</h3>
<p>La liste est calculée puis le <code>deleteSync()</code> est commenté. Le stockage occupé sur le téléphone du visiteur ne diminue jamais.</p>
<span class="src">v2/offline-visit-plan.md</span>
</article>
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">visitapp</span></div>
<h3>Échecs de téléchargement silencieux</h3>
<p>Un fichier raté fait un <code>print</code> et passe. La visite est annoncée téléchargée alors qu'elle est incomplète.</p>
<span class="src">v2/offline-visit-plan.md</span>
</article>
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">mobile</span><span class="tag">M3</span></div>
<h3>meterZoneGPS ignoré</h3>
<p>Le rayon de déclenchement configuré par section n'a aucun effet — une constante en dur à 100 m le remplace.</p>
<span class="src">parity-manager-visitapp.md §2</span>
</article>
</div>
</section>
<!-- À TESTER -->
<section class="col" style="--stripe: var(--brand)">
<div class="col-head"><h2>À tester</h2><span class="count">6</span></div>
<div class="stack">
<article class="card" data-area="manager">
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-warn">Jamais lancé</span></div>
<h3>Écran Statistiques refondu + export PDF</h3>
<p>Écrit et analysé, jamais ouvert dans un navigateur. À vérifier sur données réelles : la règle mono-canal, le rendu des titres longs dans les barres. Sur le PDF : les accents, et le logo — <strong>s'il manque, c'est le CORS du bucket Firebase</strong>, le rapport doit se générer quand même. Et après <code>database update</code> : que les <em>instances</em> soient bien passées à 395 jours (pas seulement les plans), et que le job <code>visit-events-purge</code> déclenché à la main <strong>ne supprime rien</strong>.</p>
<span class="src">test-plan.md §8bis, §8ter, §8quater</span>
</article>
<article class="card" data-area="visitapp backend">
<div class="card-meta"><span class="tag">§21</span><span class="flag f-critical">En premier</span></div>
<h3>Visite hors ligne sur device</h3>
<p>15 cas : images d'articles, audios, extensions de fichiers, fraîcheur, dégradation des types non offline. Conditionne l'urgence de tous les bugs hors ligne.</p>
<span class="src">test-plan.md §21</span>
</article>
<article class="card" data-area="backend manager doc">
<div class="card-meta"><span class="tag">§18</span><span class="flag f-warn">Jamais exécuté</span></div>
<h3>Onboarding self-service complet</h3>
<p>Inscription, création d'instance, essai 14 j, Stripe, 9 emails Resend, job de cycle d'essai. Code écrit et compilé, aucun parcours joué. Reste l'alias <code>onboarding@</code> et le <code>whsec_</code> Stripe CLI.</p>
<span class="src">test-plan.md §18</span>
</article>
<article class="card" data-area="manager visitapp">
<div class="card-meta"><span class="tag">§19.13</span></div>
<h3>Chasse au trésor / carnaval — cas E</h3>
<p>Le scénario le plus complet : SectionMap + SectionParcours + géodéclenchement + questions + mode jeu, d'un seul tenant.</p>
<span class="src">test-plan.md §19.13</span>
</article>
<article class="card" data-area="manager visitapp">
<div class="card-meta"><span class="tag">§19</span></div>
<h3>Parité par type de section</h3>
<p>« Tout ce que je configure est-il visible ? » — le chapitre qui valide les 13 types sur les deux apps visiteur.</p>
<span class="src">test-plan.md §19</span>
</article>
<article class="card" data-area="infra">
<div class="card-meta"><span class="tag">§0</span></div>
<h3>Porte d'entrée build</h3>
<p>5 commandes, bloquant absolu. <code>flutter analyze</code> et <code>next dev</code> ne prouvent rien — seuls les vrais builds disent la vérité.</p>
<span class="src">test-plan.md §0</span>
</article>
</div>
</section>
<!-- PLANIFIÉ -->
<section class="col" style="--stripe: var(--ink-3)">
<div class="col-head"><h2>Planifié</h2><span class="count">20</span></div>
<div class="stack">
<article class="card" data-area="commercial" data-horizon="v1">
<div class="card-meta"><span class="tag">après prod</span><span class="tag">relationnel</span></div>
<h3>Reprendre contact avec Louise Smets — Musée d'Ixelles</h3>
<p><strong>Une fois la prod stabilisée</strong>, pas avant : on n'ouvre pas une conversation en montrant un produit en cours de bascule. Premier temps d'<strong>écoute</strong> — comprendre ce qu'est réellement son travail au quotidien et sur quoi elle bute — avant de parler de MyInfoMate. L'entraide se décide ensuite, pas dans le premier message. ⚠️ Rien n'est su de son rôle exact à ce stade : à ne pas supposer.</p>
<span class="src">STATUS.md §1quinquies — Après la bascule</span>
</article>
<article class="card" data-area="backend" data-horizon="v2">
<div class="card-meta"><span class="tag">priorité 1</span><span class="flag f-warn">V2</span></div>
<h3>Rapport de stats — envoi mensuel automatique</h3>
<p><strong>Le rapport à la demande est livré</strong> (07/08, généré côté client). Reste l'envoi automatique, seule partie qui exige vraiment le backend : personne n'a son navigateur ouvert le 1<sup>er</sup> à 6 h. QuestPDF + Hangfire, config destinataires/fréquence, et une méthode d'envoi <em>avec pièce jointe</em> — les 10 méthodes d'<code>IEmailService</code> sont des templates figés sans attachement. ⚠️ QuestPDF exige <code>libfontconfig1</code> + une police, absents du Dockerfile : ça marche en local sous Windows et casse dans le conteneur.</p>
<span class="src">plan-import-ia-stats-subsides.md §1</span>
</article>
<article class="card" data-area="backend" data-horizon="v1">
<div class="card-meta"><span class="tag">manager-service</span><span class="flag f-warn">Devenu pressant 09/08</span></div>
<h3><code>GetSummary</code> agrège en mémoire</h3>
<p><code>eventsQuery.ToList()</code> charge <strong>tous</strong> les événements de la période avant d'agréger. Tenable tant que la fenêtre était de 30 jours ; elle vient de passer à <strong>13 mois</strong>. À passer en SQL avant de vendre le rapport annuel à un gros site — sinon c'est la mémoire du conteneur qui arbitre.</p>
<span class="src">todo-features.md — Plans &amp; Quotas</span>
</article>
<article class="card" data-area="backend" data-horizon="v1">
<div class="card-meta"><span class="tag">manager-service</span><span class="flag f-warn">Dette · découvert 09/08</span></div>
<h3>Aucun test ne couvrira le RAG</h3>
<p>Ajouter <code>ContentEmbedding</code> a cassé <strong>116 des 124 tests</strong> d'un coup : la suite tourne sur <strong>EF InMemory</strong>, qui ignore le type <code>Vector</code> et refuse de valider le modèle. Contourné en excluant l'entité hors Npgsql — les tests repassent, mais vector store, recherche cosinus, index HNSW et contrainte anti-doublon Hangfire restent <em>structurellement</em> non testables. Piste : <code>Testcontainers.PostgreSql</code> sur l'image <code>Dockerfile.postgres</code>.</p>
<span class="src">STATUS.md §1quater — Dette ouverte</span>
</article>
<article class="card" data-area="backend">
<div class="card-meta"><span class="tag">priorité 2</span><span class="flag f-good">Exécuté 10/08 — 2150 morceaux</span></div>
<h3>RAG sur le contenu CMS — vector store, jobs, outil <code>SearchKnowledge</code></h3>
<p>Persona simple : réponses sourcées sur le contenu existant. Pas d'ingestion documentaire, pas d'OCR, pas de TTS — c'est ce qui le rend tenable en V1.</p>
<p><strong>Livré le 10/08</strong> : <code>IngestionService</code> (chargement des collections filles par sous-type, un jeu de morceaux par langue), serveur Hangfire dédié à 2 workers, garde <code>AiTokensPerMonth &gt; 0</code><code>plan-starter</code> est à 0 — et <code>SearchKnowledge</code> ajouté aux outils d'<code>AssistantService</code> plutôt qu'un <code>/ask</code> concurrent, avec sa règle dans les <strong>4</strong> prompts.</p>
<p><strong>Joué contre la vraie base et la vraie API le 10/08 : 52 sections → 2150 morceaux en 53 s.</strong> Deux défauts que seule l'exécution pouvait montrer, corrigés dans la foulée. <strong>1)</strong> Les gabarits de <code>LanguageInit</code> étaient indexés : les <em>cinq</em> premiers résultats d'une question en néerlandais étaient des <code>NL - Title NL - Description</code>, le bonus de langue suffisant à les faire passer devant du vrai contenu français — le cas même que le cross-lingue devait servir. <strong>2)</strong> Le même paragraphe occupait trois des cinq résultats, au même score : <code>DistinctBy(Text)</code> avant le <code>Take</code>. ⚠️ Le post-filtrage HNSW reste non éprouvé <strong>à plusieurs instances</strong> — il n'y en a qu'une en base locale, soit le cas où le problème ne se voit pas.</p>
<p>Les deux angles morts du découpage sont fermés : HTML retiré avant l'embedding, et ligne trop longue recoupée à la fin de phrase — sans quoi un article dépassait l'entrée max de <code>gemini-embedding-001</code> et l'échec emportait les 50 morceaux du lot.</p>
<p><strong>Déclenchement unifié</strong> : l'<code>Enqueue</code> par contrôleur et un intercepteur EF ont coexisté une demi-journée. Retenu : <code>SectionIndexingInterceptor</code> seul — les 5 sous-contrôleurs totalisaient <strong>30 <code>SaveChanges</code> et 0 <code>Enqueue</code></strong>, donc ajouter 40 points d'intérêt à une carte ne réindexait rien.</p>
<span class="src">STATUS.md §1quater — lot 3 · v2/rag-indexing-trigger-decision.md · v2/rag-pgvector-integration-plan.md §3, §4</span>
</article>
<article class="card" data-area="manager backend" data-horizon="v1">
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-warn">Front fait · backend à faire</span></div>
<h3>RGPD &amp; conformité — lot J, dernier avant la bascule</h3>
<p><strong>Reporté en fin de backlog le 11/08.</strong> Tenable <em>parce que</em> la porte « rien en prod avant la fin du backlog » tient : d'ici là la journalisation ne tourne que sur des bases de développement, aucun visiteur réel n'est concerné. Si un déploiement anticipé était décidé, ce lot redeviendrait bloquant.</p>
<p><strong>Déjà fait le 11/08</strong> : CGU §8 réécrites (elles décrivaient un service qui ne collectait que des statistiques anonymes, citaient des durées de plans abandonnés, et ne mentionnaient <em>aucun sous-traitant</em> alors que la question du visiteur part chez Google), texte d'information visiteurs en FR/NL/EN, et purge à 90 jours active sans condition de configuration — une durée écrite dans un contrat n'est pas un réglage.</p>
<p><strong>Reste</strong> : la <strong>table d'agrégats de thèmes</strong> (les CGU promettent que les regroupements survivent à la purge, or <code>ThemeId</code> est une colonne de la ligne supprimée — au 91<sup>e</sup> jour le client perdrait tout), le job de regroupement, un <strong>interrupteur de collecte par instance</strong> (⚠️ le client est responsable de traitement mais ne peut pas refuser la collecte), la mention à afficher côté visiteur, la <strong>relecture juridique du §8</strong> et la vérification des conditions réelles de Google.</p>
<span class="src">v1-plan.md — lot J · cgu-myinfomate.md §8 · mention-information-visiteurs.md</span>
</article>
<article class="card" data-area="manager" data-horizon="v1">
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-warn">Jamais ouvert</span></div>
<h3>Écrans du lot design — vérification à l'œil</h3>
<p>Deux écrans neufs compilent (<code>flutter build web</code> ✅) mais <strong>n'ont jamais été affichés dans un navigateur</strong> — le défaut récurrent de ce tableau. Le <strong>Guide IA</strong> (coquille à onglets, aperçu de conversation, carte de connaissance) et l'<strong>éditeur de parcours</strong> refondu (rail d'étapes, panneau, questions dépliées, enregistrement à la saisie).</p>
<p>Exige un <code>manager-service</code> qui tourne et une session ouverte : <code>flutter run -d chrome</code>, puis comparaison côte à côte avec <code>claude design/guide-ia-screen.html</code> et <code>claude design/sectionparcours-refonte-flux.html</code>. Pour le parcours, rejouer aussi <code>test-plan.md §19.13 cas 0</code>.</p>
<span class="src">v1-plan.md — DB5</span>
</article>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">priorité 2</span><span class="flag f-warn">V2</span></div>
<h3>Ingestion documentaire + OCR</h3>
<p>PdfPig avec cascade OCR Gemini (~0,03 $ / 100 pages). Word, PowerPoint, images. Legacy <code>.doc</code>/<code>.ppt</code> refusés à l'upload.</p>
<span class="src">v2/rag-pgvector-integration-plan.md</span>
</article>
<article class="card" data-area="backend" data-horizon="v2">
<div class="card-meta"><span class="tag">priorité 3</span><span class="flag f-warn">V2</span></div>
<h3>TTS pré-généré — audioguide multilingue</h3>
<p>Le produit que les musées achètent depuis toujours, à coût marginal quasi nul. Aucun concurrent de cette gamme ne le fait.</p>
<span class="src">v2/tts-pregenerated-plan.md</span>
</article>
<article class="card" data-area="backend visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">priorité 4</span><span class="flag f-warn">V2</span></div>
<h3>Génération de visites à la demande</h3>
<p>« Un parcours de 45 min pour familles ». Marche sur le smartphone du visiteur, aucun hardware. Vendable aux villes comme balade sonore IA.</p>
<span class="src">plan-import-ia-stats-subsides.md §3</span>
</article>
<article class="card" data-area="manager backend" data-horizon="v2">
<div class="card-meta"><span class="tag">guide IA</span><span class="flag f-warn">V2</span></div>
<h3>Guides multiples &amp; guide thématique</h3>
<p>Plusieurs personas, assignables à une section ou une configuration. Le <em>modèle</em> part en V1 (liste, pas champ unique) ; le comportement attend — qui répond depuis l'accueil, et sur quel périmètre de contenu, est une vraie question de conception.</p>
<span class="src">v2/tts-pregenerated-plan.md — PersonaConfig</span>
</article>
<article class="card" data-area="visitapp infra" data-horizon="v1">
<div class="card-meta"><span class="tag">visitapp-web</span><span class="flag f-critical">Bloque l'offre Essentiel</span></div>
<h3>Déployer le visiteur web — <code>app.myinfomate.be</code></h3>
<p>L'app <strong>existe</strong> : build ✅, 13 types de section rendus, assistant IA inclus depuis le 06/08. Il ne manque que le Dockerfile et l'entrée Traefik dans le compose. Le plan Essentiel vendu en self-service est web-only — sans ce déploiement, l'onboarding vend un produit inaccessible.</p>
<span class="src">architecture-web-saas.md — À faire</span>
</article>
<article class="card" data-area="visitapp manager" data-horizon="v2">
<div class="card-meta"><span class="tag">tablet-app</span><span class="flag f-warn">V2</span></div>
<h3>Kiosk web (Flutter Web)</h3>
<p>Extension web de <code>tablet-app</code> pour les clients ne voulant pas d'installation native. <strong>Reporté en V2 le 07/08</strong> : aucun client demandeur, et la cible native ne compile même plus — il faudrait réparer ce build avant d'en dériver une version web.</p>
<span class="src">architecture-web-saas.md — Kiosk web</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2</span></div>
<h3>SectionForm — formulaires personnalisés</h3>
<p>Nouveau type de section + table de réponses anonymes, constructeur de formulaire et page de résultats agrégés. <strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant — rien n'oblige à le faire passer avec la migration.</p>
<span class="src">todo-features.md — SectionForm</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2</span></div>
<h3>Ressource 360° — images panoramiques</h3>
<p>Une valeur d'enum (<code>Panorama360</code>) et un branchement dans l'affichage des ressources : s'affiche partout où une ressource s'affiche, sans toucher aux sections. <strong>Reporté en V2 le 07/08</strong> — le plus petit des trois, à reprendre en premier.</p>
<span class="src">todo-features.md — Ressource 360°</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2</span></div>
<h3>AR image tracking (Mind AR)</h3>
<p>Modèle <code>ArAnchor</code>, microservice Node de compilation des <code>.mind</code>, et remplacement du scanner visiteur par une WebView unifiée QR + image. Le plus lourd des trois, fort effet démo. <strong>Reporté en V2 le 07/08</strong> — ni la migration ni la mise en prod n'en dépendent.</p>
<span class="src">todo-features.md — AR</span>
</article>
<article class="card" data-area="backend">
<div class="card-meta"><span class="tag">backend</span><span class="flag f-warn">ouvert par le front du 12/08</span></div>
<h3>Audit &amp; plafond users — les deux dettes serveur</h3>
<p><strong>Le front est livré (12/08), ces deux points ne le sont pas.</strong> (1) <strong>Aucune section n'est journalisée</strong> : <code>AuditedTypes.Contains(entry.Entity.GetType())</code> exige l'égalité exacte, or <code>Section</code> est <strong>abstraite</strong> — le type runtime est toujours <code>SectionMap</code>, <code>SectionQuiz</code>… donc <code>typeof(Section)</code> ne matche jamais. Le journal couvre ressources, configurations, devices, users et instances, mais <strong>pas le contenu</strong> — précisément ce que l'écran devait tracer. Correctif : <code>Any(t =&gt; t.IsInstanceOfType(…))</code>, en décidant si <code>EntityType</code> porte alors <code>SectionMap</code> ou reste normalisé à <code>Section</code>. (2) <strong>Plafond de 5 utilisateurs</strong> : <code>UserController.CreateUser</code> ne compte rien, pas de 422 — le compteur livré est un garde-fou d'interface, un POST direct sur l'API passe toujours. Le retour d'erreur du POST est déjà branché côté front : le 422 sera visible sans retouche.</p>
<span class="src">todo-features.md · v1-plan.md lot F · STATUS.md §1sexies</span>
</article>
<article class="card" data-area="manager" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2 · nouveau 12/08</span></div>
<h3>Écran SuperAdmin — add-on IA &amp; canaux d'une instance</h3>
<p>Donner l'IA à un client Pro se fait en SQL : <code>AiTokensPerMonth</code> n'est dans aucun DTO, <code>IsAssistant</code> est à poser deux fois (instance <em>et</em> <code>ApplicationInstance</code>), et le rattrapage d'indexation ne partant que depuis <code>UpdateInstance</code> en C#, il faut penser au bouton de relance. Exposer le quota dans <code>InstanceDTO</code> ramènerait ça à un formulaire. <strong>Reporté en V2 le 12/08</strong> : la V1 n'a besoin d'aucun add-on — les 4 clients de la bascule sont affectés à leur plan, et l'activation manuelle reste un geste interne, fait une fois, sur une base qu'on contrôle. À faire d'un bloc avec l'endpoint de mise à jour d'<code>ApplicationInstance</code> (canaux activables) : <strong>c'est le même écran</strong>.</p>
<span class="src">STATUS.md §1sexies — add-on IA</span>
</article>
<article class="card" data-area="visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">polish</span><span class="flag f-warn">V2</span></div>
<h3>Talking head — avatar animé</h3>
<p>Trois frames par persona, lipsync sur les timestamps Google TTS. Quelques jours, effet démo. Bouche-trou entre deux chantiers.</p>
<span class="src">v2/talking-head-plan.md</span>
</article>
<article class="card" data-area="doc" data-horizon="v2">
<div class="card-meta"><span class="tag">XR</span><span class="flag f-warn">V2 · subventionné</span></div>
<h3>Ray-Ban Meta &amp; Meta Quest</h3>
<p>Pilote presse uniquement, quand le SDK sort de preview. 90 % de l'expérience est livrable en audio smartphone — profil d'appel à projets, pas de ligne produit.</p>
<span class="src">roadmap.md — section XR</span>
</article>
</div>
</section>
<!-- BASCULE PROD -->
<section class="col" style="--stripe: var(--gate)">
<div class="col-head"><h2>Bascule prod</h2><span class="count">8</span></div>
<div class="stack">
<article class="card" data-area="backend infra">
<div class="card-meta"><span class="tag">étape 0</span><span class="flag f-critical">Sécurité — avant l'étape 16</span></div>
<h3>Rotation des secrets committés en clair</h3>
<p>Clé de signature JWT, clés API Gemini et OpenWeather, connection strings prod, mot de passe MQTT, token Telegram. Le dossier <code>RELEASE/</code> republie d'anciens secrets.</p>
<p><strong>Déplacée depuis « Urgent » le 11/08</strong>, à la demande : les 6 repos sont <strong>privés, sur un Gitea auto-hébergé</strong> — l'exposition réelle est celle d'un poste de dev, pas d'un dépôt public. La gravité ne bouge pas, la fenêtre d'urgence oui.</p>
<p>⚠️ <strong>Ce qui ne se négocie pas</strong> : elle doit précéder la création de l'environnement prod (étape 16), sinon la prod naît avec les clés de l'historique et il faut rotationner deux fois. Et comme le test d'onboarding exige <code>app.myinfomate.be</code> déployé, <strong>ça veut dire avant la fin du lot de tests</strong>, pas au tout dernier moment. Elle remonte en « Urgent » immédiatement si un repo passe public, gagne un collaborateur externe, ou est cloné par un CI tiers.</p>
<span class="src">security/audit-securite-manager-service.md · v1-plan.md L1, I0</span>
</article>
<article class="card" data-area="backend infra">
<div class="card-meta"><span class="tag">étape 15</span><span class="flag f-good">Sans risque</span></div>
<h3>Dry run + comparaison des comptages</h3>
<p><code>dryRun=true</code> n'écrit rien. <strong>🔨 Joué le 11/08</strong> sur l'export du 1ᵉʳ avril, et automatisé en test (<code>MigrationDryRunTests</code>, qui se saute si aucun Mongo n'écoute — la suite reste verte sans dépendance).</p>
<p><strong>Il a trouvé du premier coup ce qu'aucune lecture de code n'avait vu</strong> : 327 sections dans Mongo, <strong>307 migrées, et <code>Erreurs : 0</code></strong>. Les 20 manquantes référencent deux configurations <em>supprimées</em> dans Mongo — 19 articles et un slider, du contenu MDLF bien réel (« Senteur : fraises », « Pupitre vigne », « Ruche 7 »). Ce n'est pas un défaut de la migration : les sections sont collectées configuration par configuration, donc les orphelines n'étaient jamais énumérées. <strong>Le défaut était le silence</strong> — elles partent maintenant dans <code>Skipped</code>, et le test affirme <code>migrées + signalées = Mongo</code>.</p>
<p>⚠️ <strong>Décision produit</strong> : recréer les 2 configurations dans Mongo avant la bascule pour récupérer ces 20 sections, ou acter leur perte. À trancher avec MDLF.</p>
<p>⚠️ <strong>À rejouer sur un dump frais</strong> : l'export a 4 mois et la prod tourne encore sur Mongo. Et <code>MigrationController</code> lit un Mongo <em>vivant</em>, pas ces fichiers — recette d'import dans v1-plan.</p>
<span class="src">STATUS.md §1quinquies — étape 15</span>
</article>
<article class="card" data-area="infra">
<div class="card-meta"><span class="tag">étape 16-17</span><span class="flag f-critical">Après rotation des secrets</span></div>
<h3>Créer l'environnement prod Postgres</h3>
<p><code>Deployment/Dockerfile.postgres</code> (digest épinglé), compose prod, Traefik, <strong>port 5432 fermé</strong>. Puis <code>dotnet ef database update</code> sur la base vide et vérifier que <code>postgis</code> <em>et</em> <code>vector</code> répondent. Ne pas créer la prod avec les clés encore committées.</p>
<span class="src">STATUS.md §1quinquies — étapes 16-17</span>
</article>
<article class="card" data-area="infra">
<div class="card-meta"><span class="tag">étape 18</span><span class="flag f-critical">Filet du jour J</span></div>
<h3>pg_dump avant / après + cron quotidien</h3>
<p>Noté comme « à faire » depuis juillet et jamais fait. Ici ça cesse d'être une bonne pratique : c'est la seule chose qui permette de recommencer si la bascule tourne mal.</p>
<span class="src">STATUS.md §4 · §1quinquies — étape 18</span>
</article>
<article class="card" data-area="backend infra">
<div class="card-meta"><span class="tag">étape 19</span></div>
<h3>Rejouer pour de vrai, instance par instance</h3>
<p>Le paramètre <code>instanceId</code> existe précisément pour ça. Commencer par la plus petite instance : si quelque chose casse, ça casse sur le plus petit périmètre possible.</p>
<span class="src">STATUS.md §1quinquies — étape 19</span>
</article>
<article class="card" data-area="backend infra">
<div class="card-meta"><span class="tag">étape I9</span><span class="flag f-warn">nouveau 12/08</span></div>
<h3>Poser <code>Firebase:StorageBucket</code>, puis lancer le backfill</h3>
<p>Deux gestes que C2 et C3 laissent à la mise en prod. <strong>1)</strong> La clé <code>Firebase:StorageBucket</code> (<code>&lt;project-id&gt;.appspot.com</code>) est <strong>vide par défaut</strong> : sans elle, <code>Delete</code> ne supprime aucun blob — il ne casse rien, il ne nettoie pas. C'est aussi le verrou de la carte « retirer la suppression de blob de manager-app ». <strong>2)</strong> Lancer le backfill : <code>POST /api/Resource/backfill-storage?dryRun=true</code>, relire <code>Orphans</code> et <code>Unsized</code>, puis <code>dryRun=false</code>. Sans lui le quota de C3 porte sur des <code>SizeBytes</code> à 0 pour tout l'existant, donc il laisse tout passer (<strong>L10</strong>).</p>
<p>⚠️ <strong>Piège de build repéré le 12/08</strong> : <code>dotnet restore</code> échoue en 401 si la source NuGet <code>git.dev-espaces-naturels.lu</code> est déclarée dans l'image — elle n'a rien à voir avec ce projet. <code>Google.Cloud.Storage.V1</code> étant le premier paquet ajouté depuis longtemps, le prochain build d'image la rencontrera.</p>
<span class="src">v1-plan.md lot I — I9 · STATUS.md §1sexies</span>
</article>
<article class="card" data-area="backend manager visitapp">
<div class="card-meta"><span class="tag">étape 20</span><span class="flag f-warn">Le vrai test</span></div>
<h3>Vérifications post-bascule</h3>
<p>Login manager-app, une app visiteur avec sa clé API, un parcours, une carte, un PDF, <strong>un quiz avec ses questions</strong>. Les défauts « silencieux » ne se voient qu'ici — pas dans le rapport de migration.</p>
<span class="src">STATUS.md §1quinquies — étape 20</span>
</article>
<article class="card" data-area="infra">
<div class="card-meta"><span class="tag">étape 21-22</span></div>
<h3>Bascule par coexistence sur deux serveurs</h3>
<p><strong>Décidé le 12/08, remplace la bascule DNS.</strong> Un second serveur porte la nouvelle prod Postgres, les <strong>nouvelles versions des apps</strong> pointent vers lui, et l'ancienne prod Mongo continue de servir les apps déjà installées. Aucun DNS ne bouge, pas de fenêtre de maintenance, et un retour arrière qui consiste à ne rien faire.</p>
<p>C'est la seule réponse au problème que <strong>L19</strong> pose : on ne force pas la mise à jour du téléphone d'un visiteur.</p>
<p><strong>Ce qui rend la coexistence saine — une seule source d'écriture</strong> : dès que le back-office pointe sur la nouvelle prod, l'ancienne est figée de fait. Les vieilles apps voient un contenu gelé au jour J, ce qui est tenable quelques semaines, et <strong>les deux bases ne divergent jamais</strong>.</p>
<p>⚠️ <strong>Ce qui ne marche pas : « recopier de la nouvelle prod vers l'ancienne ».</strong> Il n'existe pas de chemin Postgres → Mongo ; <code>MigrationController</code> va dans un seul sens et écrire l'inverse coûterait autant, pour alimenter une base qu'on éteint. L'ancienne prod <strong>se fige, elle ne se synchronise pas</strong>.</p>
<p>À tenir : une <strong>date d'extinction</strong> décidée d'avance (sinon l'ancienne prod survit trois ans), le coût d'un second serveur OVH le temps de la transition, et un <strong>plan de rollback réécrit</strong> — il ne s'agit plus de restaurer un dump mais de republier l'app qui pointe vers l'ancienne adresse, ce qui déplace le risque sur les délais de validation des stores.</p>
<span class="src">v1-plan.md lot I — stratégie de coexistence</span>
</article>
</div>
</section>
</div>
<section class="done">
<h2>Fait récemment</h2>
<p>Quarante chantiers clos entre le 5 et le 12 août 2026.</p>
<div class="done-grid">
<div class="done-item">
<strong>K5 — l'APK <code>mymuseum-visitapp</code> se construit, et le lot n'était pas ce qu'on croyait</strong>
<span><strong>La prémisse était périmée.</strong> Trois documents annonçaient « <code>flutter build apk</code> échoue côté Gradle/NDK », revérifié le 11/08. Les <strong>trois flavors</strong> (<code>dev</code>, <code>mdlf</code>, <code>fortsaintheribert</code>) construisent en exit 0, APK à l'appui. Sa config Android était <strong>déjà à niveau</strong> — Gradle 8.11.1, AGP 8.9.0, <code>-Xmx4096M</code>, <code>enableJetifier=false</code>, aucun forçage d'<code>androidx.lifecycle</code>. Les dix crans du 12/08 ont amené <strong>tablet-app jusqu'à mymuseum</strong> ; il ne restait pas la même migration à refaire ici. Le « réflexe du diff » noté la veille donnait la réponse en deux <code>cat</code> — appliqué, il l'a donnée.</span>
<span><strong>Le contenu réel du lot</strong> : les deux avertissements du build. NDK <strong>27.0.12077973 → 28.2.13676358</strong> (<code>speech_to_text</code> le réclame nommément) et Kotlin <strong>2.1.0 → 2.3.10</strong> (sous le seuil 2.2.20 annoncé comme rupture par Flutter ; <strong>aligne sur tablet-app</strong>, aucune version neuve introduite). Six builds au total — les trois flavors avant, les trois après — logs relus intégralement, codes de sortie lus hors redirection, APK datés vérifiés sur disque : les trois pièges de vérification du 12/08 sont couverts. Effet mesuré : l'APK passe de <strong>382 à 349 Mo</strong>.</span>
<span>⚠️ <strong>Un cran de plus existe, délibérément non pris.</strong> Kotlin réglé, le validateur Flutter en révèle deux autres — Gradle 8.11.1 → 8.14.0 et AGP 8.9.0 → 8.11.1, « soon be dropped ». Même cascade que les dix crans de tablet-app. Arrêté là parce que c'est du <strong>terrain neuf pour les deux repos</strong> (tablet-app est à 8.11.1/8.9.1, mêmes avertissements latents) : le faire ici seul <em>désaligne</em> les deux apps au lieu de les aligner. À faire d'un bloc sur les deux, ou pas du tout — le plan parke déjà cette dette en V2.</span>
<span>⚠️ <strong>Une question ouverte pour K6</strong> : le travail V1 récent de ce repo (D1, lot E, lot A) est committé sur <code>Meta-Rayban-Test</code>, pas sur <code>master</code>. C'est la branche de travail réelle, alors que le §5bis la décrit comme « un POC jamais mergé ». À trancher <strong>avant de publier</strong>. <code>tablet-app</code> est dans le même cas, sur <code>AI-Assistant-test</code>. <strong>Ce que K5 débloque</strong> : D0 / test-plan §21 sur device, donc la mesure des bugs offline D2-D5 et la confirmation du volet visiteur de D1, qui ne tient aujourd'hui que sur <code>flutter analyze</code>.</span>
</div>
<div class="done-item">
<strong>Lot médias terminé côté serveur — backfill (C2) et quota autoritaire (C3)</strong>
<span><strong>C2</strong> : <code>POST /api/Resource/backfill-storage</code>, SuperAdmin, <code>dryRun</code> à <strong>true par défaut</strong> — la migration se joue sur une base vide, ce backfill sur des lignes de production. La méthode annoncée au plan (« <code>SizeBytes</code> par listing du bucket Firebase ») était <strong>inapplicable</strong> : le serveur n'avait aucun client de stockage. Le sondage passe par HEAD, et <strong>le sondeur est extrait plutôt que recopié</strong> (<code>ResourceSizeProbe</code>, partagé avec la migration — même raisonnement que L5). ⚠️ <strong>L'extraction a bouché un trou que personne ne cherchait</strong> : l'original ne notait l'échec que dans son <code>catch</code>, or un HEAD sur un blob absent <strong>ne lève pas</strong> — il répond 404 sans <code>Content-Length</code>. Ces ressources arrivaient à 0 octet <em>sans figurer dans le rapport</em>. Le « 37 sur 45 » du plan n'étant pas vérifiable d'ici, le backfill rend son propre inventaire, <code>Orphans</code> et <code>Unsized</code> séparés — deux causes distinctes, jamais additionnées.</span>
<span><strong>C3</strong> : ⚠️ le pré-vol <strong>existait déjà à moitié et ce n'était écrit nulle part</strong><code>Upload</code> (multipart) contrôlait, <code>Create</code> (JSON) ne contrôlait rien, or c'est le chemin qu'emprunte manager-app. ⚠️ Et les deux lectures du quota <strong>divergeaient</strong> : <code>Upload</code> lisait le <em>plan</em>, <code>GetQuota</code> l'<em>instance</em> avec le plan en repli — une instance à quota surchargé (le mécanisme de l'add-on) affichait un chiffre et se faisait bloquer sur un autre. Fermé par <code>StorageQuota</code>. <code>Delete</code> supprime le blob <strong>avant</strong> la ligne et renvoie <strong>502</strong> en la conservant si le bucket échoue : une ressource encore listée se rattrape, un blob que plus aucune ligne ne désigne est facturé à l'aveugle. ✅ L'angle mort laissé par C1 était une <strong>fausse crainte</strong> : <code>PathFor</code> ne construit qu'un <code>pictures/{instanceId}/{resourceId}</code>, le type n'entre pas dans le chemin. <strong>Aucun secret nouveau</strong> : <code>FirebaseAdmin</code> était déjà là pour le push, <code>Google.Cloud.Storage.V1</code> réutilise le même credential. <code>dotnet test</code> <strong>163/163</strong>.</span>
</div>
<div class="done-item">
<strong>tablet-app construit à nouveau — APK produit pour la première fois depuis avril</strong>
<span><code>flutter build apk --debug</code> <strong>vert</strong>. K1, K2 et K4 sont livrés et <em>prouvés par un build</em>, pas seulement par <code>flutter analyze</code>. L'outillage a demandé <strong>dix crans</strong>, pas les six annoncés en cours de journée : ce compte datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite — heap à 4096M, Jetifier coupé, forçage <code>androidx.lifecycle</code> retiré, Kotlin porté à 2.3.10. ⚠️ <strong>Trois de ces quatre derniers crans étaient de simples alignements sur mymuseum-visitapp</strong>, qui utilise le même plugin mapbox sans rencontrer aucun de ces problèmes : les causes ont été cherchées une par une alors qu'un <code>diff</code> des deux <code>gradle.properties</code> les donnait toutes d'un coup. <strong>Réflexe pour K5 : commencer par ce diff.</strong><br><br>Le cran le plus instructif : un <code>resolutionStrategy</code> forçait <code>androidx.lifecycle</code> à 2.4.0 sous le commentaire « To fix mapbox issue ». Il réglait un problème d'il y a trois ans et <strong>causait</strong> celui d'aujourd'hui — <code>mapbox_maps_flutter</code> 2.21.1 appelle <code>setViewTreeLifecycleOwner</code>, absent de 2.4.0. Du contournement fossilisé, comme les trois blocs commentés qui ont fait mentir les diagnostics le même jour. K4 apporte au passage à la tablette le <strong>puzzle glissant</strong>, le bouton d'indice et un dimensionnement au ratio de l'image.</span>
</div>
<div class="done-item">
<strong>tablet-app rebranché sur le contrat d'API de Postgres v3 (K2)</strong>
<span>Les <strong>132 erreurs Dart</strong> que le Gradle masquait sont tombées : <code>flutter analyze lib</code> ne renvoie plus que les 6 <code>PuzzleDTO</code> de K4. Livré : <code>TabletAppContext.currentAppConfigurationLink</code> (copie du getter de mymuseum), <code>lib/Helpers/geo.dart</code> avec l'extension <code>lat</code>/<code>lng</code>, 67 accès <code>roundedValue</code> rebranchés, <code>main_view</code> et les 4 vues carte portées. La formule de clé par coordonnées de <code>geo_point_filter</code>, <strong>dupliquée à quatre endroits</strong> alors que les deux côtés de l'appariement doivent produire la même valeur, ne vit plus qu'à un seul.<br><br>⚠️ <strong>Deux bugs latents trouvés — la raison pour laquelle un chercher-remplacer aurait été dangereux.</strong> <strong>(1)</strong> <code>applicationInstanceDTO</code> n'était assigné <em>que si</em> l'instance avait l'IA : il servait de drapeau d'assistant. Ce DTO portant désormais les <code>AppConfigurationLink</code>, le nouveau getter aurait renvoyé <code>null</code> sur <strong>toute instance sans IA — donc MDLF et le Fort</strong> — et les cinq réglages d'affichage seraient retombés sur leurs défauts, <em>sans erreur ni log</em>. Drapeau rendu explicite (<code>isAssistantEnabled</code>), en préservant le ET instance × canal. <strong>(2)</strong> <code>ConfigurationDTO.isTablet</code> ayant disparu, le filtre « configurations pour tablette » n'avait plus de source : il porte maintenant sur les liens du canal <code>AppType.Tablet</code>. <strong>À valider sur device</strong> — une configuration non rattachée au canal tablette rendra l'écran de sélection vide.</span>
</div>
<div class="done-item">
<strong>tablet-app : dix crans d'outillage Android rattrapés</strong>
<span>Le premier vrai build depuis des mois. Chaque erreur dictait le correctif suivant : Gradle <strong>7.5 → 8.11.1</strong> (le plugin Kotlin exigeait ≥ 7.6.3), AGP <strong>7.2.0 → 8.5.0</strong> puis <strong>8.7.3</strong> (minimum Flutter) puis <strong>8.9.1</strong> (réclamé par <code>androidx.browser</code> et <code>core-ktx</code>), suppression de <code>android.bundle.enableUncompressedNativeLibs</code> (retirée en AGP 8.1), <code>jcenter()</code><code>mavenCentral()</code> (arrêté depuis 2021), <code>jvmargs</code> <strong>1536M → 4096M</strong> et <code>enableJetifier</code> <strong>true → false</strong> (Jetifier ne sert plus à rien depuis qu'AndroidX est partout, et c'est lui qui saturait la heap), et Kotlin <strong>1.9.0 → 2.3.10</strong> (les dépendances tirent <code>kotlin-stdlib 2.3.10</code>, métadonnées incompatibles avec un compilateur 2.0).</span>
<span>⚠️ <strong>Le cran le plus instructif</strong> : un <code>resolutionStrategy</code> forçait <code>androidx.lifecycle</code> en <strong>2.4.0</strong> avec le commentaire « To fix mapbox issue ». Il réglait un problème mapbox d'il y a trois ans et <strong>causait</strong> celui d'aujourd'hui — <code>mapbox_maps_flutter</code> 2.21.1 appelle <code>setViewTreeLifecycleOwner</code>, absent de 2.4.0. Retiré, avec un commentaire pour qu'on ne le remette pas. ⚠️ Et un <strong>bloc <code>buildscript</code> entièrement commenté</strong> déclarait AGP 8.5.0/Kotlin 2.0.10 pendant que la vraie config vivait dans <code>settings.gradle</code> en 7.2.0/1.9.0 : il a fait conclure deux fois à une configuration qui n'était pas celle du build — supprimé. <strong>Troisième piège « bloc commenté »</strong> du projet.</span>
<span><strong>Trois de ces crans sont des alignements sur <code>mymuseum-visitapp</code></strong>, qui utilise le même plugin mapbox sans rencontrer aucun de ces problèmes. Réflexe à garder : comparer les deux <code>gradle.properties</code> et les deux <code>build.gradle</code> avant de chercher. Ce que ça débloque : <code>compileSdkVersion 36</code>, sans lequel plus aucune mise à jour n'est publiable sur le store. ⚠️ Le compte de « six crans » annoncé plus tôt dans la journée était provisoire — il datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite.</span>
</div>
<div class="done-item">
<strong>Écran d'audit log et compteur d'utilisateurs — le lot F côté manager-app</strong>
<span>Deux chantiers dans la même passe, <code>manager-app</code> seul. <strong>Écran « Activité »</strong> sur <code>GET /api/Audit</code> : filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table, entrée de menu réservée à <code>role.value == 0</code> comme la policy de l'endpoint. Les ids sont résolus en noms, un id inconnu restant affiché tel quel — le journal doit rester lisible après la disparition de ce qu'il décrit. <strong>Compteur « X / 5 utilisateurs »</strong> et bouton d'ajout désactivé au plafond, <strong>SuperAdmin exclu</strong> : sa liste couvre toutes les instances, la compter contre un plafond <em>par instance</em> n'aurait aucun sens. <strong><code>manager_api_new</code> n'a pas été touché</strong><code>http.get</code> direct au Bearer, comme le Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main. 40 clés i18n FR/EN/NL, <code>flutter build web</code> ✅. ⚠️ <strong>Trois pièges relevés en câblant</strong> : <code>AuditController</code> renvoie les entités <strong>brutes</strong>, pas un DTO ; <code>invokeAPI</code> <strong>ne lève pas</strong> sur code d'erreur et son résultat était ignoré, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé ; et <code>DropdownButtonFormField</code> <strong>ne relit pas <code>initialValue</code></strong> sur reconstruction, donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un <code>DropdownButton</code> piloté. Les deux dettes serveur qu'il ouvre sont sur leur propre carte, en Planifié.</span>
</div>
<div class="done-item">
<strong>Alerte de budget GCP posée</strong>
<span>Le bucket est en Europe : aucun quota gratuit, facturation dès le premier octet. L'alerte est posée dans la console GCP — rien à vérifier dans le code, et c'est la seule protection réelle contre une surprise de facture pendant les tests de charge du lot H.</span>
</div>
<div class="done-item">
<strong>Le rattrapage d'indexation ne brûle plus le quota Gemini en boucle</strong>
<span><code>BackfillInstanceAsync</code> parcourait les sections d'une instance <em>en boucle dans un seul job</em>. Avec <code>[AutomaticRetry(Attempts = 2)]</code> posé sur la classe, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par Hangfire — donc <strong>ré-embedder les 279 déjà indexées, deux fois</strong>. Des appels facturés pour rien, et autant de chances de retomber sur la limite de débit qui avait causé le premier échec. Elle met désormais en file <strong>un job par section</strong> : l'unité de reprise devient la section, <code>ReplaceAsync</code> la rend idempotente, et le chemin de backfill cesse d'être un chemin à part — c'est celui de l'intercepteur, déjà éprouvé. Effet de bord utile : l'avancement se lit section par section dans <code>/hangfire</code> au lieu d'un job opaque. Complété par un <strong>backoff sur 429/503</strong> dans <code>GoogleEmbeddingService</code> (3 tentatives, <code>Retry-After</code> s'il est fourni, exponentiel sinon), qui absorbe la rafale courte sans jamais remonter jusqu'à Hangfire. <code>dotnet test</code> 148/148. Découvert en préparant l'activation de l'IA sur MDLF et le Fort — deux instances Pro à doter à la main pour éprouver le post-filtrage HNSW sur 4 instances plutôt que 2.</span>
</div>
<div class="done-item">
<strong>La visite hors ligne embarque enfin ses médias</strong>
<span>Le <code>switch</code> qui collecte les ressources d'une configuration était commenté <strong>des deux côtés</strong> : 157 lignes mortes dans <code>ConfigurationController.Export</code>, 126 dans <code>Import</code>, et le pendant dans <code>downloadConfiguration.dart</code>. Une visite téléchargée n'embarquait donc que l'image de la configuration, celle du loader et l'image de chaque section — <strong>ni contenus d'articles, ni audios, ni icônes de carte, ni images de quiz</strong>. Remplacé par <code>GetReferencedResourceIds()</code>, qui existait déjà sur les 13 sous-types et n'attendait que d'être appelée. Côté import et côté client, la charge est enregistrée <em>en une passe</em> au lieu d'être redécouverte section par section : le serveur sait maintenant ce qu'il envoie, le client n'a plus à le deviner — et ça répare au passage la purge des fichiers obsolètes, pilotée par la même liste d'ids utilisés. 4 tests, dont un qui vérifie <strong>par réflexion que les 13 sous-types implémentent la collecte</strong> : le trou venait d'un <code>switch</code> où un type oublié passait dans le <code>default</code> sans bruit, et c'est exactement ce qu'il ne faut plus pouvoir refaire. <code>dotnet test</code> 148/148. ⚠️ Le volet visiteur ne tient que sur l'analyse — l'APK ne compile pas — <strong>confirmation sur device au §21</strong>, carte « Visite hors ligne sur device » déjà en attente.</span>
</div>
<div class="done-item">
<strong>MigrationController — trois des huit écarts n'existaient pas</strong>
<span>Le plan les décrivait en « perte de données ». L'export Mongo, lu plutôt que supposé, dit qu'il n'y a rien à perdre. <strong>(a) « 11 types de section sur 13 »</strong> : les 327 sections de l'export ne contiennent que les types 0 à 10 — <code>SectionEvent</code> et <code>SectionParcours</code> sont nés avec Postgres v3, le <code>default</code> est un filet et non un trou, et le « scénario carnaval » qu'on croyait menacé est du contenu <em>à créer</em>, pas à migrer. <strong>(f) les trois booléens forcés à false</strong> : ces colonnes n'existent pas dans Mongo, <code>false</code> est le bon défaut — et le contrôle inverse a été fait, les cinq réglages qui <em>existent</em> vraiment dans l'export (<code>IsDate</code>, <code>IsHour</code>, <code>IsSectionImageBackground</code>, <code>RoundedValue</code>, <code>ScreenPercentageSectionsMainPage</code>) sont bien mappés sur <code>AppConfigurationLink</code>. <strong>(g)</strong> était tombé avec le rename du lot B. Trois cartes de la colonne « Bascule prod » se ferment sans une ligne de code.</span>
</div>
<div class="done-item">
<strong>MigrationController — les cinq écarts réels, corrigés</strong>
<span><strong>(b)</strong> <code>QuizQuestions = new()</code> jetait les questions : mesuré sur l'export, <strong>5 sections quiz en portent 41</strong>, réponses comprises. Migrées, en <code>MultipleChoice</code> puisque le type de validation n'existait pas dans l'ancien modèle. Les agendas (aucun événement dans Mongo) et les parcours (inexistants avant) gardent leurs collections vides à raison. <strong>(d)</strong> était pire que décrit : Mongo <strong>n'a pas de champ Role</strong>, donc les 10 utilisateurs y avaient un accès complet, et le <code>ContentEditor</code> en dur les dégradait <em>tous</em> en silence — plus personne n'aurait pu gérer les utilisateurs de son instance. Passé à <code>InstanceAdmin</code> ; aucun SuperAdmin n'est créé par la migration, à poser à la main. <strong>(c)</strong> tout est généré ou dérivé, la source ne contenant que 4 champs. <strong>(e)</strong> <code>StoragePath</code> n'était pas écrit du tout : passe par le calculateur commun, et l'échec du <code>HEAD</code>, jusqu'ici avalé, remonte dans le rapport. <strong>(h)</strong> transaction unique, et surtout <strong>500 au lieu de 200 OK</strong> sur échec fatal — le piège était qu'un script de bascule testant le code HTTP concluait au succès. <code>dotnet test</code> 143/143.</span>
</div>
<div class="done-item">
<strong>Le schéma est gelé — lot B, et le lot G peut enfin commencer</strong>
<span>Le point d'articulation du plan : tant qu'un changement de modèle restait ouvert, réécrire <code>MigrationController</code> revenait à l'écrire deux fois. <strong>Une seule migration EF</strong> porte les trois changements. <code>SectionMap.MapResourceId</code><code>IconResourceId</code>, ce qui fait tomber l'<strong>écart (g)</strong> de la bascule tout seul — et il fallait renommer la propriété de navigation <code>MapResource</code> avec, sinon la convention EF fabriquait un FK fantôme. Point vérifié plutôt que supposé : EF a produit un <code>RenameColumn</code>, <em>pas</em> un drop+add — les icônes déjà configurées survivent. <code>SectionEvent.ParcoursIds</code> supprimé (l'avertissement de perte de données d'EF ne porte que sur lui, c'est l'intention). Filigrane : <code>Instance.IsImageWatermark</code> remplace le <code>instanceId == "633ee379…"</code> en dur. <strong>Référence tenue : 130/130 tests avant comme après</strong>, build Debug <em>et</em> Release verts.</span>
</div>
<div class="done-item">
<strong>Le backdoor d'authentification, et deux fuites de moins</strong>
<span>Trouvé en cherchant le « backdoor <code>#if DEBUG</code> » du rapport d'audit : dans <code>AuthenticationController.Authenticate</code>, un bloc <strong>écrasait l'email et le mot de passe reçus</strong> par un compte de test — toute compilation en Debug authentifiait n'importe quelle saisie en tant que <code>test@email.be</code>. Retiré. <code>EnableSensitiveDataLogging</code> (qui écrit les valeurs des paramètres dans les logs : hashes, clés API, données de visiteurs) passe sous <code>#if DEBUG</code>, l'idiome déjà employé dans ce même fichier pour le CORS et Hangfire — et le Dockerfile publiant en <code>-c Release</code>, c'est un vrai verrou, ce qu'un build Release a confirmé. Les autres <code>#if DEBUG</code> du repo sont légitimes.</span>
</div>
<div class="done-item">
<strong>Le client généré nettoyé — et la règle « pas de régénération » enfin verrouillée</strong>
<span>Le compte exact : <code>api.dart</code> déclarait <strong>139 <code>part</code> pour 153 fichiers</strong>. Les 14 restants portaient <code>part of openapi.api;</code> sans être listés par leur bibliothèque — en Dart, <strong>hors du graphe de compilation</strong> : l'analyzer les lisait, le compilateur ne les voyait jamais. Toute l'asymétrie qui a coûté du temps était là, et avec elle la garantie que les supprimer ne pouvait pas casser un build. Mesuré : <code>flutter analyze</code> sur le package passe de <strong>67 à 3 issues</strong>, et l'analyse globale de manager-app de <em>68 erreurs de bruit</em> à <strong>3 erreurs</strong>, toutes dans <code>test/widget_test.dart</code> (cassé et connu) — <code>flutter analyze</code> redevient un feu vert utilisable, ce qui débloque les lots design. <code>flutter build web</code> ✅ avant comme après.</span>
</div>
<div class="done-item">
<strong>Trois déclencheurs de génération, pas un</strong>
<span>Le verrou ne tenait que dans manager-app. <code>mymuseum-visitapp/lib/api/openApiTest.dart</code> générait depuis <strong>son propre swagger</strong> vers un <code>manager_api_new</code> local — un second client divergent dans un repo qui consomme celui de manager-app par <code>path:</code> — et <code>tablet-app/lib/api/openApi.dart</code> faisait de même sous un autre nom de fichier, ce qui lui avait permis d'échapper à toutes les recherches sur « openApiTest ». Plus un résidu <code>tablet-app/manager_api_new/</code> réduit à un <code>pubspec.lock</code>, non suivi par git, daté d'avril. Les trois sont supprimés : ce ne sont pas des fichiers morts, c'était <em>le moyen d'écraser les éditions manuelles</em><code>onboarding_api.dart</code>, le câblage d'<code>AIApi</code>, le mapping <code>isGood</code><code>isCorrect</code>. Contrôle de non-régression : <code>grep -rl "@Openapi" */lib</code> doit rester vide.</span>
</div>
<div class="done-item">
<strong>Fournisseur de carte en web — tranché, et pas comme prévu</strong>
<span>Option a retenue : <strong>le web reste sur Leaflet quoi que le client configure</strong>. Ni MapBox (token payant) ni Google Maps JS (clé facturée + seconde librairie carto à maintenir) — aucun coût récurrent ajouté avant la prod. Mais l'option annoncée disait « retirer le champ pour les instances web », et <strong>c'est impossible</strong> : le fournisseur est porté par la <em>section</em> (<code>SectionMap.MapMapProvider</code>, <code>SectionAgenda.AgendaMapProvider</code>), pas par l'instance — et une même Configuration est liée à plusieurs <code>ApplicationInstance</code> via <code>AppConfigurationLink</code>, donc servie au mobile <em>comme</em> au web. Masquer le champ aurait supprimé le réglage mobile. Il est donc conservé, et manager-app <strong>cesse de laisser croire qu'il vaut pour le web</strong> : mention explicite sous les deux sélecteurs. Zéro ligne côté visitapp-web — Leaflet inconditionnel était déjà le comportement ; ce qui manquait n'était pas du code mais une phrase honnête dans l'interface.</span>
</div>
<div class="done-item">
<strong>PDF en média d'étape — et le dispatcher qui n'était pas le bon</strong>
<span>Les trois volets sont faits. La liste de types du sélecteur de médias devient un paramètre : l'étape passe désormais « types du slider + PDF » là où c'était figé en dur. Côté web, le cas PDF réutilise le <code>PdfViewer</code> existant <em>et</em> son proxy <code>/api/pdf</code> — le contournement CORS était déjà écrit, il n'y avait qu'à le brancher. <strong>Côté Flutter, la carte indiquait le mauvais endroit</strong> : <code>showElementForResource</code> fait un <code>return CachedCustomResource(...)</code> <em>avant</em> son <code>switch</code>, donc tout ce switch est du code mort depuis un moment — ajouter le cas PDF là n'aurait rien changé et aurait été « vérifié » sans rien prouver. Le vrai dispatcher est <code>CachedCustomResource</code>, et il a <strong>deux</strong> switch (ressource distante / fichier déjà téléchargé pour l'offline) qui tombaient tous deux sur <code>Text("Not supported type")</code> : les deux sont traités, le distant devant télécharger avant d'afficher puisque <code>PDFView</code> n'accepte qu'un chemin de fichier. Au passage : l'enum du client s'appelle <code>ResourceType.Pdf</code>, pas <code>PDF</code>. <code>npm run build</code> ✅, <code>flutter build web</code> ✅ — le volet mymuseum ne tient que sur l'analyse, son APK ne compilant pas.</span>
</div>
<div class="done-item">
<strong>QuestionType — la fin des comparaisons par entier brut</strong>
<span>Descendu du lot B, où il ne restait plus qu'une propreté de front. La cause était dans le client généré : le générateur nomme les valeurs <code>number0/1/2</code>, et le front finissait donc par écrire <code>?.value == 2 ? 'Puzzle' : …</code> — lisible seulement si l'on connaît l'ordre de l'enum serveur par cœur. Des alias nommés d'après le serveur (<code>simple</code>, <code>multipleChoice</code>, <code>puzzle</code>) sont ajoutés à la main dans <code>question_type.dart</code>, sans toucher à <code>values</code> : ce sont les mêmes trois valeurs, pas de nouvelles. Les deux sites visés par le plan sont corrigés, plus les 7 usages <code>number*</code> du dialogue de question — il ne reste <strong>aucun</strong> <code>QuestionType.number</code> dans les trois apps.</span>
</div>
<div class="done-item">
<strong>Parcours — on ne perd plus son travail sans être averti</strong>
<span>Le défaut était pire que décrit : <strong>trois</strong> dialogues faisaient <code>Navigator.pop</code> sur « Annuler » sans rien demander, pas un — Parcours, Étape et Question, chacun jetant tout ce qui était saisi <em>sous</em> lui. Le plus coûteux est le plus profond : une question de quiz avec ses réponses. Ce n'était pas « la croix » comme le disait cette carte, c'était le bouton Annuler, et le clic hors fenêtre était déjà neutralisé — le vrai chemin de perte restant est le <strong>retour arrière du navigateur</strong>, couvert par <code>PopScope</code>. Détection des modifications par instantané JSON, pris <strong>après la première frame</strong> : le dialog Question normalise ses réponses pendant sa construction, un instantané pris avant aurait déclaré « modifié » une fenêtre intacte. Réutilise <code>showConfirmationDialog</code> plutôt que d'ouvrir un second composant de confirmation. 3 clés i18n FR/EN/NL, <code>flutter build web</code> ✅. <strong>Retiré le jour même par la carte suivante</strong> — c'était l'issue prévue : un garde-fou n'a plus d'objet quand il n'y a plus rien à perdre.</span>
</div>
<div class="done-item">
<strong>Parcours — les quatre fenêtres empilées deviennent une seule</strong>
<span>Option A livrée, l'arbitrage tombe avec. Les trois <code>showNewOrUpdate…</code> sont supprimés au profit d'un <strong>rail d'étapes toujours visible</strong> à gauche, d'un panneau de détail à droite, et d'une question qui <strong>se déplie sur place</strong> au lieu d'ouvrir une quatrième fenêtre : la profondeur maximale retombe à <strong>2</strong> — l'éditeur, puis la traduction. Les champs sont d'abord sortis en trois widgets autonomes (<code>ParcoursFields</code>, <code>EtapeFields</code>, <code>QuestionFields</code>) — c'est ce qui a rendu la refonte possible sans tout réécrire, et ce qui resterait transposable si l'écran plein cadre revenait un jour.</span>
<span><strong>Plus de bouton « Sauvegarder » : tout part à la saisie</strong> (débounce 700 ms ; les ajouts, suppressions et réordonnancements partent sans attendre). Deux pièges du backend, trouvés en lisant le contrôleur plutôt qu'en le supposant : <code>UpdateGuidedPath</code> <strong>supprime les étapes absentes du DTO</strong>, donc le payload n'emporte que celles qui ont déjà un id — les autres attendent leur <code>CreateGuidedStep</code> et seraient dupliquées ; et comme les questions n'ont <em>pas</em> d'endpoint propre (c'est voulu, elles partent avec leur étape), leur id entier est récupéré après coup <strong>par <code>order</code></strong>, seul repère stable entre la liste locale et celle du serveur. Le parcours est créé <strong>à la première modification</strong>, pas à l'ouverture : une fenêtre neuve refermée intacte ne laisse rien en base. Si une écriture échoue, la fenêtre refuse de se fermer et le pied de page porte un « Réessayer ». 14 clés i18n FR/EN/NL, 3 retirées, <code>flutter build web</code> ✅. <strong>Reste la vérification à l'œil</strong> (DB5).</span>
</div>
<div class="done-item">
<strong>Plus rien sur un seul disque — les 9 repos sont propres</strong>
<span>La carte la plus ancienne de la colonne « Urgent » se referme. L'alerte du 09/08 (« rien depuis le 17/07 », 75 + 40 + 14 + 4 fichiers) avait déjà été révisée le matin du 11/08 en « ~41 fichiers dont 9 non suivis » ; <strong>les deux versions sont maintenant périmées</strong>. Vérifié au <code>git status --porcelain</code> sur les huit repos : vide partout, et aucun <code>ahead</code>/<code>behind</code> face au remote. Le lot 3 RAG est dans <code>18e4240</code>, le socle visuel et l'écran Guide IA dans <code>eb8e849</code>. Ce qui rendait le point urgent — neuf fichiers non suivis qui n'existaient nulle part ailleurs et disparaissaient au premier <code>git checkout</code> — n'existe plus. À ne pas re-planifier : c'était l'étape 1 du lot A.</span>
</div>
<div class="done-item">
<strong>Les maquettes sortent du compte Claude et entrent dans le repo</strong>
<span>Cause diagnostiquée d'un défaut qui se répétait : les trois maquettes vivaient dans des artifacts d'un <strong>autre compte</strong>, illisibles depuis la machine de dev — chaque session redessinait. D'où un écran Guide IA « livré » à la moitié de sa spec et des stats d'assistant à zéro. Le HTML est rapatrié dans <code>claude design/</code>, JS interactif compris. Même découpage que ce tableau : <strong>contenu dans le repo, diffusion par artifact</strong>. Constat en ouvrant les fichiers : Guide IA et Statistiques déclarent des tokens <strong>rigoureusement identiques</strong> — c'était déjà un design system, jamais porté en Dart — et <code>--brand #264863</code> <em>est</em> <code>kPrimaryColor</code>.</span>
</div>
<div class="done-item">
<strong>Socle visuel — 11 tailles de police deviennent 11 rôles</strong>
<span>Mesuré dans <code>lib/Components/</code> : onze tailles différentes (9 à 25 px), aucune échelle, <code>constants.dart</code> portant encore <code>// TO FILL WITH CORRECT COLOR</code> et deux styles. L'échelle, les espacements et les rayons sont extraits du CSS des maquettes ; <strong>les couleurs restent celles de l'app</strong> — un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent existant sont reprises : remplissages discrets, bordures, gris atténué, ambre. Pas de thème sombre. La reprise des écrans existants reste son propre chantier, pas un effet de bord.</span>
</div>
<div class="done-item">
<strong>Écran Guide IA — la coquille à onglets, et ce que le guide sait vraiment</strong>
<span>L'écran passe de cinq cartes en colonne à la structure de la maquette : deux onglets, deux colonnes. <strong>L'aperçu de conversation interroge le vrai <code>/api/AI/chat</code></strong>, pas une simulation — au passage, <code>AIApi</code> était dans le client généré mais <strong>exposé nulle part</strong> dans <code>client.dart</code>, où les 18 autres façades sont. Câblé. Nouveau <code>GET /api/Ai/knowledge/{id}</code> pour la carte « Ce que connaît votre guide » : tout est agrégé sur <code>ContentEmbedding</code>, donc sur ce qui est <em>réellement indexé</em> — compter les sections publiées donnerait un chiffre plus flatteur et faux, une section désactivée étant purgée de l'index. Le « points d'intérêt » de la maquette devient « morceaux de contenu » : les points d'une carte sont indexés dans le texte de leur SectionMap, les compter à part répondrait à une autre question. 34 clés i18n FR/EN/NL, <code>dotnet test</code> 130/130, <code>flutter build web</code> ✅.</span>
</div>
<div class="done-item">
<strong>Le changement de plan donne enfin ce qu'il vend</strong>
<span>Trois défauts qui se tenaient la main. <strong>1)</strong> <code>Updateinstance</code> changeait <code>SubscriptionPlanId</code> mais ne recopiait jamais les quotas du nouveau plan — <code>CreateInstance</code> le faisait, pas lui : passer un client de Starter à Premium ne lui donnait ni stockage ni jetons IA. <strong>2)</strong> <code>CheckQuota</code> ne bloquait que si <code>quota &gt; 0</code> : à 0 jeton, <strong>ni blocage ni compteur</strong> — l'ambiguïté vient du code lui-même, où <code>0</code> veut dire <em>illimité</em> pour le stockage et <em>pas d'IA</em> pour les jetons. Une instance Starter avec <code>IsAssistant</code> à true consommait donc gratuitement et sans compteur, et <strong>toute instance migrée depuis Mongo arrive à 0</strong> (écart c du §1quinquies). Corrigé en 403 avant tout appel au modèle. <strong>3)</strong> <code>BackfillInstanceAsync</code> n'était appelé par personne : branché automatiquement quand <code>AiTokensPerMonth</code> passe de 0 à une valeur, plus un bouton de relance <strong>réservé au SuperAdmin</strong> dans l'écran Guide IA — outil de réparation, pas fonctionnalité : exposé au client il serait cliqué à chaque réponse décevante, pour un coût d'embedding complet et sans rien améliorer. <code>IBackgroundJobClient</code> injecté au lieu de la façade statique, qui lève sans <code>JobStorage</code> et cassait les tests. 130/130.</span>
</div>
<div class="done-item">
<strong>La dimension 768 n'est plus un pari</strong>
<span><code>IEmbeddingService</code> + <code>GoogleEmbeddingService</code> (batch par 50), et surtout <strong>vérifiés contre l'API réelle</strong>. Trois choses qu'aucun plan ne disait : le modèle sort en <strong>3072 dimensions par défaut</strong><code>dimensions: 768</code> est obligatoire ; les vecteurs réduits <strong>ne sont pas normalisés</strong> (norme mesurée 0,578, pas 1) et sans normalisation côté client le classement cosinus serait faussé <em>sans lever d'erreur</em> ; et Google ne renvoie pas le champ <code>index</code> du contrat OpenAI. Recherche cross-lingue jouée en base : question NL → contenu NL 0,8145, FR pertinent 0,7982, FR hors-sujet 0,4818. L'écart NL/FR n'étant que de 0,016, le <strong>bonus de score par langue</strong> est bien nécessaire.</span>
</div>
<div class="done-item">
<strong>L'historique des stats passe à 13 mois pour tous</strong>
<span>L'axe « durée » disparaît du découpage commercial : <code>HasAdvancedStats</code> devient le seul différenciateur stats, et <strong>l'export PDF est inclus dès qu'il y a les stats</strong>. Motif : un dossier de subside est annuel — plafonné à 30 jours le rapport n'était envoyable à personne. Et la landing ne promettait aucune durée (la chaîne « 30 jours » existe dans <code>translations.ts</code> mais n'est affichée nulle part). Migration <code>UnifyStatsRetentionTo13Months</code> — elle met à jour les plans <em>et reprend les instances en SQL</em>, sans quoi les clients actuels seraient restés à 30 jours : c'est <code>Instance.StatsHistoryDays</code> que lit le contrôleur. <code>dotnet test</code> 124/124. ⛔ La purge associée est codée mais <strong>inactive</strong> jusqu'à la mise en place du pg_dump.</span>
</div>
<div class="done-item">
<strong>Configuration des parcours — 4 correctifs</strong>
<span>Relecture du 09/08 de l'écran refondu le 06/08. Le plus sérieux : un parcours créé sans toucher à la question de progression <em>affichait</em> « Dans l'ordre » et <em>enregistrait</em> « Libre ». Les 12 tests de <code>progression_mode</code> ne pouvaient pas le voir — ils couvrent la relecture des booléens, pas le DTO fabriqué par la popup. Aussi : vidéos et audios d'étape rendus en dur comme des images dans les deux apps visiteur, « Quelle ambiance ? » restée en case à cocher, menu « Carte de base » disparaissant sans un mot. À valider — test-plan §19.13 cas 0.</span>
</div>
<div class="done-item">
<strong>Le schéma du RAG est en base</strong>
<span>Image <code>postgis + pgvector</code> (0.8.6, base <strong>épinglée par digest</strong>), table <code>ContentEmbedding</code> avec index HNSW, 7 colonnes IA/stockage sur <code>Resource</code>, table <code>VisitorQuestion</code>. 62 migrations, <code>dotnet test</code> 124/124, données intactes. Deux surprises : la base locale avait <strong>une migration de retard</strong> — les colonnes <code>Guide*</code> du lot 1 n'existaient pas, toute requête sur <code>Instances</code> aurait planté ; et un <strong>warning de collation</strong> (glibc 2.36→2.31) qui désalignait les index texte, corrigé par <code>REINDEX</code> puis <code>REFRESH COLLATION VERSION</code> — dans cet ordre. D'où l'épinglage par digest.</span>
</div>
<div class="done-item">
<strong>Le guide lit enfin sa configuration</strong>
<span>L'écran Guide IA ne pilote plus dans le vide. En ouvrant le fichier : <strong>quatre</strong> blocs de prompt et non deux — le relevé s'était arrêté au scope configuration. Les quatre lisent le nom, la personnalité et les messages de repli du client ; le ton imposé (« tu es chaleureux ») est retiré, la politique de repli divergente de la ligne 550 réconciliée. <strong>Deux prompts sur quatre n'avaient aucune règle hors-sujet</strong> — sur web et mobile, un visiteur pouvait demander une recette de tarte. Le tirage du repli est fait côté serveur pour que la phrase du client soit reprise mot pour mot. <code>dotnet test</code> 124/124, jamais vu tourner.</span>
</div>
<div class="done-item">
<strong>Écran Statistiques refondu, export PDF compris</strong>
<span>Les 7 défauts corrigés, <em>aucun changement backend</em> : barres horizontales monochromes, filtres sur une seule ligne avec le volume de chaque canal, règle mono-canal, 4 KPI portant chacun leur variation, bandeau « à retenir », courbe en aire. La période précédente s'obtient en rappelant le même endpoint. <strong>Le rapport PDF se génère dans le navigateur</strong> (paquet <code>pdf</code> Dart déjà présent) et partage les valeurs calculées de l'écran — un chiffre ne peut pas diverger entre l'écran et le document envoyé à la commune. Le test de génération a attrapé deux plantages qui seraient sortis au premier clic. Au passage : <code>VisitEvent</code> porte bien l'<code>AppType</code>, le canal vocal est branché ; et <strong>deux puces du sommaire promettaient des données inexistantes</strong> (parcours terminés, questions au guide IA), retirées.</span>
</div>
<div class="done-item">
<strong>Les builds sont débloqués</strong>
<span>3 apps Flutter + visitapp-web + <code>dotnet test</code> 124/124. Un seul fichier sans <code>// @dart=2.18</code> cassait les trois apps Flutter d'un coup.</span>
</div>
<div class="done-item">
<strong>Bug backend révélé par les tests</strong>
<span><code>BuildAuditEntries()</code> modifiait le <code>ChangeTracker</code> pendant son énumération — exception sur toute écriture d'entité auditée. Invisible tant que les tests ne compilaient plus.</span>
</div>
<div class="done-item">
<strong>Assistant IA en web + alignement mobile</strong>
<span>Débloque l'add-on IA sur le plan Essentiel, qui est web-only. Suggestions dérivées du contenu réel, zéro configuration client.</span>
</div>
<div class="done-item">
<strong>Refonte de la progression des parcours</strong>
<span>9 booléens sur 3 niveaux remplacés par 3 questions. <code>IsStepLocked</code> supprimé : il rendait une étape définitivement infranchissable même après réussite.</span>
</div>
<div class="done-item">
<strong>Centrage de carte, horaires, icônes</strong>
<span>M1, M2 et W2 corrigés — des champs que le client saisissait sans qu'ils aient le moindre effet.</span>
</div>
<div class="done-item">
<strong>Audit de cohérence de la doc</strong>
<span>Le LLM annoncé était Claude à 4 endroits (c'est Gemini), l'infra email « inexistante » (Resend est en place), et deux docs se contredisaient sur l'état des builds.</span>
</div>
<div class="done-item">
<strong>CGU reprises et landing corrigée</strong>
<span>Les CGU décrivaient des plans abandonnés (Starter/Standard/Premium 69/99/199 €). Alignées sur la landing, avec un quota IA défini en tokens renvoyant au back-office plutôt qu'un chiffre figé dans un contrat. Landing : « req/mois » → « questions de visiteurs / mois » en 4 langues.</span>
</div>
<div class="done-item">
<strong>Écran Guide IA — onglet Configuration livré</strong>
<span>Migration additive (4 colonnes nullable), champs sur <code>Instance</code>, client généré étendu à la main, écran avec 35 clés i18n FR/EN/NL. <code>dotnet build</code> et <code>flutter build web</code> passent. Menu conditionné à <code>isAssistant</code>, le même drapeau que la garde d'<code>AiController</code>.</span>
</div>
<div class="done-item">
<strong>Trois plans écrits, deux écrans maquettés</strong>
<span>Médias &amp; stockage, visite hors ligne, écran Guide IA, écran Statistiques. Le découpage V1/V2 est tranché : le RAG sur contenu CMS part en V1, l'ingestion documentaire en V2. <strong>Les deux écrans maquettés sont désormais implémentés.</strong></span>
</div>
</div>
</section>
<footer class="foot">
<span>Source de vérité : <code>DOCS/STATUS.md §1ter</code> · chantier Guide IA : <code>§1quater</code></span>
<span>Maquettes : <a href="https://claude.ai/code/artifact/c43fe02d-3283-48c2-992f-d687e18acdfa">Guide IA</a> · <a href="https://claude.ai/code/artifact/052fe8f3-0288-4847-b63f-4c7de06068ec">Statistiques</a></span>
<span>Règle de découpage : ce qui touche aux données écrites part avec la migration, ce qui est additif attend.</span>
<span>Rien ne part en prod avant que ce tableau soit vide.</span>
</footer>
</div>
<script>
(function () {
var areaButtons = Array.prototype.slice.call(document.querySelectorAll('[data-filter]'));
var horizonButtons = Array.prototype.slice.call(document.querySelectorAll('[data-horizon-filter]'));
var cards = Array.prototype.slice.call(document.querySelectorAll('.card'));
var cols = Array.prototype.slice.call(document.querySelectorAll('.col'));
var stats = Array.prototype.slice.call(document.querySelectorAll('.stat'));
var done = document.querySelector('.done');
var emptyMsg = document.querySelector('.empty-msg');
var area = 'all';
var horizon = 'all';
function apply() {
cards.forEach(function (card) {
var areas = (card.getAttribute('data-area') || '').split(/\s+/);
var cardHorizon = card.getAttribute('data-horizon') || 'v1';
var matchesArea = area === 'all' || areas.indexOf(area) !== -1;
var matchesHorizon = horizon === 'all' || cardHorizon === horizon;
card.classList.toggle('hidden', !(matchesArea && matchesHorizon));
});
var total = 0;
cols.forEach(function (col, i) {
var visible = col.querySelectorAll('.card:not(.hidden)').length;
total += visible;
var count = col.querySelector('.count');
if (count) { count.textContent = visible; }
col.classList.toggle('hidden', visible === 0);
var stat = stats[i];
if (stat) {
var n = stat.querySelector('.n');
if (n) { n.textContent = visible; }
stat.classList.toggle('stat-muted', visible === 0);
}
});
// « Fait récemment » n'a pas d'horizon : c'est du passé, pas du reste-à-faire.
if (done) { done.classList.toggle('hidden', horizon !== 'all'); }
if (stats[5]) { stats[5].classList.toggle('hidden', horizon !== 'all'); }
if (emptyMsg) { emptyMsg.classList.toggle('hidden', total !== 0); }
}
function bind(buttons, set) {
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
set(btn);
apply();
});
});
}
bind(areaButtons, function (btn) { area = btn.getAttribute('data-filter'); });
bind(horizonButtons, function (btn) { horizon = btn.getAttribute('data-horizon-filter'); });
apply();
})();
</script>