Cartes 600, 610 et 620 closes (back-office XR, ressource 360, appairage tablette), 250 et 320 retirées du planifié, 015 et 330 à jour ; STATUS, roadmap, test-plan, todo-features et plans VR / frontière immersif alignés ; kanban.html regénéré. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
1728 lines
254 KiB
HTML
1728 lines
254 KiB
HTML
<!-- FICHIER GENERE — ne pas editer a la main.
|
||
Source : DOCS/kanban/ (un chantier = un fichier .md)
|
||
Regenerer : python3 DOCS/tools/build_kanban.py -->
|
||
<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>
|
||
<p><strong>🎉 Le développement V1 est clos depuis le 13/08.</strong> Les lots F et J sont terminés. <strong>Un bug rouvert le 25/08</strong> : la jauge de stockage du menu latéral ne se rafraîchit pas après un ajout de média — affichage seul, le comptage serveur est juste. Ce qui suit est du <strong>test</strong> (dont le §21 sur device, qui n'a jamais été joué), du <strong>juridique</strong> (relecture du §8 des CGU, conditions Google, DPA) et de l'<strong>infra</strong> (secrets, pg_dump, bascule) — plus une ligne de code applicatif à écrire, hors K9 qui est du confort visuel sacrifiable. <strong>Une exception assumée le 25/08</strong> : le dock de filtres en paysage de <code>visitapp-web</code>, écrit parce que l'assistant recouvrait un bouton de la carte — livré, mais jamais vu tourner, la base étant vide.</p>
|
||
<p><strong>Ajouté le 28/08, côté commercial :</strong> un chantier de <strong>grille tarifaire</strong> sur la landing — les frais de mise en place y sont annoncés sans chiffre, ce que l'acheteur découvre en rendez-vous — et le volet <strong>commercial du TTS pré-généré</strong> (facturer la génération et non le stockage, self-service en prérequis d'annonce, argument accessibilité). Aucune ligne de code applicatif : de la copie de site et une règle de facturation.</p>
|
||
<p><strong>Et un chantier matériel, ouvert le même jour :</strong> les <strong>iBeacons n'ont jamais été testés sur un device</strong> alors qu'ils sont vendus dans Pro et Premium — avec un piège iOS lisible dans le code (UUID en dur, filtrage absent sur Android). Le test suppose des balises en main : d'où un <strong>sourcing à lancer</strong> (Holyiot, puis Feasycom <code>FSC-BP104D</code>), dont une conclusion technique ferme — <strong>la télémétrie de batterie doit passer par le <code>major</code></strong>, le <code>minor</code> servant déjà à identifier le point d'intérêt.</p>
|
||
<div class="stamp">
|
||
Mise à jour · 2026-08-28<br>
|
||
Dev V1 clos · pas encore en prod
|
||
</div>
|
||
</header>
|
||
|
||
<section class="summary" aria-label="Chiffres clés">
|
||
<div class="stat"><span class="n n-critical">3</span><span class="k">Urgent</span></div>
|
||
<div class="stat"><span class="n n-info">1</span><span class="k">Migration v3</span></div>
|
||
<div class="stat"><span class="n n-warn">2</span><span class="k">Bugs ouverts</span></div>
|
||
<div class="stat"><span class="n">12</span><span class="k">À tester</span></div>
|
||
<div class="stat"><span class="n">39</span><span class="k">Planifié</span></div>
|
||
<div class="stat"><span class="n n-gate">9</span><span class="k">Bascule prod</span></div>
|
||
<div class="stat"><span class="n n-good">66</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 & ops</button>
|
||
<button class="chip-btn" type="button" data-filter="doc" aria-pressed="false">doc & 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">3</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="backend">
|
||
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">sécurité</span><span class="flag f-critical">Le contenu de n'importe quelle instance est lisible par qui connaît son identifiant</span></div>
|
||
<h3>L'API publique répond <strong>sans clé API</strong></h3>
|
||
<p>Constaté le 08/09 sur la préprod, depuis internet, sans authentification :</p>
|
||
<pre>curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047"
|
||
→ 200, avec le contenu complet et ses traductions</pre>
|
||
<p>La même requête <strong>avec</strong> <code>X-Api-Key</code> renvoie le même 200. <strong>Cause exacte, vérifiée le 08/09</strong> : <code>Get</code> (<code>ConfigurationController.cs:52</code>) et <code>GetDetailAsync</code> (<code>:117</code>) portent un <code>[AllowAnonymous]</code> <em>explicite</em>. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (<code>client.dart:54</code>), et le mécanisme existe et fonctionne : <code>ApiKeyAuthenticationHandler</code> + la policy <code>AppReadAccess</code>, déjà utilisée par <code>byPin</code> (<code>:83</code>) et <code>export</code> (<code>:383</code>) du même contrôleur.</p>
|
||
<p><strong>Ce que ça expose</strong> : le contenu éditorial de n'importe quelle instance, à qui connaît un <code>instanceId</code> — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.</p>
|
||
|
||
<p>✅ <strong>Audit complet fait le 12/09</strong> — <strong>40 routes anonymes</strong> recensées, dont <strong>2 seulement contrôlent la clé</strong> (<code>Configuration/{id}/export</code> et <code>Instance/{id}</code>, tous deux corrigés en septembre). Le trou est donc <strong>général, pas ponctuel</strong> : 24 routes de contenu sont ouvertes — <code>Configuration</code> (2), <code>Section</code> (4), <code>Resource</code> (2), <code>SectionMap</code> (3), <code>SectionEvent</code> (3), <code>SectionAgenda</code> (2), <code>SectionParcours</code> (2), <code>SectionQuiz</code> (1), <code>ApplicationInstance</code> (2).</p>
|
||
|
||
<p>🔴 <strong>Trouvaille plus grave que la carte, corrigée le 12/09.</strong> <code>GET /api/Instance/slug/{slug}</code> était anonyme <strong>et sans filtrage</strong> : il rendait le <strong>pinCode</strong> — celui qui ouvre l'appairage des tablettes et des casques — plus l'<strong>adresse de facturation</strong>, le <strong>numéro de TVA</strong>, les quotas et le plan du client. Et le slug n'est pas un secret : <strong>c'est l'URL du site visiteur</strong> (<code>app.myinfomate.be/{slug}</code>). La fonction de filtrage <code>StripCommercialFields</code> existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur <code>slug</code> et sur <code>byPin</code>, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : <code>publicApiKey</code>, sans laquelle <code>visitapp-web</code> ne peut pas s'amorcer, et <code>isTrialActive</code>, qui sert au filigrane d'essai affiché au visiteur.</p>
|
||
|
||
<p>⚠️ <strong>Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas.</strong> La clé s'obtient <strong>anonymement</strong> : par le slug (qui est dans l'URL) ou par <code>app-key</code> avec le pincode. Après fermeture, le contenu ne sera donc pas confidentiel : il sera lisible par qui lit une URL, au lieu de qui devine un <code>instanceId</code>. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et <strong>révocable</strong>, et un seul chemin d'entrée au lieu de vingt-quatre. Rendre le contenu réellement privé serait un autre chantier, avec une autre décision produit.</p>
|
||
|
||
<p><strong>Reste à faire</strong> : fermer les 24 routes de contenu. Deux obstacles connus. <strong>Un</strong> : <code>[Authorize]</code> de classe et d'action se <strong>combinent</strong> — c'est pour ça qu'<code>export</code> passe par <code>[AllowAnonymous]</code> + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. <strong>Deux</strong> : deux routes doivent <strong>rester</strong> anonymes et c'est écrit dans le code de <code>visitapp-web</code> — <code>instance/slug/{slug}</code> (l'amorce, qui distribue la clé) et <code>ApplicationInstance?instanceId=</code> (la page <code>/download</code>, où le visiteur arrive d'un QR imprimé sans slug ni clé).</p>
|
||
<span class="src">conversation 08/09 — vérification de la préprod depuis internet · audit complet 12/09</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend infra">
|
||
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">sécurité</span><span class="tag">Studio</span><span class="flag f-critical">À régler impérativement avant/avec la mise en production de la version actuelle</span></div>
|
||
<h3>Le bucket Firebase est <strong>ouvert en écriture et en suppression à tous</strong></h3>
|
||
<p>Relevé le 14/09 sur le projet <code>mymuseum-3b97f</code> (release <code>firebase.storage/mymuseum-3b97f.appspot.com</code>, ruleset <code>025f98db-…</code>, <strong>inchangé depuis le 08/01/2024</strong>) :</p>
|
||
<pre>match /{allPaths=**} {
|
||
allow read, write; //: if request.time < timestamp.date(2024, 1, 12)
|
||
}</pre>
|
||
<p>La garde temporelle du template Firebase a été <strong>commentée</strong> pour qu'elle n'expire pas. N'importe qui connaissant le nom du bucket — il est dans l'URL de chaque image, donc public — peut <strong>lire, lister, écrire et supprimer</strong> le contenu de tous les clients. Vérifié sans aucune authentification : <code>GET firebasestorage.googleapis.com/v0/b/mymuseum-3b97f.appspot.com/o</code> répond <strong>200</strong> avec la liste des fichiers.</p>
|
||
<p><strong>Pourquoi ce n'est pas déjà fermé</strong> : le manager-app déployé écrit dans le bucket <strong>sans Firebase Auth</strong>. Fermer avant de déployer casse ses uploads. La fermeture est donc séquencée <strong>après</strong> la mise en production du lot 0 du Studio (upload par URL signée + ingestion serveur), comme le prévoit <code>v2/studio-plan.md</code> §8. ⚠️ <strong>La date de ce déploiement est donc une date de sécurité, pas seulement une date de feature</strong> — ne pas mettre la version actuelle en production sans traiter ce point dans la foulée.</p>
|
||
<p><strong>Piège à traiter dans le même geste</strong> : le CORS du bucket de prod n'a <strong>aucun <code>responseHeader</code></strong>. Le jour du déploiement, le <code>PUT</code> signé portant <code>Content-Type</code> et <code>x-goog-content-length-range</code> sera refusé au préflight et <strong>tous les uploads casseront</strong>. À corriger sur le bucket avant la bascule. Le bucket de dev <code>mymuseum-3b97f-dev</code>, lui, est déjà configuré correctement (CORS, cycle de vie <code>incoming/</code>, règles fermées) et sert de modèle.</p>
|
||
<p>Une piste applicable <strong>avant</strong> le déploiement, à vérifier : restreindre <code>read</code>/<code>list</code> seul. Les apps visiteur lisent par URL à jeton, qui ne passe pas par les règles ; reste à confirmer qu'aucun code client ne lit par le SDK Firebase.</p>
|
||
<span class="src">conversation 14/09 — relevé des règles via l'API firebaserules</span>
|
||
</article>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- MIGRATION -->
|
||
<section class="col" style="--stripe: var(--info)">
|
||
<div class="col-head"><h2>Migration v3</h2><span class="count">1</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">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">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-warn">Affichage seul</span></div>
|
||
<h3>La jauge de stockage ne bouge pas après un ajout de ressource</h3>
|
||
<p><code>QuotaBarsWidget</code> appelle <code>instanceGetQuota</code> <strong>une seule fois, dans <code>initState</code></strong> (<code>quota_bars_widget.dart:23</code>), et il est monté dans le pied du menu latéral <em>persistant</em> (<code>main_screen.dart:477</code>) — jamais reconstruit en naviguant. Le chiffre affiché date de l'ouverture de la session ; ajouter ou supprimer un média ne le fait pas bouger avant un rechargement complet de la page.</p>
|
||
<p>⚠️ <strong>Le comptage lui-même est correct — ne pas partir chercher le bug côté serveur.</strong> <code>storageUsedBytes</code> est <strong>recalculé à chaque appel</strong> (<code>SUM(SizeBytes)</code>, <code>InstanceController.cs:383</code>), et <code>SizeBytes</code> est renseigné à la création sur les deux chemins (multipart <code>:277</code>, JSON <code>:341</code>) puis confirmé à l'<code>Update</code> avec la taille réelle <em>après</em> compression. Le contrôle d'upload, lui, <strong>refait un <code>GET /quota</code> juste avant de téléverser</strong> (<code>resources_screen.dart:186-198</code>) : d'où l'incohérence visible pour le client — une jauge à « 0 KB » et un refus 413 sur le même chiffre, lu à deux moments différents.</p>
|
||
<p><strong>Correctif</strong> : exposer un rafraîchissement de la jauge et le déclencher en fin de <code>create()</code> et de suppression de ressource. Pas de plomberie d'état à introduire pour ça.</p>
|
||
<span class="src">todo-features.md — Quota stockage</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">offline</span><span class="tag">store</span><span class="flag f-critical">condition de publication sur le Play Store</span></div>
|
||
<h3>La taille du téléchargement n'est jamais affichée</h3>
|
||
<p><strong>Le visiteur ne sait jamais combien il télécharge.</strong> Le dialogue de <code>configurations_list.dart</code> annonce « il est nécessaire de télécharger du contenu supplémentaire » sans chiffre, et l'écran de progression n'affiche qu'un pourcentage. Or une visite du Fourneau, c'est un ordre de grandeur de plusieurs centaines de Mo sur la 4G du visiteur.</p>
|
||
<p><strong>Google Play exige que la taille des téléchargements complémentaires soit annoncée avant de les lancer</strong> — c'est une condition de publication, pas un confort.</p>
|
||
<p><strong>Rien à ajouter côté serveur</strong> : <code>ResourceDTO.sizeBytes</code> existe déjà et arrive dans <code>ExportConfigurationDTO.resources</code>. La somme se calcule côté client, et sur les seules ressources réellement à télécharger — <code>isResourceOutdated</code> filtre déjà celles qui sont à jour, donc une mise à jour de visite doit annoncer le <em>delta</em>, pas le total.</p>
|
||
<p><strong>Deux endroits</strong> : le dialogue de confirmation (avant), et l'écran de progression (« 128 / 412 Mo » plutôt qu'un pourcentage nu). ⚠️ La taille annoncée dans le dialogue suppose d'avoir déjà appelé <code>configurationExport</code> pour connaître la liste — aujourd'hui cet appel n'a lieu qu'<em>après</em> la confirmation.</p>
|
||
<span class="src">relevé le 2026-09-04 — configurations_list.dart, downloadConfiguration.dart</span>
|
||
</article>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- À TESTER -->
|
||
<section class="col" style="--stripe: var(--brand)">
|
||
<div class="col-head"><h2>À tester</h2><span class="count">12</span></div>
|
||
<div class="stack">
|
||
|
||
<article class="card" data-area="visitapp">
|
||
<div class="card-meta"><span class="tag">visitapp-web</span><span class="tag">UI</span><span class="flag f-warn">Jamais vu tourner</span></div>
|
||
<h3>Dock de filtres en paysage — carte et parcours</h3>
|
||
<p><strong>Deux calques se disputaient le coin bas-droit.</strong> Le lanceur de l'assistant est monté pour <em>toutes</em> les sections (<code>[configId]/layout.tsx</code>, <code>right:20/bottom:20/z-1200</code>) et recouvrait la bascule Carte/Liste de <code>MapSection</code> (<code>z-1000</code>) : c'est elle qui disparaissait. En parallèle le panneau de filtres, posé <code>left-3 right-3</code>, masquait en paysage la carte qu'on filtrait.</p>
|
||
<p><strong>Le parti vient de tablet-app</strong> (<code>Screens/Map/geo_point_filter.dart</code>) : en paysage, colonne vitrée à gauche — recherche, catégories, liste des points — la bascule Carte/Liste disparaît puisque le dock <em>est</em> la liste, et la fiche du point passe en colonne droite. Portrait inchangé, feuille basse. Même traitement pour la progression cartographiée de <code>ParcoursSection</code> (étapes à gauche, étape en cours à droite).</p>
|
||
<p>⚠️ <strong>Rien n'a été vu tourner</strong> : <code>next build</code> passe, mais <code>/api/ApplicationInstance</code> répond <code>[]</code> — la base est vide, il n'existe aucune instance à ouvrir. À reprendre dès qu'un jeu de données existe, et à éprouver dans les <strong>quatre</strong> combinaisons (téléphone/tablette × portrait/paysage), plus le cas < 420 px de haut où le dock doit démarrer replié.</p>
|
||
<p><strong>Primitives partagées, à réutiliser plutôt qu'à recopier</strong> : <code>ui/FloatingPanel.tsx</code> (dock ↔ feuille basse selon l'orientation), <code>ui/PointFilter.tsx</code> (recherche sans accents, cases, groupes), et surtout <code>--mim-assistant-inset-y/-x</code> + <code>.mim-clear-assistant</code> dans <code>globals.css</code> — l'encombrement de l'assistant est déclaré <strong>une fois</strong>, sinon la collision revient à la section suivante.</p>
|
||
<span class="src">todo-features.md § Design & UI</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">vocal</span><span class="flag f-good">Livré 13/08</span></div>
|
||
<h3>Latence vocale et commandes multilingues — à valider sur le terrain</h3>
|
||
<p>Cinq réglages livrés le 13/08 dans <code>voice_orchestrator.dart</code>, tous à éprouver en visite réelle avec Viva <strong>et</strong> Marco, sur lunettes <strong>et</strong> téléphone.</p>
|
||
<p><strong>1. <code>done.mp3</code> retardait la réponse de toute sa durée</strong> — <code>await _playDoneSound()</code> juste avant <code>speak()</code>, et dans <code>just_audio</code> le future de <code>play()</code> ne se résout qu'à la <em>fin</em> de la lecture. En prime un doublon : la parole <em>est</em> le signal de fin. Retiré, helper et constante supprimés.</p>
|
||
<p><strong>2. Sons préchargés</strong> — <code>setAsset</code> décodait à chaque wake word, sur le chemin le plus sensible à la latence du pipeline. <strong>3. Escalade du son de réflexion</strong> — silence 700 ms, puis fondu d'entrée à 45 % ; une réponse rapide ne déclenche plus aucun son. ⚠️ <code>stop()</code> n'appelait pas <code>_stopThinkingLoop()</code> : le timer pouvait jouer une nappe après l'arrêt de l'orchestrateur.</p>
|
||
<p><strong>4. Les commandes n'existaient qu'en français</strong> — <code>_isStopCommand</code> et les trois autres cherchaient « répète », « arrête », « prends » en dur. Les quatre listes couvrent FR/NL/EN/DE, et le matching passe de <code>contains</code> à <strong>mot entier</strong>. ⛔ Ce changement a révélé deux faux positifs déjà présents : « je ne com<strong>prends</strong> pas » déclenchait une photo, « raconte <strong>encore</strong> une histoire » rejouait la réponse précédente. Et <code>code</code> nu ne déclenche plus de scan QR (« what's the code of this painting »). <strong>16 cas joués via <code>dart run</code>, 16/16.</strong></p>
|
||
<p><strong>5. Limite 4 langues actée</strong> dans le code. <code>flutter analyze lib/Services/Glasses/</code> → 0 erreur.</p>
|
||
<span class="src">voice-latency-plan.md §1 · STATUS.md §5bis</span>
|
||
</article>
|
||
|
||
<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>
|
||
<p>✅ <strong>Débloqué par K5</strong> (12/08, les trois flavors construisent). Valide désormais aussi <strong>D2, D3 et D4, livrés le 12/08</strong> — <code>audio/mpeg</code> reconnu, purge des fichiers obsolètes réactivée, filtre de fraîcheur sur <code>dateUpdate</code>.</p>
|
||
<p>⚠️ <strong>Deux cas à ajouter au §21 avant de le jouer</strong>, ouverts par D2 : (1) <strong>montée v3 → v4 de la base locale</strong> sur un device portant déjà une visite — elle ne doit <strong>rien</strong> re-télécharger ; (2) <strong>device portant des <code><id>.unknown</code></strong> — le MP3 doit être re-téléchargé et devenir lisible. Le second est le seul qui prouve que D3 répare le <strong>parc existant</strong>, pas seulement les installations neuves.</p>
|
||
<span class="src">test-plan.md §21</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp">
|
||
<div class="card-meta"><span class="tag">§9</span><span class="flag f-warn">Nouveau 28/08 · device requis</span></div>
|
||
<h3>iBeacons sur device réel — Android <em>et</em> iOS</h3>
|
||
<p>Les 5 cas du <code>§9</code> sont écrits et <strong>aucun n'est coché</strong> : le déclenchement par balise n'a jamais été observé sur un téléphone. Vendu dans Pro et Premium (« Offline + beacons BLE » sur la landing), donc à prouver avant la première vente, pas après. Exige du matériel en main — d'où la carte de sourcing en « Planifié ».</p>
|
||
<p>⚠️ <strong>Le piège est iOS, et il est dans le code.</strong> <code>geo_beacon_trigger_service.dart:148-155</code> ne pose une région filtrée que <strong>sur iOS</strong>, avec un <code>proximityUUID</code> <strong>en dur</strong> (<code>FDA50693-A4E2-4FB1-AFCF-C6EB07647825</code>) ; Android scanne <code>Region(identifier: 'GlassesBeacon')</code> sans filtre. Conséquence : <strong>une balise réglée sur un autre UUID marche sur Android et reste invisible sur iOS</strong> — et le test passerait sur le seul device qu'on a sous la main. Soit les balises commandées émettent cet UUID, soit il devient configurable. À trancher avec le sourcing, pas pendant le test.</p>
|
||
<p>⚠️ <strong>Deux chemins de scan à jouer séparément</strong>, ils ne partagent pas leur code : <code>configuration_page.dart:197-205</code> (notification <code>beaconFound</code>) et <code>geo_beacon_trigger_service.dart:166-189</code> (déclenchement proactif de l'assistant). ⚠️ Et <code>configuration_page.dart:43</code> dit <code>int meterToBeacon = 100; // 15 meters</code> — <strong>le commentaire et la valeur se contredisent</strong>, et <code>Section.meterZoneGPS</code>, configurable par section dans manager-app, est ignoré. Un test à 100 m ne mesure rien : la distance de déclenchement est la seule chose que ce chapitre doit établir.</p>
|
||
<p>À mesurer sur place, pas au bureau : portée réelle derrière un mur, temps entre l'entrée en zone et la notification, et absence de spam quand on stationne devant un POI (le cooldown).</p>
|
||
<span class="src">test-plan.md §9 · todo-features.md — Beacons / géofencing</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>
|
||
|
||
<article class="card" data-area="manager" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">manager-app</span><span class="tag">manager-service</span><span class="tag">UX</span><span class="flag f-warn">vert au compilateur, jamais ouvert dans un navigateur</span></div>
|
||
<h3>Médiathèque codée de bout en bout — checklist à passer au navigateur</h3>
|
||
<span><strong>Les 8 étapes de <code>v1-mediatheque-plan.md</code> sont codées</strong> : <code>Resource.Width</code>/<code>Height</code> (migration <code>AddResourceDimensions</code>), <code>fileName</code> dans <code>ToDTO()</code>, <code>ResourceUsageService</code> + <code>GET /usages</code> et <code>GET /usage-map</code>, <code>409</code> sur la suppression d'une ressource utilisée et <code>DELETE /bulk</code> à rapport partiel ; côté front, renommage en <strong>Médiathèque</strong>, rail de facettes cumulables à compteurs (type · usage · configuration), tri, groupement par mois, bascule grille/liste, sélection multiple, compteur d'usages sur la vignette, et le panneau latéral qui remplace la popup de 520 px. Les deux bugs sont corrigés : un PNG se télécharge en <code>.png</code>, et <code>FileName</code> est exposé.</span>
|
||
<span><strong>Deux corrections apportées à la spécification, vérifiées dans le code.</strong> Le « piège 1 » (union sur toutes les langues) était <em>faux</em> : <code>GetReferencedResourceIds(null)</code> rend déjà toutes les langues — appeler la méthode sans argument suffit. Et un <strong>quatrième piège</strong> manquait : un <code>GuidedPath</code> peut pendre d'un <code>SectionEvent</code>, dont <code>GetReferencedResourceIds</code> ne descend pas dans les parcours ; les parcours sont donc parcourus séparément, et volontairement pas chargés sur <code>SectionParcours</code> pour éviter le double comptage. Les deux sont figés par des tests (<code>ImageUsedOnlyInDutch_IsNotOrphan</code>, <code>GuidedPathOnEvent_IsWalked</code>). Piège 3 assumé et testé tel quel : <code>GuidedStep.ImageUrl</code> reste une URL, donc comptée orpheline — le tooltip de la facette « Jamais utilisées » l'écrit.</span>
|
||
<span>⚠️ <strong>Ce qui reste, et pourquoi cette carte est ici.</strong> <code>dotnet test</code> 236/236 et <code>flutter build web</code> passent — <strong>la checklist de 15 points du plan n'a été passée dans aucun navigateur</strong>. Le point le plus coûteux d'abord : <em>ouvrir un champ image d'une section, choisir une ressource, enregistrer</em>. <code>showSelectResourceModal</code> monte <code>ResourcesScreen</code>, donc une régression y touche les 13 types de section, les POI et les étapes d'un coup. Hors périmètre, laissé tel quel : la jauge de stockage du menu (<code>quota_bars_widget.dart:23</code>).</span>
|
||
<span class="src">v1-mediatheque-plan.md — les 8 étapes sont codées</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp">
|
||
<div class="card-meta"><span class="tag">mymuseum-visitapp</span><span class="tag">UI</span><span class="tag">hors ligne</span><span class="flag f-warn">Jamais vu tourner sur device</span></div>
|
||
<h3>Home v3 — téléchargement hors ligne porté, et mort de l'ancienne home</h3>
|
||
<p><strong>Trois trous de <code>home_3.0.dart</code> comblés le 2026-09-08, aucun vu tourner sur device.</strong></p>
|
||
<p><strong>(1) Les configurations désactivées s'affichaient.</strong> <code>isActive</code> ne vit pas sur <code>ConfigurationDTO</code> mais sur le <em>lien</em> config↔canal (<code>AppConfigurationLinkDTO.isActive</code>, ce que bascule <code>app_configuration_link_screen:407</code>). Ni le fetch par liens ni le set <code>mobileConfigIds</code> ne le lisaient. ⚠️ <strong>L'ancienne home avait le même bug</strong> — ce n'était pas une régression de la v3. La référence était à côté : <code>visitapp-web/src/lib/api/client.ts:170</code> filtre déjà <code>link.isActive && link.configuration</code>.</p>
|
||
<p><strong>(2) Une visite hors ligne était un cul-de-sac.</strong> Pas de bouton télécharger, pas de garde : la tuile ouvrait <code>ConfigurationPage</code>, et <code>body.dart:250</code> lisant ses sections <em>uniquement</em> en base locale quand <code>isOffline</code> est vrai, le visiteur voyait un détail vide, sans message ni erreur. Porté depuis <code>configurations_list.dart</code> : badge en pastille de verre sur la tuile bento (↓ / ✓), tuile ternie tant qu'elle n'est pas prête, dialogue sombre choix de langue + progression (<code>DownloadConfigurationWidget</code> réutilisé tel quel). Zéro nouvelle clé i18n, tout existait dans les 10 langues.</p>
|
||
<p>⚠️ <strong>Piège trouvé en câblant</strong> : <code>alreadyDownloaded</code> se déduisait des lignes de la table <code>configurations</code> — table dans laquelle le fetch <strong>écrit une ligne par visite</strong> pour cacher <code>order</code>/<code>gridSpan</code>. Au deuxième lancement, <em>toutes</em> les visites se seraient déclarées téléchargées, et le clic aurait rouvert un détail vide. L'état se déduit maintenant des <strong>sections en base locale</strong>, ce que lit le détail.</p>
|
||
<p><strong>(3) Passe visuelle.</strong> Nouveau <code>Components/GlassPill.dart</code> (verre dépoli, 3 usages) : le cog devient <code>Icons.tune</code> + drapeau de la langue courante en pastille, <code>GlassesStatusWidget</code> passe sur la même pastille, et la feuille de réglages passe en sombre — une feuille blanche dans une app noire était le contraste le plus violent de l'écran.</p>
|
||
<p><strong>✅ À supprimer une fois validé sur device : <code>Screens/Home/home.dart</code> et <code>Screens/Home/configurations_list.dart</code>.</strong> Ils ne sont plus référencés par personne — <code>HomePage</code> est orphelin, <code>CustomAppBar</code>, <code>main.dart:220</code>, <code>body.dart:122</code> et <code>menu_page.dart:136</code> pointent tous sur <code>HomePage3</code>. Ils n'étaient gardés que pour le téléchargement, qui vient d'être porté. <strong>Ne les supprimer qu'après avoir joué §21 du test-plan sur device</strong> : ce sont les seules références du comportement d'origine si le portage se révèle incomplet.</p>
|
||
<p>⚠️ <strong>Deux verrues mises en lumière, pas corrigées.</strong> <code>downloadPrompt</code> annonce « 39,8MB » <strong>en dur dans les 10 langues</strong> et aucune donnée de poids n'existe côté DTO (la colonne <code>weightMasonryGrid</code> a été abandonnée en v3) : soit la taille entre dans l'export, soit le chiffre sort du texte. Et <code>home_3.0.dart:904</code> porte <code>isOnline = true; // Todo remove if not local test</code> — la branche hors ligne <strong>ne s'exécute jamais</strong> (l'analyzer le confirme, <code>dead_code</code> juste après) : une visite téléchargée s'ouvre, mais l'accueil en mode avion passe quand même par le réseau.</p>
|
||
<span class="src">conversation du 2026-09-08 — captures du Fort de Saint-Héribert</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">§23</span><span class="tag">manager-service</span><span class="tag">manager-app</span><span class="tag">visitapp-web</span><span class="tag">mymuseum-visitapp</span><span class="flag f-warn">Codé 10/09 · device requis · redirection serveur à faire</span></div>
|
||
<h3>QR codes, nom d'application, page <code>/download</code> et notifications de proximité</h3>
|
||
<p><strong>Codé</strong> dans les quatre repos, rien n'est committé :</p>
|
||
<ul>
|
||
<li><strong>ApplicationInstance</strong> (par app, Mobile ≠ Web) : <code>IsQRCodeEnabled</code> (défaut <code>true</code>), <code>AppName</code> (traductions), <code>AppStoreUrl</code>/<code>PlayStoreUrl</code> (modifiables par un SuperAdmin seulement, contrôlé côté API). Migration <code>AddAppNameQrAndStoresToApplicationInstance</code>.</li>
|
||
<li><strong>Scan mobile</strong> : id brut (QR du Fort) résolu via la section puis sa visite, y compris depuis l'accueil ; les URL <code>web.mymuseum.be</code> (MDLF) restent lues ; nouvelle URL <code>app.myinfomate.be/download/…</code>. QR d'une autre instance refusé.</li>
|
||
<li><strong>QR du manager</strong> : <code>app.myinfomate.be/download/{instance}/{config}/{section}</code> — ou la section web directement pour une instance web seule.</li>
|
||
<li><strong>visitapp-web</strong> : page <code>/download</code> (nom, image principale, liens stores), bouton de scan conditionné, nom de l'app sur l'accueil et dans l'onglet, suggestion de proximité GPS dans la page. Slugs <code>download</code>/<code>demo</code>/<code>api</code> réservés.</li>
|
||
<li><strong>Proximité mobile</strong> : beacon + zones GPS (<code>meterZoneGPS</code>). App ouverte → popup ; écran verrouillé pendant une visite → notification, dont le tap ouvre la section. Android : service de premier plan <code>location</code> ; iOS : mode d'arrière-plan <code>location</code>. App tuée : rien, par choix.</li>
|
||
</ul>
|
||
<p>⚠️ <strong>Reste à faire hors code</strong> :</p>
|
||
<ul>
|
||
<li><strong>Redirection 301</strong> <code>web.mymuseum.be/{i}/{c}/{s}</code> → <code>app.myinfomate.be/download/{i}/{c}/{s}</code>, pour les QR MDLF scannés à l'appareil photo. Le domaine n'est routé dans aucun compose des repos : à poser là où il est servi.</li>
|
||
<li><code>app.myinfomate.be</code> n'est pas encore déployé (carte 210) : les QR générés par le manager y mènent, les deux partent en prod ensemble.</li>
|
||
<li>Stores : déclarer le service de premier plan de type <em>location</em> dans la Play Console ; justifier le mode <code>location</code> à la revue Apple.</li>
|
||
</ul>
|
||
<p>Bug corrigé au passage : la popup beacon (<code>BeaconArticleFound</code>) lisait les maps JSON brutes de <code>currentSections</code> comme des <code>SectionDTO</code> et plantait.</p>
|
||
<span class="src">test-plan.md §23</span>
|
||
</article>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- PLANIFIÉ -->
|
||
<section class="col" style="--stripe: var(--ink-3)">
|
||
<div class="col-head"><h2>Planifié</h2><span class="count">39</span></div>
|
||
<div class="stack">
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">mymuseum-visitapp</span><span class="tag">tablet-app</span><span class="tag">UI</span></div>
|
||
<h3>Porter l'orientation paysage dans les deux apps Flutter</h3>
|
||
<p><strong>Le web a pris de l'avance sur deux écrans</strong> : <code>ArticleSection</code> (deux volets côte à côte, lecteur audio ancré) et maintenant le dock de filtres de la carte. Les équivalents Flutter n'ont aucune règle d'orientation — <code>grep Orientation</code> sur <code>mymuseum-visitapp</code> ne ramène que <code>quizz_page.dart</code>.</p>
|
||
<p>⚠️ <strong>Une question précède l'écran</strong> : <code>mymuseum-visitapp</code> autorise-t-elle seulement le paysage ? Coder un rendu paysage pour une app verrouillée en portrait, ce serait du code mort. À lire dans <code>main.dart</code> avant de commencer.</p>
|
||
<p><strong>tablet-app n'est pas une cible pour la carte</strong> : verrouillée en paysage (<code>main.dart:105</code>), c'est elle qui possède le panneau flottant d'origine, et le web n'a fait que l'emprunter. Y reste l'article deux volets, et l'harmonisation du panneau (verre, pastille de repli, compteur) avec ce que le web vient de poser.</p>
|
||
<span class="src">todo-features.md § Design & UI</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">vocal</span><span class="flag f-warn">Feu vert à donner</span></div>
|
||
<h3>Découpage du TTS par phrase — le seul vrai levier de latence</h3>
|
||
<p><strong>Le time-to-first-audio est aujourd'hui la somme de tout</strong> : <code>LlmClient.chat()</code> retourne un future de réponse <em>complète</em> (pas de flux) et <code>GeminiTtsEngine._synthesize()</code> fait un <code>generateContent</code> unaire qui attend <strong>tout</strong> le PCM avant d'écrire le WAV. C'est ce trou que la nappe de réflexion bouche.</p>
|
||
<p><strong>Le remède ne dépend d'aucune API nouvelle</strong> : découper <code>result.reply</code> en phrases, synthétiser et jouer la première pendant que les suivantes se préparent. Le time-to-first-audio tombe à <code>LLM + TTS(1 phrase)</code>. Contenu dans <code>GeminiTtsEngine</code> — ni l'orchestrateur ni le backend ne bougent.</p>
|
||
<p>⏸️ <strong>Volontairement pas fait avec le reste du lot du 13/08</strong> : c'est le seul chantier structurant des six, il mérite un feu vert séparé. ⚠️ <strong>À vérifier avant</strong> : <code>:streamGenerateContent</code> émet-il des chunks audio progressifs sur <code>gemini-2.5-flash-preview-tts</code> ? Test curl de 20 min — à faire, pas à supposer.</p>
|
||
<span class="src">voice-latency-plan.md §1.3</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">vocal</span><span class="flag f-warn">Après les tests</span></div>
|
||
<h3>Accusé de réception parlé à la place du bip du wake word</h3>
|
||
<p>Remplacer <code>wake_detected.mp3</code> par une phrase dans la langue et la voix du visiteur — « Oui, je vous écoute » — avec 2-3 variantes, un visiteur entendant l'ack des dizaines de fois sur une visite.</p>
|
||
<p><strong>Retenu, mais explicitement après les tests</strong> : ça demande de générer et valider <em>à l'oreille</em> ~40 fichiers (2 voix × 4 langues × 5 phrases), et une partie du besoin que l'ack compense disparaît si le découpage par phrase fait baisser la latence. Décider avant d'avoir entendu le flux, ce serait décider à l'aveugle.</p>
|
||
<p>⚠️ <strong>Trois pièges déjà identifiés.</strong> <code>pubspec.yaml:133</code> déclare <code>assets/sounds/</code> mais <strong>les déclarations de dossier ne sont pas récursives en Flutter</strong>. Générer avec exactement le même <code>voicePrompt</code> que le runtime, sinon le timbre décroche entre l'ack et la réponse — et Gemini TTS n'est pas déterministe, prévoir plusieurs prises. ⛔ Et une <strong>règle de cohérence non négociable</strong> : sans <code>GEMINI_API_KEY</code> le moteur retombe sur la voix système Android — des acks en Sulafat suivis d'une réponse en voix système seraient <em>pires</em> que le bip. Acks actifs seulement si le moteur runtime est Gemini et que la voix correspond à <code>guideVoiceId</code>.</p>
|
||
<p>Ordonnancement à calibrer sur place : ouvrir le micro à <code>player.duration - 150ms</code> plutôt que de faire de l'AEC — sur Ray-Ban, micro et haut-parleur partagent la monture.</p>
|
||
<span class="src">voice-latency-plan.md §2</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">3 repos</span><span class="flag f-warn">V2 — spike d'abord</span></div>
|
||
<h3>Live API Gemini — audio natif bidirectionnel</h3>
|
||
<p>Supprimerait les trois maillons Whisper → LLM → Gemini TTS au profit d'un <strong>WebSocket permanent</strong> : flux micro brut en entrée (PCM 16 kHz), flux audio en sortie (24 kHz), sans jamais passer par du texte. Débloque trois choses que l'architecture actuelle ne peut <strong>structurellement</strong> pas faire : le <strong>barge-in</strong> (couper la parole à l'assistant), le <strong>VAD côté serveur</strong> (fin du <code>timeout: 5 secondes</code> en dur de <code>_listenForFollowUp</code>) et une latence de l'ordre de la seconde. Les voix prébuilt sont de la même famille — Viva/Marco survivraient.</p>
|
||
<p><strong>Le point dur n'est pas l'audio, c'est le tool calling.</strong> Les outils (<code>GetSectionDetail</code>, RAG) vivent dans <code>manager-service</code>. Deux options : l'app parle directement à Gemini (latence minimale, mais logique métier déplacée dans le client et jetons éphémères obligatoires — on ne met pas une clé Gemini dans un APK de visiteurs), ou <strong><code>manager-service</code> proxifie le WebSocket</strong> (clé au chaud, prompt et stats gardés, mais relais audio temps réel en C# avec sessions, reconnexions et backpressure). <strong>C'est l'option B qui a du sens, et c'est elle qui coûte cher.</strong></p>
|
||
<p>⚠️ <strong>Le modèle de coût change de nature</strong> : plus à la requête mais à la session ouverte, avec des jetons audio bien plus chers. 80 visiteurs simultanés = 80 sessions. <strong>À chiffrer, les tarifs ne sont pas connus ici.</strong> ⚠️ Sessions à durée limitée, reprise à gérer sur réseau mobile en bâtiment de pierre. ⛔ <strong>Modèles en preview</strong> — après l'historique ElevenLabs → Gemini, y accrocher une fonction vendue serait imprudent. <strong>Verdict : spike chiffré d'une journée avant toute décision.</strong></p>
|
||
<span class="src">voice-latency-plan.md §3.2</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">code mort</span><span class="flag f-warn">V2</span></div>
|
||
<h3>Nettoyer downloadConfiguration.dart</h3>
|
||
<p>Trouvé en livrant D4 le 12/08, laissé volontairement hors périmètre du lot D. Deux blocs morts dans le même fichier :</p>
|
||
<p><strong>1. ~130 lignes de classe commentée</strong> — <code>/*class DownloadConfiguration {</code> ligne 328 jusqu'au <code>*/</code> ligne 461. C'est <strong>ce bloc qui a fait croire au plan V1 que « les deux chemins de téléchargement divergent »</strong> : la ligne citée comme preuve vivait dedans. Un doublon commenté qui ressemble à du code actif coûte plus cher que pas de code du tout — il a produit un diagnostic faux.</p>
|
||
<p><strong>2. <code>cleanLocalResources</code>, appelée nulle part</strong> — et non réactivable telle quelle : la table locale <code>resources</code> n'a pas de colonne <code>configurationId</code>, elle est globale à toutes les visites, et le paramètre <code>configuration</code> de la fonction n'est jamais utilisé. Deux issues : la <strong>supprimer</strong>, ou lui donner un périmètre en ajoutant la colonne. ⚠️ Conséquence à assumer si on supprime : <strong>les lignes DB des ressources retirées d'une visite ne sont purgées par personne</strong>. Impact réel faible — rien ne les lit pour le rendu, <code>CachedCustomResource</code> retrouve le fichier en listant le répertoire — mais la table croît sans borne.</p>
|
||
<span class="src">v1-plan.md lot D — D4 · STATUS.md §K5</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">carto</span><span class="tag">offline</span><span class="flag f-warn">prérequis pour vendre un parcours GPS sur un site sans réseau</span></div>
|
||
<h3>Carte hors ligne — tile packs Mapbox</h3>
|
||
<p><strong>Aujourd'hui aucune carte ne fonctionne hors ligne</strong>, et ce n'est pas une question de données : <code>MapDTO</code> porte déjà <code>points</code>, <code>centerLatitude</code>, <code>zoom</code> et <code>iconResourceId</code>, et les icônes sont téléchargées avec la visite. C'est le <strong>fond</strong> qui manque — les tuiles sont chargées en réseau, sans cache.</p>
|
||
<p><strong>Le GPS, lui, n'est pas le problème</strong> : <code>geolocator ^13.0.0</code> lit le GNSS, qui est autonome. Seule réserve à annoncer au client : sans A-GPS, le premier point peut demander 30 à 60 s sous couvert forestier.</p>
|
||
<p><strong>Le chemin court existe déjà dans le pubspec</strong> : la version <strong>résolue est 2.8.0</strong> (pas 2.0.0 comme le laisse croire la contrainte), et le paquet installé contient bien <code>OfflineManager</code>, <code>TileStore</code>, <code>StylePackLoadOptions</code>, <code>TileRegionLoadOptions</code> et <code>TileRegion</code> — <strong>vérifié dans le cache pub, pas supposé</strong>. On télécharge une emprise bornée au moment du téléchargement de la visite ; pour un domaine comme le Fourneau, quelques dizaines de Mo.</p>
|
||
<p>✅ <strong>Mapbox est désormais le fournisseur par défaut</strong> (04/09) : le <code>null</code> retombait sur Google dans <code>map_page.dart</code> (deux endroits) et <code>map_config.dart</code>. ⚠️ Effet de bord à surveiller — les sections carte existantes de MDLF et Fort Saint-Héribert dont le <code>mapProvider</code> est <code>null</code> <strong>basculent silencieusement de Google à Mapbox</strong>. Le token est déjà en place, en dur dans <code>main.dart:60</code>.</p>
|
||
<p>⚠️ <strong>Ce n'est pas gratuit, et le compteur est le mauvais</strong> : ce qui coûte n'est pas la surface de l'emprise mais le <strong>nombre de téléchargements</strong> — un tile pack par visiteur. La facture monte donc avec la fréquentation, c'est-à-dire avec le succès du client. Grille à vérifier chez Mapbox, ligne « tile packs » et pas seulement « map loads ». C'est exactement ce calcul que supprime <code>v2/plan-illustre-plan.md</code>.</p>
|
||
<p>⛔ <strong>Google est une impasse, et pas seulement techniquement.</strong> Le SDK Maps pour Android n'expose aucune API de tuiles hors ligne — les « zones hors connexion » sont une fonction de l'app Google Maps, pas du SDK intégrable. Et le code n'utilise même pas ce SDK pour les sections carte : il passe par <code>flutter_map</code> sur <code>https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}</code>, un endpoint non documenté dont la mise en cache est explicitement interdite. <strong>Conclusion : Mapbox devient le fournisseur du mode hors ligne, Google reste en ligne seulement.</strong></p>
|
||
<p>⚠️ Tant que ce chantier n'est pas fait, <code>SectionType.Map</code> reste volontairement hors de <code>offlineCapableSectionTypes</code> (<code>downloadConfiguration.dart</code>) : l'ajouter donnerait une carte grise, ce qui est pire que ne pas la proposer. Une fois les tuiles packagées, c'est <strong>une ligne à ajouter dans cette constante</strong>.</p>
|
||
<span class="src">analyse du code le 2026-09-04 — MapProvider, flutter_map, mapbox_maps_flutter</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="manager backend visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">carto</span><span class="tag">offline</span><span class="tag">manager-app</span><span class="flag f-good">le seul chemin vers une carte hors ligne sans coût tiers</span></div>
|
||
<h3>Plan illustré — un 3<sup>e</sup> fournisseur de carte, sans tuiles</h3>
|
||
<p><strong>Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné.</strong> Conception complète, modèle de données et périmètre par repo : <strong><code>DOCS/v2/plan-illustre-plan.md</code></strong>. Rien n'est implémenté.</p>
|
||
<p><strong>La décision qui structure tout</strong> : les repères se posent <strong>directement sur l'image</strong>, à la main, et c'est cette position qui fait foi — un plan dessiné n'étant ni à l'échelle ni orienté au nord, projeter des <code>GeoPoint</code> depuis leurs coordonnées les ferait tomber à côté des bâtiments. Le calage GPS ne sert qu'à afficher <strong>le visiteur</strong>, et devient donc <strong>facultatif</strong>.</p>
|
||
<p>⚠️ <strong>Deux affirmations de la première version de cette carte étaient fausses</strong>, corrigées le 04/09 : on ne projette pas les repères, et <strong>deux points d'ancrage ne suffisent pas</strong> — il en faut trois pour une transformation affine qui absorbe rotation et étirement, et pour pouvoir vérifier le calage au lieu de diluer l'erreur.</p>
|
||
<p>✅ <strong>Le calage se fait assis</strong> : cliquer le même angle de bâtiment sur le plan puis sur une carte réelle en regard. <code>GeolocInputContainer</code> ouvre déjà un <code>FlutterLocationPicker</code> (vraie carte, recherche d'adresse) — le composant est écrit, il faut le mettre à côté du plan. Plus besoin de prestation d'installation.</p>
|
||
<p>✅ <strong>Hors ligne gratuit</strong> : le plan est une <code>Resource</code>, une ligne dans <code>GetReferencedResourceIds()</code> et le pipeline existant l'embarque. Aucun tiers, aucun quota — contrairement aux tile packs Mapbox, facturés <strong>par visiteur</strong>.</p>
|
||
<p>⚠️ <strong>Conséquence web à trancher</strong> : <code>visitapp-web</code> reste sur Leaflet (lot E, W1), et <code>mapProviderMobileOnlyNote</code> le dit déjà au client. Pour un plan illustré c'est bloquant — le plan <em>est</em> le produit. Soit on porte le rendu en CSS dans la foulée, soit le <strong>plan Essentiel, web-only, n'y a pas droit</strong>.</p>
|
||
<p><strong>Livrable qui se vend seul</strong> : le plan <em>sans</em> calage — téléversement, pose des repères, rendu, hors ligne. C'est le produit que les audioguides vendent depuis trente ans.</p>
|
||
<span class="src">v2/plan-illustre-plan.md — conçu le 2026-09-04</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">3 repos</span><span class="flag f-warn">Contredit le code</span></div>
|
||
<h3>Les swagger.yaml embarqués sont périmés</h3>
|
||
<p>Relevé en livrant D2 le 12/08. Les trois <code>lib/api/swagger.yaml</code> (manager-app, mymuseum-visitapp, tablet-app) décrivent un <code>ResourceDTO</code> qui n'a <strong>ni <code>sizeBytes</code></strong> (ajouté à l'époque de C1/C3) <strong>ni <code>dateUpdate</code></strong> (D2).</p>
|
||
<p>Ce sont des artefacts de génération, et <strong>le client s'édite à la main</strong> : ils ne sont plus la source de vérité. Le risque n'est pas fonctionnel — rien ne les lit à l'exécution — mais <strong>ils contredisent le code en silence</strong>, et c'est exactement ce qui a coûté du temps sur le « fallback Voice → Mobile » du lot F : une doc affirmative et fausse. Deux issues : les <strong>régénérer une fois</strong> et les tenir, ou les <strong>supprimer</strong> et assumer que le Swagger en ligne est la seule référence.</p>
|
||
<span class="src">STATUS.md §K5 — relevé D2</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">piège latent</span><span class="flag f-warn">V2</span></div>
|
||
<h3>La colonne path de la table resources est écrasée à vide</h3>
|
||
<p>Trouvé en livrant D2 le 12/08. <code>DatabaseHelper.insert</code> fait un <strong>UPDATE de la ligne entière</strong> quand l'id existe déjà : la boucle qui enregistre la charge de ressources (héritée de D1) repasse derrière la boucle de téléchargement et réécrit <code>path</code> avec la valeur par défaut de <code>ResourceModel</code>, soit <code>""</code>.</p>
|
||
<p><strong>Sans effet aujourd'hui</strong> : personne ne lit cette colonne pour le rendu. Mais elle est <code>NOT NULL</code> et prétend porter le chemin local — <strong>le premier code qui s'y fiera trouvera une chaîne vide</strong>, sans erreur. Même famille que le <code>dateUpdate</code> effacé par la même boucle, corrigé en D2 parce que lui, il est lu. Deux issues : renseigner <code>path</code> dans les deux boucles, ou retirer la colonne et assumer que le répertoire fait foi.</p>
|
||
<span class="src">v1-plan.md lot D — D2</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="commercial" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">visibilité</span><span class="tag">à investiguer</span></div>
|
||
<h3>Se référencer dans l'annuaire Fournisseurs des Musées (OCIM)</h3>
|
||
<p><strong>Le site est édité par l'OCIM</strong> (Office de Coopération et d'Information Muséales) avec France Edition Multimédia — pas un annuaire SEO anonyme, mais la porte d'entrée que des musées francophones utilisent réellement pour chercher un prestataire. Deux mécanismes distincts sur le site : l'<strong>annuaire</strong> (fiche permanente, <strong>inscription annoncée comme gratuite</strong>, formulaire demandant coordonnées, contact et <strong>exactement trois thématiques</strong> — « multimédia » est la catégorie évidente pour MyInfoMate) et <strong>SOS Fournisseurs</strong>, où un musée décrit un besoin et reçoit des propositions.</p>
|
||
<p><strong>Ce qui reste à investiguer, et qui décide de la valeur réelle</strong> : les demandes SOS sont-elles diffusées à tous les inscrits de la thématique ou faut-il une formule payante pour les recevoir ? Le tarif n'est affiché nulle part — <strong>à demander, pas à supposer</strong> (contact@francedit.com). Vérifier aussi ce que donne l'annuaire une fois dedans : combien de fiches en « multimédia », comment elles sont classées, et si une fiche gratuite est visible ou noyée.</p>
|
||
<p>⏸️ <strong>Pas avant que la prod soit stabilisée</strong> — même raison que la reprise de contact avec le Musée d'Ixelles : une fiche publique renvoie vers un produit qu'il faut pouvoir montrer. ⚠️ Le site est orienté France ; vérifier qu'un fournisseur belge y est accepté et bien référencé.</p>
|
||
<span class="src">fournisseursdesmusees.com — annuaire & SOS Fournisseurs</span>
|
||
</article>
|
||
|
||
<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">RGPD · lot J</span></div>
|
||
<h3><code>AuditLog</code> n'a aucune purge</h3>
|
||
<p><code>VisitEvent</code> purge à 13 mois, <code>VisitorQuestion</code> à 90 jours, <code>AuditLog</code> <strong>à jamais</strong>. <strong>C'est un sujet RGPD et rien d'autre</strong> : la table porte <code>UserId</code>, et les valeurs avant-après d'une modification de <code>User</code> contiennent e-mail, prénom et nom. <strong>Ce n'est pas un sujet de volume</strong> — l'inondation par <code>WeatherSyncService</code> est coupée depuis le 12/08, la table croît désormais au seul rythme des éditions humaines. <strong>Donc à traiter au lot J, pas au lot F.</strong> Recommandation : <strong>12 mois, uniforme</strong>, via un <code>AuditLogPurgeService</code> calqué sur <code>VisitEventPurgeService</code> — 12 et non 13, le 13 mois des stats existe pour comparer une saison à la précédente et le recopier ici serait du mimétisme. ⚠️ <strong>Reprendre le même verrou</strong> : purge inerte tant qu'<code>Audit:RetentionDays</code> n'est pas défini, <strong>le pg_dump quotidien n'étant toujours pas en place</strong> — supprimer des lignes d'audit sans restauration fine est la pire combinaison. Écarté : garder les <code>Delete</code> plus longtemps que les <code>Create</code>/<code>Update</code>, ça double les règles pour un cas qu'une sauvegarde couvre déjà.</p>
|
||
<span class="src">v1-plan.md lot F · STATUS.md §1sexies</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 > 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">juridique</span><span class="flag f-warn">Code fait · relecture à faire</span></div>
|
||
<h3>RGPD — ce qui reste est juridique, plus du code</h3>
|
||
<p>✅ <strong>Tout le volet codable est livré le 13/08</strong> : table d'agrégats de thèmes, job de regroupement, interrupteur de collecte par instance, mention affichée dans l'assistant. Voir « Fait récemment ».</p>
|
||
<p><strong>Reste, et rien de tout ça ne s'écrit en Dart ou en C#</strong> : la <strong>relecture juridique du §8</strong> des CGU (rédigé côté produit, jamais validé — priorité au §8.6 sous-traitants et au §8.5 transfert hors UE), la <strong>vérification des conditions réelles de Google</strong> (l'API Gemini offre-t-elle une résidence européenne ? un DPA est-il signé ? la réponse réécrit le §8.5), et la décision sur un <strong>DPA séparé</strong> — l'article 28 exige un acte écrit, et une commune ou un musée subsidié en demandera un en annexe.</p>
|
||
<p>⚠️ <strong>Deux traductions à commander avec cette relecture.</strong> La mention aux visiteurs n'existe qu'en FR/NL/EN alors que l'app porte 10 langues : les 7 autres replient sur l'anglais, faute de traduction relue. Traduire soi-même une mention de protection des données n'est pas une coquille d'interface qu'on rattrape après.</p>
|
||
<p>⚠️ <strong>Et une question ouverte au passage</strong> : activer <code>WHISPER_API_KEY</code> ajouterait <strong>OpenAI</strong> comme sous-traitant, que le §8.5 ne mentionne pas — il ne parle que de Google. La clé est vide aujourd'hui, c'est le moteur du device qui transcrit. À trancher avant de l'activer, pas après.</p>
|
||
<p>✅ <strong>L'échéance n'est pas la bascule mais la première question d'un visiteur réel</strong> — rien n'est collecté aujourd'hui, le délai de 90 jours n'a pas commencé à courir.</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>
|
||
<p><strong>Précisé le 28/08 — le volet commercial, absent du plan technique jusqu'ici.</strong> Audio par POI, <strong>pré-généré et embarqué dans le bundle offline</strong> : ce n'est plus « des textes sur un téléphone » qu'on vend mais un <em>audioguide</em>, face à des audioguides physiques à plusieurs milliers d'euros. La catégorie de produit change, donc l'argumentaire aussi.</p>
|
||
<p>⚠️ <strong>Facturer la génération, pas le stockage.</strong> Sinon la correction d'une coquille relance un cycle TTS complet sur toutes les langues, sans qu'un octet stocké n'ait bougé — le client paierait le mauvais compteur. ⚠️ <strong>Prérequis avant toute annonce sur le site</strong> : pilotable en self-service depuis le back-office ; à défaut, badge « Bientôt » et rien de plus. Un client qui doit nous écrire pour régénérer un audio n'achète pas une fonctionnalité, il achète une prestation.</p>
|
||
<p><strong>Argument accessibilité</strong> — déficients visuels, FALC, seniors : souvent <em>exigé</em> en secteur public, parfois clé de subside. À rapprocher du chantier WCAG. <strong>Premier upsell identifié : le Fourneau Saint-Michel</strong>, une fois ses 20 bâtiments saisis.</p>
|
||
<span class="src">v2/tts-pregenerated-plan.md · todo-features.md — TTS pré-généré (volet commercial)</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="infra visitapp">
|
||
<div class="card-meta"><span class="tag">matériel</span><span class="flag f-warn">Nouveau 28/08 · mail à envoyer</span></div>
|
||
<h3>Sourcing balises iBeacon extérieures — écrire à Holyiot, sinon Feasycom</h3>
|
||
<p><strong>Premier geste, un mail à Holyiot (vendeur AliExpress)</strong> : reste-t-il du <code>22044-beacon-BP-3a-V1.0</code> <em>ou une révision ultérieure</em> ? Trois exigences à faire confirmer <strong>par écrit</strong>, pas déduites d'une fiche produit : <strong>piles AA ou AAA remplaçables</strong> (une CR2450 soudée ou non accessible disqualifie le modèle — on ne redéploie pas 20 balises pour changer une pile), <strong>UUID / major / minor configurables</strong>, et <strong>remontée de l'état de batterie</strong>. Sans réponse ou sans stock : <strong>même demande à Feasycom, sur la référence <code>FSC-BP104D</code></strong> (IP67, 2×AAA, multi-slot iBeacon + Eddystone UID/URL/TLM) — la faire confirmer sur les trois mêmes points plutôt que sur sa fiche produit.</p>
|
||
<p>⚠️ <strong>La batterie doit passer par le <em>major</em>, pas par le minor</strong> — vérifié dans le code le 28/08 : l'app identifie le POI <strong>par le seul <code>minorId</code></strong> (<code>geo_beacon_trigger_service.dart:177</code> et <code>configuration_page.dart:205</code>, comparés à <code>beaconSection.minorBeaconId</code>). Encoder un niveau de batterie dans le minor <strong>casserait l'identification du point d'intérêt</strong>. Le <code>major</code>, lui, n'est lu nulle part dans les deux apps : c'est le seul champ libre. À demander tel quel au fournisseur — « batterie dans le major », pas « batterie dans major ou minor ».</p>
|
||
<p>⚠️ <strong>Et à faire confirmer avec : l'UUID d'émission.</strong> iOS ne voit que l'UUID codé en dur dans le service (voir la carte de test §9). Une balise non réglable sur cet UUID impose de rendre le champ configurable — c'est du code, donc à savoir <em>avant</em> de commander, pas à la réception.</p>
|
||
<p><strong>Alternative propre si les fournisseurs bloquent sur le major</strong> : une balise multi-slot émettant <strong>iBeacon + Eddystone-TLM</strong> en parallèle, le TLM portant tension et température par construction. ⚠️ Mais c'est un second mécanisme de scan à écrire — le <code>beacon_scanner</code> actuel travaille sur les régions iBeacon, et sur iOS CoreLocation n'expose pas les données d'annonce brutes : il faudrait un scan BLE parallèle et une corrélation. <strong>Le major est le pis-aller qui ne coûte aucune ligne de code</strong> ; le TLM est plus juste et plus cher. Trancher au vu des réponses, pas avant.</p>
|
||
<p><strong>Commander 2 ou 3 exemplaires avant tout volume</strong>, et exiger une <strong>photo du boîtier ouvert</strong>. Autres critères pour le plein air (Fourneau Saint-Michel) : <strong>IP67</strong>, couvercle à vis ou quart de tour, doc <strong>CE/RED</strong> — exigible en marché public. Piles <strong>lithium primaires</strong> (L91/L92) plutôt qu'alcalines : tenue au froid, pas de coulure ; contrepartie, leur décharge est plate, donc surveiller aussi l'âge de pose. Joint à regraisser à chaque changement, sachet déshydratant dans le boîtier, pas de fixation sur métal.</p>
|
||
<p><strong>Le raccourci à garder en tête</strong> : le bois est transparent au BLE. Monter les balises <em>à l'intérieur</em> des bâtiments côté entrée règle la plupart des POI du Fourneau, et réserve l'IP67 aux points réellement isolés — moins de matériel exposé, moins de piles à changer sous la pluie.</p>
|
||
<span class="src">todo-features.md — Beacons / géofencing (sourcing) · geo_beacon_trigger_service.dart</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="doc" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">myinfomate-landing</span><span class="flag f-warn">10/09 : Thomas ne veut pas afficher de montant</span></div>
|
||
<h3>Chiffrer les frais de mise en place sur la page tarifs</h3>
|
||
<p><strong>Le défaut est de confiance, pas de design.</strong> La carte Pro annonce <code>€99/mois</code> et, juste dessous, <code>setupFeeNote</code> = « Frais de mise en place + engagement 12 mois (app mobile) » — <strong>sans aucun chiffre</strong> (<code>translations.ts:272</code>, repris tel quel en EN/NL/DE). L'acheteur budgète 1 200 €/an, puis découvre le setup en rendez-vous : c'est le pire moment pour l'apprendre. Remède : « <strong>à partir de X € selon le volume de contenu</strong> », dans les 4 langues. <strong>Priorité n°1 de la passe.</strong></p>
|
||
<p><strong>⚖️ Arbitré le 10/09 : pas de montant affiché.</strong> Thomas ne veut pas publier de plancher. Mesure de repli appliquée le même jour, dans les 4 langues : <code>setupFeeNote</code> devient « <strong>Frais de mise en place uniques, chiffrés dans le devis</strong> + engagement 12 mois », et la FAQ de la nouvelle page <code>/tarifs</code> précise ce que couvrent ces frais (configuration et publication sur les stores) et qu'ils sont chiffrés <strong>avant signature</strong>. L'acheteur sait donc qu'une ligne s'ajoute, et quand il en connaîtra le montant. <strong>Ce qui reste ouvert</strong> : si un plancher est un jour décidé, c'est cette carte qui le porte.</p>
|
||
<p><strong>Secondaire, même passe</strong> : badge « <strong>IA incluse</strong> » sur Premium — le badge « Recommandé » <em>reste</em> sur Pro (<code>HomeClient.tsx:525</code>), il ne se déplace pas ; regrouper assistant IA + traduction automatique + audio sous un intitulé unique type « <strong>Studio IA</strong> » plutôt que trois lignes de features indistinctes ; remplacer <strong>le stockage en GB</strong> (1 / 15 / 50 GB, en dur dans <code>HomeClient.tsx</code> et <code>SegmentPageClient.tsx</code>) par une métrique que l'acheteur sait évaluer : <strong>POI / parcours / langues / sites</strong>. Personne ne sait ce que pèsent ses contenus en gigaoctets.</p>
|
||
<p>⚠️ <strong>Le « Bientôt disponible » sur Essentiel n'existe pas dans le code</strong> — vérifié le 28/08 : le seul badge « Bientôt » de la landing est sur le 4<sup>e</sup> mode de déploiement (XR, <code>HomeClient.tsx:154</code>), et la carte Essentiel pointe déjà vers <code>/signup</code>. Ce qui bloque réellement Essentiel est le <strong>déploiement de <code>app.myinfomate.be</code></strong> (carte dédiée dans ce tableau). L'arbitrage est donc : déployer, ou <em>ajouter</em> un badge honnête en attendant — pas retirer un badge absent.</p>
|
||
<span class="src">myinfomate-landing/src/data/translations.ts · HomeClient.tsx §pricing · todo-features.md — Site vitrine</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="commercial" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">myinfomate-landing</span><span class="tag">seo</span><span class="tag">linkedin</span><span class="flag f-good">Démarré 10/09 · 18 des 21 actions faites</span></div>
|
||
<h3>Plan SEO/GEO de la landing et lancement LinkedIn</h3>
|
||
<p><strong>Audit du 09/09 : 21 actions classées par rapport impact/effort.</strong> Faites le 10/09 et vérifiées dans le HTML servi : titres <code>h2</code>/<code>h3</code>, schema <code>Organization</code> ancré en Belgique avec la TVA, trois offres dans le schema, les 9 modules de la home enfin dans le HTML, liens morts, <code>llms.txt</code> en français, <strong>prérendu statique rétabli</strong> (58 pages, le layout racine est passé dans <code>[lang]</code>) et une page <strong><code>/tarifs</code></strong> avec FAQ tarifaire. <strong>Stagé, pas committé.</strong></p>
|
||
<p><strong><code>/assistant-ia</code> est écrite le 10/09, en français seulement</strong> (~850 mots, 6 FAQ, schema <code>FAQPage</code>) : en attente de relecture, puis traduction NL/EN/DE. Les autres langues répondent 404, c'est volontaire.</p>
|
||
<p><strong>Vocabulaire acheteur injecté dans les 6 segments FR le 10/09</strong> : une FAQ RGPD partout, une FAQ marchés publics dans les 5 segments publics, une FAQ accessibilité qui assume l'absence de certification Access-i. ⚠️ <strong>Pas encore traduit</strong> en NL/EN/DE.</p>
|
||
<p>Ajouté ensuite le même jour : <strong>FAQ de la page d'accueil</strong> (8 questions ×4 langues + <code>FAQPage</code>), <strong><code>BreadcrumbList</code></strong> sur toutes les pages profondes, et de <strong>vraies dates dans le sitemap</strong> à la place de la date de build.</p>
|
||
<p>Puis <strong>42 items de FAQ traduits</strong> en EN/NL/DE (parité dans les 4 langues) et <strong>images recompressées</strong> : 1,1 Mo économisé, <code>sharp</code> en installation temporaire seulement. ⚠️ <code>public/google-maps-route.png</code> (699 Ko) n'est référencé nulle part. Puis <strong>captures d'écran renouvelées avec les vraies vues de la V3</strong> (accueil bento sombre, fiche article, liste des sections) et <strong>textes alternatifs localisés</strong> dans les 4 langues.</p>
|
||
<p>Le 10/09 également : <strong>trois pages en 4 langues</strong> — <code>/marches-publics</code>, <code>/borne-kiosk</code>, <code>/visite-hors-ligne</code> — un gabarit partagé <code>ContentPage.tsx</code>, une colonne « Solutions » dans le pied de page, et <strong><code>sharp</code> en dépendance</strong> (mesuré : 64 Ko de JPEG servis sans lui, 26 Ko de WebP avec — ⚠️ à confirmer au premier build Docker).</p>
|
||
<p><strong>Suite</strong> : pages comparatives — <strong>preuve à mettre en avant, confirmée le 10/09 : Visit Namur a été gagné sur cahier des charges publié</strong>, mais ⚠️ aucun document d'appel d'offres n'existe encore, donc ne rien promettre. Les frais de mise en place restent <strong>sans montant, par décision de Thomas</strong> (carte 180).</p>
|
||
<p><strong>LinkedIn</strong> : vitrine MyInfoMate créée sous Unov, fiche produit soumise. Huit posts rédigés en deux vagues : ⚠️ ceux qui poussent à l'inscription attendent la bascule prod.</p>
|
||
<span class="src">myinfomate-landing/audit-seo-geo.md §0 · DOCS/linkedin-posts-lancement.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="doc" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">juridique</span><span class="tag">myinfomate-landing</span><span class="flag f-warn">CGU publiées le 10/09 sans relecture juridique</span></div>
|
||
<h3>Faire valider les CGU par un juriste belge</h3>
|
||
<p><strong>Les CGU sont en ligne depuis le 10/09</strong> — publier valait mieux que faire cocher « j'accepte » sur un lien mort — mais elles n'ont <strong>jamais été relues par un juriste</strong>, ce que le document source dit lui-même depuis avril. Deux points appellent un œil professionnel : le <strong>§8 (RGPD)</strong>, réécrit le 11/08 quand le guide IA s'est mis à enregistrer les questions des visiteurs, et le <strong>§9 (SLA à 99 %)</strong>, qui est un engagement chiffré tenu par une infrastructure mono-serveur.</p>
|
||
<p><strong>Deux sources à garder synchrones</strong> : <code>DOCS/cgu-myinfomate.md</code> fait foi, la page Next en est le rendu. Toute correction du juriste passe par les deux.</p>
|
||
<p>⏱️ <strong>À faire avant d'ouvrir l'inscription self-service à de vrais clients payants</strong>, pas avant la mise en prod technique. Prévoir aussi le <strong>contrat de traitement des données (DPA)</strong> qu'un acheteur public demandera — il n'existe pas encore.</p>
|
||
<span class="src">DOCS/cgu-myinfomate.md (note d'en-tête) · myinfomate-landing/src/app/(legal)/conditions-generales/page.tsx</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 & 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. ⚠️ <em>Le second motif est tombé le 12/08</em> — « la cible native ne compile même plus » n'est plus vrai, l'APK est produit depuis K1.</p>
|
||
<span class="src">architecture-web-saas.md — Kiosk web</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">tablet-app</span><span class="flag f-warn">K9 · élargi 12/08</span></div>
|
||
<h3>Repasse visuelle de la borne — bento, types, orientation</h3>
|
||
<p><strong>Arbitré le 12/08 : paysage seul en V1, portrait en V2.</strong> Une seule maquette à dessiner, les six règles de la maquette K3/K7 s'étendant aux neuf autres types. ⚠️ <strong>Mais pas « paysage en dur »</strong> : le portrait étant un objectif V2 assumé, les décisions liées à l'orientation (sens de partage, côté du rail média, position de la carte) passent par <strong>un point de décision unique et nommé</strong> qui renvoie « paysage » pour l'instant. Surcoût V1 nul, et la V2 devient un branchement au lieu d'une réécriture. Pas de maquette portrait, pas de <code>if portrait</code> spéculatif.</p>
|
||
<p><strong>a) L'écran principal en bento — le seul code vraiment neuf, et moins cher que prévu.</strong> ⚠️ Vérifié le 12/08 : le kiosk n'a <em>pas</em> le bento — <code>main_view.dart:403-405</code> est un <code>GridView</code> à <code>crossAxisCount</code> fixe, toutes les tuiles de même taille, et <code>myinfomate_layout</code> n'est pas dans son <code>pubspec.yaml</code>. ✅ Mais la donnée est déjà là : les spans vivent sur <code>AppConfigurationLinkDTO.gridColSpan/gridRowSpan</code>, donc <strong>par canal</strong>, réglables dans manager-app — et <strong>K2 a déjà posé <code>TabletAppContext.currentAppConfigurationLink</code></strong>. Il reste à ajouter la dépendance locale et appeler <code>bentoLayout()</code> comme <code>home_3.0.dart:538</code>. <strong>Bénéfice de bord : l'aperçu de manager-app cesse de montrer au client un bento que sa borne ne rend pas.</strong></p>
|
||
<p><strong>b) Inventaire par type, tranché le 12/08.</strong> À revoir : <strong>Slider</strong> (le plus faible), <strong>Weather</strong> (retouche légère). À garder comme référence : <strong>Agenda</strong> — les autres s'alignent dessus. Réputés bons, à confirmer à l'œil : Game, Web, Map, Video. Non tranchés, à regarder dans la même passe : Menu, Quiz, PDF. Article et Event sont déjà couverts par K3/K7 et ne se rouvrent pas — mais sont à repasser sur le point de décision d'orientation, sinon la V2 aura deux idiomes.</p>
|
||
<p><strong>c) Vimeo — un vrai trou, passé en V1.</strong> ⚠️ <code>SectionVideo.cs:21</code> annonce « URL YouTube/<strong>Vimeo</strong> » et le kiosk a bien son <code>video_viewer_youtube.dart</code> — mais <strong><code>vimeo</code> n'apparaît nulle part dans le Dart des deux apps visiteur</strong> : une URL Vimeo tombe dans le lecteur de fichier et ne lit rien. Lecteur à écrire sur le modèle de l'existant, branché aux deux mêmes points de dispatch. ⚠️ Les trois fichiers concernés existent <strong>en double</strong>, une copie par app : écrire une fois, copier sciemment. ⚠️ Comme YouTube, une URL reste <strong>indisponible hors ligne</strong> (<code>SectionVideo.cs:25-27</code>) — c'est voulu, ne pas le « corriger ».</p>
|
||
<p>⚠️ <strong>Non bloquant pour la bascule</strong>, contrairement à K2/K3/K4 qui réparaient un contrat d'API cassé : c'est du confort visuel, publiable après K6 au prix d'une seconde publication d'APK qui ne coûte rien sur un parc géré. <strong>Premier poste à sacrifier si la date serre.</strong> 4 à 6 jours, dont ~1 de maquette.</p>
|
||
<span class="src">v1-plan.md lot K — K9 · maquette kiosk-paysage-article-event.html</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">produit</span><span class="tag">backend</span><span class="tag">manager-app</span><span class="tag">visitapp-web</span><span class="tag">tablet-app</span><span class="flag f-warn">V2</span></div>
|
||
<h3>SectionForm — formulaires visiteurs</h3>
|
||
<p>Nouveau type de section : le lieu compose un questionnaire (texte, texte long, choix unique, choix multiple, note 1-5), le visiteur y répond <strong>anonymement</strong>, l'admin lit les réponses agrégées dans un onglet de la section, avec export CSV. Enquête de satisfaction, livre d'or, sondage d'exposition.</p>
|
||
<p><strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant. <strong>Analysé dans le code le 15/09</strong> — plan complet dans <code>v2/section-form-plan.md</code>, décisions arrêtées, ne pas refaire l'analyse.</p>
|
||
<p>Ce qui remonte aujourd'hui du visiteur est uniquement <em>dérivé</em> (<code>VisitEvent</code>, <code>VisitorQuestion</code>) : c'est le premier mécanisme qui permet à un lieu de <strong>poser une question</strong>. La moitié de la plomberie existe déjà — <code>StatsController.TrackEvent</code> pour l'écriture anonyme, <code>SectionQuiz</code>/<code>QuizQuestion</code> pour la structure, <code>VisitorQuestionPurgeService</code> pour la rétention.</p>
|
||
<p><strong>Trois fronts visiteur, kiosk compris.</strong> Piège propre à <code>tablet-app</code> : sur borne fixe le visiteur suivant hérite de l'écran du précédent — reset après soumission et sur inactivité. Et un piège backend : <code>GetEmbeddableText</code> doit indexer les libellés de questions mais <strong>jamais les réponses</strong>, sinon le guide IA récite les avis des visiteurs précédents.</p>
|
||
<span class="src">v2/section-form-plan.md — analysé le 15/09</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="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 & 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>
|
||
<p><strong>Troisième add-on à poser dans le même écran, décidé le 31/08</strong> : l'add-on <strong>« Contenu immersif » à partir de 70 €/mois</strong> (image 360 + vidéo 360 + modèles GLB). Techniquement c'est le patron d'<code>IsAssistant</code> à l'identique — <code>Instance.HasImmersiveContent</code> plus un relèvement de <code>StorageQuotaBytes</code>, l'<code>Instance</code> recopiant déjà localement les valeurs du plan (<code>Instance.cs:87-98</code>). <strong>Pas de nouvelle ligne de plan, pas de table de jointure</strong>, ~1 j. ⚠️ Mais l'enveloppe de stockage n'est pas optionnelle : un client Pro a un quota Pro et une vidéo 360 de 5 min pèse des Go.</p>
|
||
<span class="src">STATUS.md §1sexies — add-on IA · v2/vr-quest-unity-plan.md §6</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend manager" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">Studio IA</span><span class="flag f-good">V2 · lots 0-4 codés le 13/09, gate à passer</span></div>
|
||
<h3>Module Studio — génération d'images sous identité visuelle</h3>
|
||
<p>🛠️ <strong>Lots 0 à 4 codés le 13/09</strong> sur la branche <code>studio</code> (manager-service, manager-app, mymuseum-visitapp) : ingestion serveur, crédits, identité visuelle (menu <strong>Studio</strong>), génération fal.ai derrière <code>IGenerationProvider</code> (fournisseur interchangeable), onglet <strong>Générer</strong> dans le sélecteur de ressource, image d'étape rattachée à une ressource. <strong>Reste avant le gate</strong> : bucket de dev + CORS, clé fal.ai (<code>Studio__Fal__Key</code>), migrations appliquées, calibrage et prix (§9.4), puis le test du conservateur.</p>
|
||
<p>✅ <strong>Plan consolidé le 13/09.</strong> Le §8 donne, pour les lots 0 à 4, les prérequis, les étapes, les vérifications et le gate. Le §9 contient un catalogue v0 (8 styles, règle de préambule, 3 gabarits) à calibrer en fin de lot 3. Six décisions ont été prises : <strong>upload par URL signée + ingestion serveur</strong> (bucket fermé aux clients, gros fichiers hors VPS), MVP limité à « depuis le texte » et « depuis une image » (objets et lieux), prix différés avec <code>Grant</code> manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en <strong>ED25519, pas en HMAC</strong>.</p>
|
||
<p><strong>Le produit n'est pas l'accès aux modèles, c'est la cohérence.</strong> N'importe qui peut générer une image ailleurs. La valeur : qu'un conservateur qui ne sait pas prompter obtienne, en trois champs, une image raccord avec les quarante autres du même parcours. Une <code>VisualIdentity</code> par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + <strong>surcharge optionnelle par configuration</strong> — le parcours Halloween a sa propre identité, exactement comme <code>Configuration</code> porte déjà ses <code>PrimaryColor</code>/<code>Languages</code>. Injectée côté serveur dans chaque prompt, <strong>jamais retapée</strong>. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.</p>
|
||
<p>⛔ <strong>Prérequis dur, carte séparée</strong> : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.</p>
|
||
<p>⚠️ <strong>Quatre pièges déjà tranchés.</strong> Une <strong>URL Firebase à jeton est publique</strong> — un brouillon dans <code>pictures/</code> serait servable au visiteur : préfixe <code>studio-drafts/</code> + proxy authentifié. Le <strong>quota IA existant ne tient pas</strong> sur des jobs parallèles (<code>CheckQuota</code> avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un <code>CreditLedger</code>. Le <strong>plafond dur n'arrête pas le stagiaire</strong> — c'est un plafond <em>par utilisateur et par jour</em> qu'il faut, pas un plafond organisation. Et <code>GuidedStep.ImageUrl</code> <strong>est une URL, pas un <code>ResourceId</code></strong> : les images générées de l'escape game <em>ne partiraient pas offline</em> (<code>GuidedStep.cs:74</code>) — migration à faire dans le lot MVP.</p>
|
||
<p>🚫 <strong>Pas de watermark gravé dans les pixels</strong> (tranché le 01/09) : irréversible, et laid sur une illustration de musée. L'AI Act exige que le visiteur soit <em>informé</em>, pas que l'image soit tatouée. Provenance complète en <code>Resource.AiProvenance</code> (modèle, prompt effectif, refs, auteur, version d'identité, <code>rightsHolder</code> = le client) + badge « image générée par IA » dans l'UI visiteur, traduit dans les langues de la configuration. Gravure en <strong>option par instance</strong> pour l'institution qui l'exigerait par écrit.</p>
|
||
<p><strong>MVP = escape game, images seulement.</strong> Faible enjeu scientifique, fort besoin de cohérence, gros volume. Crédits en <strong>monnaie dédiée</strong>, distincte des tokens IA — griller son quota d'images ne doit pas priver les visiteurs du guide. Catalogue de modèles <strong>en base, pas dans le code</strong> (fal.ai comme passerelle unique : FLUX.2 pro, Nano Banana Pro, Recraft V3 ; Kling/Veo et Meshy/Tripo plus tard). 🧪 <strong>Le test de réussite se joue à la fin du lot 4</strong> : installer un conservateur devant l'écran et le laisser produire 10 images cohérentes seul. Si je dois prompter à sa place, l'UX a échoué — les lots suivants n'y changeront rien. <strong>Gros volume de test à prévoir.</strong></p>
|
||
<p>👤 <strong>Trois entrées distinctes dans le panneau de génération</strong>, à ne pas confondre (précisé le 02/09) : l'<strong>identité visuelle</strong> donne le style du projet, l'<strong>image source</strong> donne <em>cet objet-ci</em> (la vraie pièce de la collection, avec un curseur de fidélité : rien / le sujet / sujet + pose / cadrage exact), et le <strong>personnage</strong> donne <em>cette figure récurrente</em> — « c'est Léon qui est dans le donjon ». Le canon part en référence <em>et</em> ses paramètres (époque, costume, âge) sont injectés en texte : une image de référence seule ne porte pas « capote d'officier ».</p>
|
||
<p>⚠️ <strong>Deux personnages dans une image coûtent la cohérence du style.</strong> Le plafond de références est celui du modèle (10 pour FLUX.2) : un personnage à 4 vues + 4 références d'identité + 1 source = <strong>9/10</strong>, mais deux personnages font <strong>tronquer l'identité</strong> — donc le style dérive précisément sur l'image la plus ambitieuse. Priorité serveur au canon, troncature de l'identité ensuite, jauge visible dans l'UI et avertissement dès le deuxième. <strong>Un personnage par image</strong>, et on compose la scène autour de lui.</p>
|
||
<p>🖼️ <strong>Attention aux personnes identifiables.</strong> « Modifier une image » sur la photo d'une personne réelle, chez des institutions publiques, c'est du traitement de ressemblance : consentement écrit et tracé. La réponse produit est de <strong>rediriger vers les personnages</strong> — un avatar généré n'est le portrait de personne.</p>
|
||
<span class="src">v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0), décisions 22-23</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="manager" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">manager-app</span><span class="tag">visitapp</span><span class="tag">tablet</span><span class="tag">vr</span><span class="flag f-good">Décision arrêtée, à exécuter après la bascule</span></div>
|
||
<h3>Loader animé — presets paramétriques, pas de format de fichier</h3>
|
||
<p>Aujourd'hui le loader est une image fixe : <code>Configuration.LoaderImageUrl</code> / <code>LoaderImageId</code>, rendus par <code>loading_common.dart</code> dans les deux apps Flutter et par <code>SplashScreen</code> côté web. Le besoin : que le client compose un loader <em>animé</em> depuis le manager, sans fournir de fichier.</p>
|
||
|
||
<p><strong>Décision : pas de format de fichier animé. On transporte des paramètres.</strong> Un nouveau champ de configuration porte <code>{preset, couleurs[], logoResourceId, vitesse}</code> — quelques centaines d'octets — et chaque front implémente nativement les 5-6 mêmes presets : <code>AnimationController</code> en Flutter, CSS/Canvas en Next.js, animation Unity côté casque. Le preset n°1 existe déjà et tourne : <code>manager-app/lib/Components/loader_animated_pieces.dart</code> (6 pièces vectorielles, onde d'opacité, rotation, flottement) — il suffit d'en sortir les couleurs et les timings, aujourd'hui en dur.</p>
|
||
|
||
<p>Les couleurs sont pré-remplies depuis <code>VisualIdentityDTO.palette</code>, qui existe déjà. Aucun crédit Studio débité : le rendu est local au navigateur, rien ne passe par un modèle.</p>
|
||
|
||
<p>⛔ <strong>Le SVG animé est écarté</strong> : <code>flutter_svg</code> ignore <code><animate></code>, SMIL et les keyframes CSS — il rendrait une image figée dans les trois apps Flutter — et Unity n'a aucun rendu SVG. Un seul des quatre fronts l'afficherait.</p>
|
||
|
||
<p>⛔ <strong>Lottie est écarté à ce stade</strong>, malgré son écosystème. Format de fait et non norme, gouverné par une société unique (LottieFiles) sur une spec récente ; surtout, <em>aucun runtime moteur de jeu n'est listé sur le site officiel</em>. Les deux options Unity sont fragiles : <code>thorvg.unity</code> (Android arm64 annoncé, mais 26 commits et 14 étoiles, successeur de <code>Lottity</code> archivé en 11/2025) et <code>unity-rlottie</code> (plus fourni mais en <em>experimental</em>, avec un ticket ouvert sur une texture NULL en build Android). Les deux rastérisent en <code>Texture2D</code> à chaque frame côté CPU — inacceptable sur Quest à 72-90 Hz pour un écran de chargement.</p>
|
||
|
||
<p><strong>Porte de sortie assumée</strong> : le jour où un client veut apporter <em>sa</em> propre animation faite par une agence, on ajoute Lottie sur les 3 fronts 2D seulement, avec le PNG de première frame en repli côté VR. Le champ loader accepte alors soit des paramètres, soit une URL — rien de ce qui est fait ici n'est à refaire.</p>
|
||
|
||
<p><strong>Coût réel : les presets.</strong> Chacun est du code dans 4 dépôts, à tester sur les 4 fronts. En sortir 5-6, pas 20 : au-delà le client ne choisit plus, il se perd. Stocker les paramètres permet la réédition six mois plus tard.</p>
|
||
|
||
<p>⚠️ <strong>Pas un bloquant V1</strong> — le <code>loaderImageUrl</code> actuel fait le travail avec un PNG. À ouvrir après la bascule prod.</p>
|
||
<span class="src">décision 2026-09-15 — analyse des formats de loader animé</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="manager" data-horizon="v1">
|
||
<div class="card-meta"><span class="tag">manager-app</span><span class="tag">UI</span><span class="tag">dialogues</span><span class="flag f-good">Maquette prête, code à faire</span></div>
|
||
<h3>Refonte visuelle des écrans Configuration & Section, et de leurs dialogues</h3>
|
||
<p><strong>Lots 1 à 5 codés le 04/09</strong> (<code>flutter analyze</code> sans erreur, <code>flutter build web</code> passe ; <strong>rien de vérifié à l'écran</strong>). Pilote <code>select_resource_modal</code> : coquille standard, corps souple, plus de double défilement, <code>showValues</code> mort supprimé, largeur <code>0,85</code> conservée. Lot 1 : <code>text_field_container</code> sans <code>width: double.infinity</code> ni rayon 29, compteur discret dans <code>rounded_input_field</code>. Lot 2 : libellé au-dessus et plus d'auto-centrage dans <code>string_</code>/<code>multi_string_</code>/<code>resource_</code>/<code>check_input_container</code>. Lot 3 : grille responsive de sections, réordonnancement par <code>Draggable</code>/<code>DragTarget</code> (<code>ReorderableListView</code> ne fait pas de grille, et pas de dépendance nouvelle), tuile « Ajouter » à la place du bouton vert. Lot 4 : les deux écrans de détail en <code>EditorHeader</code> + <code>EditorColumns</code> + <code>Pane</code> (nouveau <code>Components/pane.dart</code>), <code>_buildFooter</code> supprimé, le QR devient le rail « Diffusion ». Lot 5 : grille de types dans <code>new_section_popup</code>, modal de traduction en rail de langues + <code>IndexedStack</code>, et <code>multi_input_modal</code> réduit à une redirection <code>isHTML: false</code>. 24 clés i18n ajoutées en FR/EN/NL.</p>
|
||
|
||
<p>🐛 <strong>Le bug des catégories est confirmé et corrigé</strong> : <code>showCreateOrUpdateCategories</code> recevait un <code>newValues</code> vide de l'appelant et le renvoyait tel quel — valider sans rien toucher <em>effaçait</em> les catégories. Le tampon part maintenant des catégories existantes, et les libellés en dur sont passés par <code>AppLocalizations</code>.</p>
|
||
|
||
<p><strong>Lot 6 entamé</strong> : <code>Components/collection_editor.dart</code> existe — liste ordonnée à gauche (réordonnancement par poignée), élément édité en place à droite, ligne « Ajouter » en fin de liste, suppression dans l'en-tête du détail. Trois types migrés : <strong>Slider</strong> et <strong>Article</strong> (via un <code>ContentFields</code> partagé, extrait de <code>showNewOrUpdateContentSlider</code>) et <strong>PDF</strong> (le bloc figé <code>70 × 440</code> disparaît). Les filtres d'envoi au backend sont conservés à l'identique (contenus sans ressource exclus, champs d'article recopiés).</p>
|
||
|
||
<p>⚠️ <strong>Trois fichiers sont devenus du code mort mais ne sont pas supprimés</strong> : <code>pdf_file_input_container.dart</code>, <code>pdf_list.dart</code>, <code>new_update_pdfFile.dart</code>. Ils portent des modifications non committées d'un autre lot — les effacer perdrait ce travail. À supprimer une fois l'arbre propre.</p>
|
||
|
||
<p><strong>Lot 6 terminé pour le gabarit B.</strong> Décision prise le 04/09 (artifact <code>4af75171</code>) : <code>CollectionEditor</code> reçoit un <strong>mode distant</strong> (<code>RemoteCollection</code> : <code>create</code> / <code>delete</code> / <code>reorder</code> asynchrones, voile de chargement, retour à l'état d'avant si l'appel échoue). <strong>Quiz</strong> l'utilise : la modale de question disparaît, les messages de score remontent en tête de carte, les réponses gardent <code>QuizzResponseList</code> tel quel. <strong>Menu</strong> garde la navigation vers <code>sub_section_edit_screen</code> — une sous-section est une section complète — et prend la grille de tuiles, extraite en <code>SectionGrid</code> et partagée avec les sections d'une configuration. <strong>Parcours</strong> : liste qui suit son contenu, les deux <code>SizedBox(height: 500)</code> de <code>event_config</code> et <code>section_parcours_config</code> retirés, éditeur à fenêtre unique inchangé.</p>
|
||
|
||
<p>🐛 <strong>Le rang en double est corrigé</strong> dans Quiz et Menu : ils n'écrivaient que l'<code>order</code> de l'élément déplacé, les autres gardaient le leur. La liste entière est renumérotée et envoyée en un <code>Future.wait</code>, comme le faisait déjà Parcours.</p>
|
||
|
||
<p><strong>Agenda n'a pas été migré, et c'est délibéré</strong> : ses événements sont groupés par langue (<code>_displayGroups</code>, pastilles de langue quand un groupe en compte plusieurs) et n'ont pas d'ordre — une liste ordonnée à édition en place perdrait le groupement. Il reçoit ce qui lui revient : hauteur pilotée par le contenu (le <code>height: 600</code> saute), lignes au système de tokens, modale conservée.</p>
|
||
|
||
<p><strong>Gabarit C — Jeu est fait, Map et Événement sont dégrossis.</strong> <code>game_config</code> devient champs à gauche + canevas à droite : le canevas montre le découpage réel de l'image en <code>rows × cols</code>, seule décision que le formulaire ne montrait pas. Au passage, son <code>TabBar</code> n'en était pas un — les deux onglets construisaient le même formulaire et ne servaient qu'à écrire <code>gameType</code> : c'est devenu un choix à deux valeurs, et le <code>SizedBox(height: 650)</code> a disparu. <code>geoloc_input_container</code> passe à la coquille standard et affiche les coordonnées retenues au lieu d'un bouton « Changer la localisation », la carte prenant la hauteur disponible. Côté <code>map_config</code> : en-tête en grille de tokens, <code>height: 700</code> et <code>height: 70</code> retirés. Côté <code>event_config</code> : <code>height: 600</code> retiré, la liste des blocs suit son contenu.</p>
|
||
|
||
<p><strong>Reste : le canevas de Map et d'Événement — et il est maintenant maquetté</strong> : <code>DOCS/claude design/refonte-gabarit-canevas.html</code> (diffusion : artifact <code>caf400b5</code>). Liste des points, fiche, carte côte à côte ; les points non sélectionnés restent visibles en gris ; <code>MapGeometryPicker</code> sort de son dialogue pour devenir le canevas (déplacement, pas écriture) ; les informations pratiques d'un point (5 champs sur 9) sont repliées derrière un compteur. Absorbe <code>showNewOrUpdateGeoPoint</code> (362 l.), <code>geometry_input_container</code> et <code>showNewOrUpdateMapAnnotation</code> (182 l.). <strong>Une question produit reste ouverte</strong> : que montre le canevas d'Événement quand <code>baseSectionMapId</code> est nul — carte vide, ou invitation à choisir un plan d'abord (ma proposition).</p>
|
||
|
||
<p><strong>Étape 1 du canevas faite le 04/09</strong> : <code>Components/map_canvas.dart</code> — le dessin est sorti du dialogue. Il reçoit une géométrie, la rend modifiable, rend la main <em>à chaque changement</em> (pas de bouton « Enregistrer » : c'est l'écran qui enregistre), et affiche les autres tracés en fond via <code>MapCanvasGhost</code>. Barre d'outils compacte sur la carte, pied qui donne les coordonnées et ce qu'on modifie, bouton « agrandir » optionnel. <code>MapGeometryPicker</code> n'est plus qu'une coquille autour de lui, donc <code>etape_fields</code> et <code>showNewOrUpdateEventAgenda</code> ne bougent pas. Au passage, ses libellés « Ligne » / « Polygone » en dur sont passés par <code>AppLocalizations</code>. <strong>Taille tranchée</strong> : 420 px par défaut + mode agrandi, pas une interface centrée carte — un point porte neuf champs traduits, la carte n'en porte qu'un.</p>
|
||
|
||
<p><strong>Étapes 2 à 4 faites : le gabarit C est en place dans Map.</strong> <code>Map/geo_point_editor.dart</code> — liste des points à gauche, fiche au centre, carte à droite ; les points non sélectionnés apparaissent en fond ; le mode agrandi replie la fiche et garde le rail. Les neuf champs traduits sont rangés par fréquence : titre, catégorie, description et image visibles, les cinq informations pratiques repliées derrière un compteur. <code>map_config</code> passe de 937 à ~510 lignes : la modale <code>showNewOrUpdateGeoPoint</code> n'est plus appelée, <code>getElement</code> et son <code>boxDecoration</code> sont supprimés, et recherche + catégories filtrent désormais la liste <em>et</em> la carte par un seul calcul. Les points sont créés, modifiés et supprimés directement par l'API (<code>sectionMapCreate</code> / <code>Update</code> / <code>Delete</code>), la suppression passant par la confirmation destructive.</p>
|
||
|
||
<p><strong>Étape 5 faite : le canevas d'Événement.</strong> Les annotations passent dans l'éditeur de collection en mode distant, avec le canevas dans le panneau d'édition. <strong>Plan par défaut</strong> retenu (décision Thomas) : le premier plan disponible est pris quand <code>baseSectionMapId</code> est nul, plutôt que de bloquer. 🐛 <strong>Trouvé en chemin</strong> : <code>MapAnnotationDTO</code> portait déjà un champ <code>geometry</code> que l'ancienne modale ne remplissait <em>jamais</em> — elle ne réglait que <code>geometryType</code>. Une annotation déclarait donc une forme sans jamais recevoir de coordonnées ; le canevas les lui donne. Enfin <code>showMultiStringInputAndResourceHTML</code> est passé à la coquille standard : plus aucun dialogue de ce chantier sur l'ancienne.</p>
|
||
|
||
<p><strong>Plan de test</strong> : <code>DOCS/test-plan-refonte-config-section.md</code>, 14 sections. Rien n'a été vérifié à l'écran — <code>flutter analyze</code> propre et <code>flutter build web</code> qui passe, c'est tout.</p>
|
||
|
||
<p><strong>Reste</strong> : supprimer le code mort une fois l'arbre git propre — <code>showNewOrUpdateGeoPoint</code>, <code>showNewOrUpdateMapAnnotation</code>, <code>pdf_file_input_container</code>, <code>pdf_list</code>, <code>new_update_pdfFile</code>, <code>listView_card_subSection</code>, <code>new_update_question_quizz</code> ; et <code>geometry_input_container</code> quand <code>etape_fields</code> et <code>showNewOrUpdateEventAgenda</code> auront leur canevas.</p> Restent aussi <code>showMultiStringInputAndResourceHTML</code> sur l'ancienne coquille, et le code mort à supprimer une fois l'arbre propre : <code>pdf_file_input_container</code>, <code>pdf_list</code>, <code>new_update_pdfFile</code>, <code>listView_card_subSection</code>, <code>new_update_question_quizz</code>.<br>⚠️ <strong>À vérifier manuellement en priorité</strong> : les deux chemins qui recréent les contrôleurs Quill (traduction IA, « appliquer à toutes les langues ») ; les hauteurs figées restées autour des champs devenus plus hauts (<code>height: 70</code>, <code>100</code>) dans les écrans non repris — <code>showNewOrUpdateGeoPoint</code>, <code>showNewOrUpdateEventAgenda</code>, <code>sub_section_edit_screen</code>, <code>new_configuration_popup</code>.</p>
|
||
|
||
<p><strong>Maquette prête et complète</strong> : <code>DOCS/claude design/refonte-configuration-sections.html</code> (diffusion : artifact <code>4d0c2ad6</code>). Elle contient le diagnostic, les deux écrans, les 3 gabarits qui couvrent les 13 types de section, la coquille de dialogue, et deux dialogues dessinés — traduction et choix du type. <strong>Rien à décider côté design : tout est tranché.</strong></p>
|
||
|
||
<p><strong>Le blanc ne vient pas des écrans mais de 4 composants partagés.</strong> <code>text_field_container.dart</code> impose <code>width: double.infinity</code> + rayon 29 ; <code>string_input_container.dart</code> met le label à gauche dans un <code>Row</code> ; <code>multi_string_input_container</code> et <code>resource_input_container</code> se centrent eux-mêmes ; <code>section_reorderList.dart</code> réserve <code>0,3 × hauteur</code> pour une liste horizontale de cartes 150×150. On les corrige une fois, les 13 types en héritent.</p>
|
||
|
||
<p>⚠️ <strong>Aucune direction visuelle à inventer</strong> : <code>constants.dart</code> porte déjà le système (<code>kSpace1..8</code>, <code>kRadiusInput</code> 5 / <code>Card</code> 8 / <code>Shell</code> 10 / <code>Pill</code>, <code>kTitleCard</code>, <code>kLabelField</code>, <code>kTextHint</code>) et <code>confirmation_dialog.dart</code> + <code>resource_picker.dart</code> l'appliquent déjà. Ces deux écrans sont simplement <em>antérieurs</em> au système.</p>
|
||
|
||
<p><strong>Premier pas conseillé : <code>select_resource_modal.dart</code>, pas les composants partagés.</strong> Un fichier, ~15 lignes, zéro ligne dans <code>ResourcesScreen</code>, et ça corrige un vrai bug — <code>AlertDialog</code> dispose de <code>hauteur − 48</code> mais le contenu en demande <code>0,85 × hauteur</code> plus titre plus actions, d'où un défilement dans un défilement. Ça sert de pilote avant de toucher aux composants qui irriguent 30 écrans. ⛔ <strong>Ne pas toucher à la largeur <code>0,85</code></strong> : la règle du rail de facettes de <code>v1-mediatheque-plan.md</code> §3.3 est écrite sur cette hypothèse et déjà codée.</p>
|
||
|
||
<p>⚠️ <strong>Deux pièges qui ne sont pas du visuel.</strong> D'abord, <code>manager-app</code> n'a que 3 tests, aucun sur ces composants : vérification manuelle, et il reste des conteneurs à hauteur figée (<code>height: 70</code>, <code>100</code>, <code>SizedBox(height: 250)</code>) autour — ce qui rentrait juste débordera. Ensuite un chemin d'écriture est câblé dans les composants : <code>section_detail_screen.dart:270</code> déclenche un <code>save(true, …)</code> depuis <code>MultiStringInputContainer.onGetResult</code>, et la ligne 381 un <code>save(false, …)</code> depuis la case iBeacon. À ne pas perdre ni dédoubler.</p>
|
||
|
||
<p>⚠️ <strong>Le piège Quill</strong> (remonté de mémoire par Thomas, confirmé dans le code). Ça marche aujourd'hui <em>parce que</em> l'empilement vertical monte toutes les langues : <code>_controllers</code> est bâtie une fois dans <code>initState</code> (<code>translation_input_container.dart:52</code>) et libérée seulement dans <code>dispose</code>. Le rail de langues de la maquette casse cette propriété s'il reconstruit l'éditeur à chaque bascule. <strong>Règle : map construite une fois pour toutes les langues, bascule par <code>IndexedStack</code>/<code>Offstage</code>, jamais de <code>TabBarView</code></strong> (construction paresseuse, enfants lâchés hors écran sans <code>AutomaticKeepAliveClientMixin</code>). Trace d'une tentative abandonnée : <code>translation_tab.dart</code> existe en <code>TabBar</code>, son appel est en commentaire dans <code>multi_input_modal.dart:46-51</code>. À retester en priorité : la traduction IA (ligne 142) et « appliquer à toutes les langues » (ligne 183), les deux chemins qui libèrent et recréent les contrôleurs.</p>
|
||
|
||
<p><strong>Deux fusions et un ménage</strong> décidés dans la maquette : <code>multi_input_modal.dart</code> est le jumeau sans HTML du modal de traduction (même travail, deux implémentations) → une coquille, deux corps, Quill si <code>isHTML</code> et champ simple sinon ; les 8 <code>showNewOrUpdate*</code> et les 2 sélecteurs géo disparaissent dans les gabarits B et C ; <code>showValues</code> est mort en double (<code>select_resource_modal.dart:64</code> et <code>multi_input_modal.dart:186</code>, appel commenté ligne 53).</p>
|
||
|
||
<p>⚠️ <strong>Conflit résolu avec l'audit</strong> — <code>security/audit-manager-app.md</code> recommandait d'extraire un <code>FormDialogScaffold</code> pour les 15 occurrences des <code>showNewOrUpdate*</code>. Or la refonte les <strong>supprime</strong> : les extraire d'abord, ce serait refactoriser du code à jeter. <strong>Ordre imposé : absorber d'abord (gabarits B et C), extraire ensuite sur ce qui survit</strong> — traduction, nouvelle section, nouvelle configuration, sélecteur de couleur, fichiers PDF, catégories. L'audit et <code>STATUS.md</code> ont été annotés en ce sens le 2026-09-04.</p>
|
||
|
||
<p>🐛 <strong>Trouvé en passant, à vérifier</strong> : <code>pdf_file_input_container.dart</code> et <code>category_input_container.dart</code> sont un copier-coller — mêmes dimensions, même structure — mais le second a ses libellés en dur (« Annuler », « Valider ») et son <code>onGetResult(result)</code> <strong>commenté</strong>. Une catégorie de carte modifiée ne remonte peut-être pas à la volée. Lecture de code, pas testé.</p>
|
||
<span class="src">DOCS/claude design/refonte-configuration-sections.html</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend manager" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">manager-app</span><span class="tag">Studio IA</span><span class="tag">sécurité</span><span class="flag f-warn">Code fait le 13/09, infra à faire</span></div>
|
||
<h3>Socle de stockage — URL d'envoi signée et ingestion serveur</h3>
|
||
<p>🛠️ <strong>Code livré le 13/09</strong> (branche <code>studio</code>) : URL signée, <code>ResourceIngestionService</code>, ImageSharp, upload avec progression dans manager-app, SDK Firebase retiré. <strong>Reste l'infra</strong> ci-dessous, puis la checklist du §8.</p>
|
||
<p><code>IResourceBlobService</code> n'expose que <code>DeleteAsync</code> : <strong>tout l'upload est fait par le navigateur en direct</strong>, à deux endroits de <code>resources_screen.dart</code>. Or le Studio et le TTS pré-généré produiront côté serveur.</p>
|
||
<p>⚠️ <strong>Trou de sécurité actuel, indépendant du Studio.</strong> manager-app n'a pas de Firebase Auth (<code>firebase_storage</code> sans <code>firebase_auth</code>) et écrit pourtant dans le bucket : les règles Storage sont très probablement <strong>ouvertes en écriture</strong>. Aucun <code>storage.rules</code> dans le repo — à relever dans la console.</p>
|
||
<p>✅ <strong>Tranché le 13/09 : URL signée + ingestion.</strong> L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans <code>incoming/</code>, puis <code>POST /ingest</code> : un <code>ResourceIngestionService</code> unique — le même pour le Studio et le TTS — mesure la <strong>taille réelle</strong>, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous <code>pictures/</code>. Les vidéos, 360 et GLB sont copiés côté Google, jamais lus par le VPS. Les écritures clientes sont ensuite fermées dans les règles.</p>
|
||
<p>Infra : CORS du bucket pour le <code>PUT</code>, règle de cycle de vie à 1 jour sur <code>incoming/</code>, et fermeture des règles <strong>après</strong> le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route <code>upload</code> morte et d'<code>ImageHelper</code>, <code>Firebase:StorageBucket</code> renseigné, et <strong><code>LB</code> (luxembourgeois)</strong>.</p>
|
||
<span class="src">v2/studio-plan.md — §8 lot 0, décisions 14, 18, 20</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="backend manager" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">Médiathèque</span><span class="flag f-warn">IsImageWatermark activé ne filigrane rien</span></div>
|
||
<h3>Le watermark des images est inactif depuis l'upload direct</h3>
|
||
<p><strong>Constat du 13/09.</strong> Le seul code de watermark vivait dans <code>ResourceController.Upload</code> (base64, <code>System.Drawing</code>) : il écrivait le texte <strong>« fortsaintheribert.be » en dur</strong>, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, <strong>aucune image n'est filigranée</strong>, même sur une instance où <code>Instance.IsImageWatermark</code> est activé.</p>
|
||
<p>Le lot 0 du Studio supprime ce code mort et ne réimplémente rien : pas de régression par rapport à la prod actuelle, mais le drapeau ment.</p>
|
||
<p><strong>À trancher avant de le refaire</strong>, dans le <code>ResourceIngestionService</code> du lot 0 (le seul endroit qui voit passer toutes les images) :</p>
|
||
<ul>
|
||
<li>le Fort le veut-il encore ?</li>
|
||
<li>texte ou logo, et <strong>par instance</strong> au lieu d'un nom de domaine en dur ;</li>
|
||
<li>une police embarquée dans le repo — Arial n'existe pas dans le conteneur <code>aspnet:8.0</code> Linux — via <code>SixLabors.ImageSharp.Drawing</code> ;</li>
|
||
<li>jamais sur une <code>Image360</code>, comme la compression.</li>
|
||
</ul>
|
||
<p>Distinct de la gravure « générée par IA » du Studio (<code>Instance.IsAiWatermarkBurned</code>, décision 6), qui réutilisera le même chemin.</p>
|
||
<span class="src">conversation 13/09 — lot 0 du Studio (studio-plan.md §8)</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="visitapp backend" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">3 repos</span><span class="tag">Studio IA</span><span class="flag f-warn">V2 · remplace le talking head</span></div>
|
||
<h3>Personnages — un objet, quatre facettes</h3>
|
||
<p><strong>Trois plans décrivaient le même objet sans se croiser</strong> (relevé le 01-02/09) : le canon visuel (<code>studio-plan.md</code>), les 3 frames de lipsync (<code>talking-head-plan.md</code>) et <code>PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }</code> (<code>tts-pregenerated-plan.md</code>). Une entité <code>Persona</code> les remplace : identité, visage (canon en <em>jeu de vues</em>), voix + intonation, parole.</p>
|
||
<p>⛔ <strong>Le talking head est abandonné, sa dépendance était déjà cassée.</strong> <code>talking-head-plan.md:11</code> anime ses frames « selon les timestamps retournés par <strong>Google Cloud TTS</strong> » (<code>enable_time_pointing</code>) — or le code livré tourne sur <strong>Gemini TTS</strong> (<code>GeminiTtsEngine</code>, <code>gemini-2.5-flash-preview-tts</code>) : aucune source de timestamps. Remplacé par le <strong>portrait canon statique</strong> à côté du lecteur audio, puis une boucle vidéo 6-8 s si du mouvement est voulu. En-tête d'abandon posé sur le fichier.</p>
|
||
<p>⚠️ <strong>« Le visiteur choisit entre Viva et Marco » était un choix de voix déguisé en choix de guide.</strong> Le besoin réel est celui du <strong>Bastogne War Museum</strong> : 3-4 personnages qui narrent chacun <em>leurs</em> stations, avec leur voix et leur intonation. Le conservateur assigne (<code>NarratorPersonaId</code> sur <code>GuidedStep</code>/<code>GeoPoint</code>/<code>SectionArticle</code>), le visiteur ne choisit rien. <strong>Bonne nouvelle : ça divise le stockage TTS par deux au lieu de le multiplier</strong> — chaque contenu n'existe plus qu'en une voix.</p>
|
||
<p><strong>Le wakeword devient une exigence du canal, pas une propriété du personnage.</strong> Il corrigeait un bug latent : <code>GuideName</code> est du texte libre sans lien avec le modèle OpenWakeWord, donc le visiteur dirait « Marco » à quelqu'un qui se présente comme « Léon ». Nouvelle règle : nom libre par défaut, contraint à un modèle disponible <strong>seulement si</strong> un canal mains-libres est actif (lunettes, casque VR). Mobile/web/kiosk : push-to-talk.</p>
|
||
<p>Deux champs à ajouter : <code>Persona.VoicePrompt</code> (l'intonation — aujourd'hui la <strong>constante de build</strong> <code>kGeminiTtsPrompt</code>, <code>visitapp constants.dart:27</code>) et une table <code>TtsVoice</code> à la place des deux constantes : deux voix ne suffisent pas à quatre narrateurs. Migration : les 4 colonnes <code>Instance.Guide*</code> deviennent une ligne <code>Persona</code>.</p>
|
||
<p>🔄 <strong>Révisé le 02/09 : le <code>Kind</code> Guide/Narrateur est abandonné.</strong> Un guide n'est rien d'autre qu'un personnage dont la facette <em>parole</em> est remplie. Donc un seul objet, et ce qu'il sait faire découle des facettes renseignées : <code>VoiceId</code> → il narre, <code>SystemPrompt</code> → il peut être guide, <code>WakewordId</code> → on l'appelle à la voix. <strong>Quatre combinaisons valides</strong>, y compris <em>visage seul</em> — une silhouette de décor qui revient huit fois a besoin d'un canon, pas d'une voix.</p>
|
||
<p><strong>Un guide par visite est possible</strong> : <code>Configuration.GuidePersonaId ?? Instance.GuidePersonaId</code>. Donc le gouverneur peut narrer les étapes 1, 3, 5 <em>et</em> répondre aux questions sur ce parcours, en personnage. Assignation : <em>le défaut se pose sur tout conteneur, la surcharge sur toute feuille</em> — narrateur sur <code>Instance</code> → <code>Configuration</code> → <code>GuidedPath</code>/<code>SectionMap</code>, surchargeable sur <code>GuidedStep</code>, <code>GeoPoint</code>, <code>SectionArticle</code>.</p>
|
||
<p>✅ <strong>Le mains-libres n'est pas la limite que je croyais</strong> (vérifié dans le code le 02/09) : <code>NativeWakeWordEngine</code> fait tourner <strong>N modèles en parallèle</strong> et l'événement dit lequel a déclenché (<code>detected:hey_marco</code>). Quatre tournent déjà, <strong>en dur</strong> (<code>voice_controller.dart:105</code>), sans lien avec le CMS. Cinq modèles sont embarqués : <code>hey_viva</code>, <code>hey_marco</code>, <code>hey_alba</code>, <code>hey_vasco</code>, <code>hey_visit</code>. La vraie contrainte est que le <strong>catalogue de noms est fini et embarqué au build</strong> — un autre prénom demande un entraînement et une republication de l'app.</p>
|
||
<p>🐛 <strong>Bug relevé au passage — le chemin mains-libres Android est cassé.</strong> Le moteur passe le <em>nom du modèle</em> dans <code>onDetectedWithCommand</code>, que <code>VoiceOrchestrator._onWakeWordWithCommand</code> (<code>voice_orchestrator.dart:132</code>) interprète comme <em>la question du visiteur</em> et envoie à <code>_dispatch()</code>. Le visiteur dit « Hey Marco », l'assistant répond à la question « hey_marco » et <strong>n'ouvre jamais le cycle d'écoute</strong>. À corriger indépendamment du Studio.</p>
|
||
<p>⚠️ <code>VisitorQuestion</code> a <code>ConfigurationId</code>, <code>AppType</code>, <code>IsVoice</code>, <code>ThemeId</code> — mais <strong>pas de <code>PersonaId</code></strong>. Sans lui, impossible de ventiler les stats par personnage, donc de voir qu'un personnage répond mal.</p>
|
||
<span class="src">v2/studio-plan.md §3.8 — lots 7 et 8</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="produit" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">studio</span><span class="tag">video</span><span class="tag">IA</span><span class="flag f-good">quelques euros, une soirée — débloque une décision produit</span></div>
|
||
<h3>Test « faire vivre un lieu » — 8 s au Fourneau</h3>
|
||
<p><strong>Une photo d'un bâtiment du Fourneau → sa version d'époque → un personnage en boucle de 8 s.</strong> Le but n'est pas de produire un asset, c'est de répondre à la seule question dont dépend tout le reste : <strong>est-ce que ça a l'air crédible, ou est-ce que ça décroche ?</strong> En patrimoine la crédibilité <em>est</em> la valeur — si le rendu est inquiétant, la fonctionnalité n'existe pas, quels que soient le prix et la facilité.</p>
|
||
<p><strong>Le principe à valider</strong> : le text-to-video pur est écarté (il invente la géométrie du lieu). Le lieu réel reste la base, l'IA n'ajoute que le mouvement — c'est « Point de départ » du Studio transposé à la vidéo, donc <strong>aucun mécanisme nouveau en base</strong> : <code>Kind = Video</code> et <code>sourceFidelity</code> existent déjà, c'est un gabarit de plus.</p>
|
||
<p><strong>Respecter les contraintes pendant le test, sinon il ne prouve rien</strong> : 6-10 s en boucle, <strong>une seule personne</strong>, mouvements simples, caméra calme, plan large ou moyen, <strong>jamais de gros plan</strong> sur les mains ou le visage.</p>
|
||
<p>⚠️ <strong>Deux choses déjà tranchées, à ne pas rejouer.</strong> Le <strong>motion transfer part en prestation Unov</strong>, pas dans le produit : il demande un tournage et un consentement écrit, ce qui échoue par construction le critère du §0 (« un conservateur produit sans intervention »). Et le cadrage de vente est resserré à <strong>« là où il ne reste rien »</strong> — bâtiment disparu, métier éteint, site réduit à ses fondations : partout où des archives existent, une vraie photo d'époque bat une boucle générée.</p>
|
||
<p>⛔ <strong>Ne bloque rien et n'est bloqué par rien</strong> — c'est justement l'intérêt de le faire tôt. Mais la fonctionnalité, elle, reste derrière le prérequis dur du Studio : <code>IResourceBlobService</code> n'expose toujours que <code>IsConfigured</code> et <code>DeleteAsync</code>, le backend ne sait pas écrire dans le bucket.</p>
|
||
<span class="src">v2/studio-plan.md §3.3 — décidé le 04/09, section conditionnelle</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 · POC écrit en entier, rien n'a jamais tourné</span></div>
|
||
<h3>Meta Quest — app Unity + Meta XR SDK</h3>
|
||
<p><strong>Stack tranchée le 31/08</strong> : Unity 6 LTS + OpenXR + Meta XR feature group. Le contenu se récupère par <code>GET /api/configuration/{id}/export</code> — un seul appel qui renvoie configuration, sections et ressources, donc aucun DTO C# à réécrire endpoint par endpoint. <strong>Unity n'est pas une nécessité technique</strong> : le mode kiosk vient de la gestion d'appareil, pas du moteur. Unity gagne sur l'exploitation sans surveillance — cache offline multi-Go, décodage 8K, et un build <em>figé</em> là où le navigateur du casque s'auto-mettrait à jour et pourrait casser la borne un mardi matin. <strong>Retenu : WebXR pour la maquette, Unity pour la borne</strong>, à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».</p>
|
||
|
||
<p>✅ <strong>Le POC est écrit en entier — E0 à E10, entre le 11 et le 12/09.</strong> Unity 6000.0.83f1, projet URP dans <code>vr-app/</code>, APK sideloadé sur un Quest 2 (Horizon OS v207). Mesuré sur casque : un décor glTF de 50 Mo charge en 1,4 s et tient 72 FPS, GPU au maximum.</p>
|
||
|
||
<table>
|
||
<tr><th>Item</th><th>Ce qui est écrit</th></tr>
|
||
<tr><td><strong>E2-E3</strong></td><td>Appairage par code PIN (<code>PairingService</code>) et lecture de l'export. ⚠️ Un <strong>404 sur <code>POST /api/device</code></strong> ne veut pas dire « introuvable » : il n'y a pas d'<code>ApplicationInstance</code> VR sur l'instance.</td></tr>
|
||
<tr><td><strong>E4</strong></td><td><strong>Cache d'abord</strong> : l'app démarre sur le disque, se rafraîchit en fond, et l'échec du rafraîchissement ne se voit pas. Écriture atomique — une borne se débranche le soir. C'est l'argument n°1 d'Unity contre le web.</td></tr>
|
||
<tr><td><strong>E5</strong></td><td>Menu flottant en arc à 2,2 m, qui <strong>ne suit pas la tête</strong>. <strong>Trois moyens de viser</strong> : manette (gâchette), main (pincement), tête (1,2 s, contre le « Midas touch »). ⚠️ <strong>« À la tête », pas « aux yeux »</strong> — le Quest 2 n'a pas d'eye tracking. ⚠️ La dépendance <code>E5 → E1</code> du plan <strong>était fausse</strong> : <code>OVRInput</code> et <code>OVRHand</code> viennent du Core SDK, donc <strong>E1 sort du chemin critique</strong>.</td></tr>
|
||
<tr><td><strong>E6</strong></td><td>Image et vidéo 360° en skybox. Le <strong>retour au menu ne reste pas planté dans le décor</strong> : 4 s, puis il s'efface et revient quand on baisse les yeux — toute la valeur d'une 360 est d'y être. ⚠️ Deux pièges <strong>invisibles dans l'éditeur</strong> : <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em> (sinon ciel magenta sur le casque seul), et une équirectangulaire décodée pèse <strong>134 Mo</strong> — compressée en ASTC et libérée à la sortie, sinon l'app meurt après quelques 360.</td></tr>
|
||
<tr><td><strong>E8</strong></td><td>Slider, Map, Parcours et Event, dans une même vue paginée. ⚠️ <strong>Deux écarts assumés</strong> : la Map est une <strong>liste de POI</strong>, pas la maquette 3D promise (elle demande <code>SectionModel3D</code>) ; le Parcours est rendu comme une Map, pour la même raison.</td></tr>
|
||
<tr><td><strong>E9</strong></td><td>Télémétrie « tire et oublie ». ⚠️ Route <code>/api/stats/event</code>, et <code>appType</code> y part en <strong>chaîne parsée par nom</strong> : une faute de frappe retombe sur Mobile <em>sans erreur</em>.</td></tr>
|
||
<tr><td><strong>E10</strong></td><td>Fin de visite à la repose du casque ou après 90 s, menu <strong>recentré devant le visiteur suivant</strong>, nouvelle session de stats. ⚠️ Le verrouillage de l'app sur le casque n'est pas du code : c'est le mode appareil de Meta.</td></tr>
|
||
</table>
|
||
|
||
<p>⚠️ <strong>Rien de E2 à E10 n'a jamais tourné</strong> — ni contre un vrai serveur, ni sur le casque. Le code compile, c'est tout ce qu'on sait. <strong>Plan de test §25</strong>, 9 blocs, écrit pour ça ; son §25.7 (déclarer une 360 dans le manager) se joue <strong>sans casque</strong>.</p>
|
||
|
||
<p>⚠️ <strong>Le risque est produit, pas technique, et il est intact.</strong> Le CMS est 2D : un Article sur un panneau flottant est <em>moins</em> bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent. <strong>Go/no-go conditionné à un client pilote disposant déjà de vidéos 360°</strong> — rien de ce qui a été codé ne répond à cette question-là.</p>
|
||
|
||
<p>🟡 <strong>XR-5, la supervision de flotte : l'essentiel est écrit</strong> (12/09). Un <strong>battement HTTP</strong> toutes les 3 min remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR, qui affichaient « — » faute d'alimentation — <code>Update</code> n'écrivait ni <code>AppVersion</code> ni <code>LastSeen</code>. Pas de MQTT : à 1-3 casques il coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour <strong>pousser</strong> vers le casque, pas pour remonter. <strong>Reste</strong> : le client MQTT le jour où l'on voudra recharger une configuration à distance, et une alerte quand un casque ne donne plus signe.</p>
|
||
|
||
<p>🟡 <strong>E7, la maquette 3D, est écrit aussi</strong> (12/09). <code>SectionModel3D</code> est <strong>un vrai type de section</strong> — une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres — mais il réutilise le <code>GeoPoint</code>, qui portait déjà deux rattachements : les points gardent titre, description, audio et multilingue. ✅ <strong>L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial du lot, n'était pas à écrire</strong> : <code>vr-app/viewer</code> sait déjà le faire et <strong>son protocole <code>postMessage</code> existait déjà</strong>, pensé pour ce cas. Le manager le monte en iframe, comme il le fait déjà trois fois ailleurs. Côté casque, rien de dessiné non plus : on construit un manifeste et on le passe au moteur de la piste S. ⚠️ Le viewer n'a jamais tourné, et doit être servi sous le même domaine que le manager.</p>
|
||
|
||
<p><strong>Reste au lot</strong> : <code>E11</code> (distribution : Horizon Store ou canal privé, à revérifier avant toute offre). ⚠️ <strong>MDM rétrogradé le 31/08</strong> : inutile à 1-3 casques, sideload + mode appareil suffisent.</p>
|
||
<span class="src">v2/vr-quest-unity-plan.md §2 (lots XR-4, XR-5), §4, §5 · roadmap.md — section XR</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="commercial" data-horizon="v2">
|
||
<div class="card-meta"><span class="tag">myinfomate-landing</span><span class="tag">seo</span><span class="tag">xr</span><span class="flag f-warn">À écrire seulement quand chaque module est démontrable</span></div>
|
||
<h3>Pages XR de la landing — lunettes connectées et casque VR</h3>
|
||
<p><strong>Deux pages, pas une</strong> (arbitré le 10/09) : <code>/lunettes-connectees</code> pour l'add-on Ray-Ban Meta et <code>/casque-vr</code> pour le canal immersif Meta Quest. Les deux publics et les deux modèles économiques diffèrent — un add-on mensuel d'un côté, un palier Immersif de l'autre — et les mettre sur une seule page brouillerait les deux.</p>
|
||
<p><strong>Aujourd'hui, la landing n'en dit qu'une ligne</strong> : « Meta Quest et lunettes connectées » dans le 4<sup>e</sup> mode de déploiement, avec un badge « Bientôt ». C'est honnête et c'est suffisant tant qu'il n'y a rien à montrer.</p>
|
||
<p>⏱️ <strong>Condition d'écriture : un module démontrable.</strong> Publier une page produit sur une fonctionnalité qu'on ne peut pas faire tourner en rendez-vous se retourne contre nous — c'est le même raisonnement que pour la vague 2 des posts LinkedIn. Le reste du contenu (positionnement, prix, arguments) est déjà écrit dans les deux docs de la colonne source.</p>
|
||
<span class="src">myinfomate-landing/audit-seo-geo.md action 19 · DOCS/v2/vr-quest-unity-plan.md · DOCS/rayban-meta-integration.md</span>
|
||
</article>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- BASCULE PROD -->
|
||
<section class="col" style="--stripe: var(--gate)">
|
||
<div class="col-head"><h2>Bascule prod</h2><span class="count">9</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-warn">scripts + destination prêts, attend la prod Postgres</span></div>
|
||
<h3>pg_dump avant / après + cron quotidien</h3>
|
||
<p><strong>Scripts écrits et testés le 07/09</strong> dans <code>manager-service/ManagerService/Deployment/backup/</code> : dump <code>-Fc</code> avec vérification de relecture et rotation, test de restauration comparant les comptages table par table (25 tables identiques), extraction par instance, contrôle de fraîcheur, units systemd, et une procédure de restauration écrite (<code>RESTORE.md</code>).</p>
|
||
<p><strong>La destination existe depuis le 07/09</strong> : <code>BACKUP_DEST=gcs:unov-myinfomate-backups/pg</code>, service account incapable de supprimer (vérifié par un 403). Voir la carte close « Trancher la destination des sauvegardes hors site ».</p>
|
||
<p><strong>Ce qui reste</strong> : <code>rclone</code> n'est ni installé ni configuré sur le VPS, et il n'y a de toute façon <strong>pas encore de base Postgres en prod à sauvegarder</strong>. Cette carte est donc une étape à l'intérieur de « Créer l'environnement prod Postgres », pas un chantier parallèle — une vingtaine de minutes le jour venu, plus l'acheminement de la clé du service account en <code>chmod 600</code>.</p>
|
||
<p>Deux constats du test : les embeddings exclus font passer le dump de 12 Mo à 1,7 Mo, et le « collation version mismatch » du README s'est reproduit — il bloque <code>createdb</code>, donc toute restauration.</p>
|
||
<span class="src">STATUS.md §4 · §1quinquies — étape 18</span>
|
||
</article>
|
||
|
||
<article class="card" data-area="infra">
|
||
<div class="card-meta"><span class="tag">étape 18</span><span class="flag f-warn">une alerte que personne ne lit n'est pas une alerte</span></div>
|
||
<h3>Brancher <code>notify.sh</code> sur un canal réel</h3>
|
||
<p>Trois surveillances sont en place — échec du script (<code>OnFailure=</code>), sauvegarde plus vieille que 48 h, test de restauration mensuel — et toutes appellent <code>backup/notify.sh</code>, qui ne fait aujourd'hui qu'un <code>logger</code>.</p>
|
||
<p>Le mode de panne réel n'est pas « le script plante » : c'est « le script ne tourne plus depuis trois semaines et personne ne l'a vu ». C'est précisément celui qu'un <code>OnFailure</code> n'attrape pas et que le contrôle de fraîcheur attrape — à condition que l'alerte sorte de la machine. Renseigner <code>BACKUP_ALERT_WEBHOOK</code>, ou brancher le canal mail du service.</p>
|
||
<span class="src">conversation 07/09 — chantier sauvegardes</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><project-id>.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>Soixante chantiers clos entre le 5 et le 13 août 2026. <strong>Les lots F et J sont clos, et il ne reste aucun bug ouvert.</strong></p>
|
||
<div class="done-grid">
|
||
|
||
<div class="done-item">
|
||
<strong>Purge du journal d'audit, mention dans visitapp-web, et <code>_toLangCode</code> dédoublonné</strong>
|
||
<span>⚠️ <strong>La purge d'<code>AuditLog</code> avait été oubliée</strong> à la première passe du lot J, alors que le plan l'y assignait explicitement. <code>AuditLog</code> porte un <code>UserId</code>, et les valeurs avant/après d'une modification de <code>User</code> contiennent e-mail, prénom et nom — <strong>conservés sans limite</strong>, quand <code>VisitEvent</code> purge à 13 mois et <code>VisitorQuestion</code> à 90 jours. <strong>12 mois, uniforme</strong> ; pas 13, les 13 mois des stats servent à comparer une saison à la précédente et les recopier serait du mimétisme — un test fige l'écart. Inerte tant qu'<code>Audit:RetentionDays</code> n'est pas défini : pas de pg_dump, pas de suppression.</span>
|
||
<span>⚠️ <strong><code>ExecuteDeleteAsync</code> n'est pas traduisible par le provider InMemory</strong> : la suppression se teste contre un vrai Postgres. L'écrire en chargeant les lignes puis <code>RemoveRange</code> l'aurait rendue testable en mémoire au prix de charger un an de journal — dégrader le code de production pour satisfaire un provider de test.</span>
|
||
<span><strong>Mention visiteurs dans <code>visitapp-web</code></strong>, panneau dépliable dans l'en-tête de l'assistant, même texte que l'app mobile. <strong><code>_toLangCode</code> était copié trois fois</strong> et la décision « FR/NL/EN/DE seulement » n'était écrite que dans une copie : les deux autres ressemblaient à un oubli qu'on aurait « corrigé » en ajoutant des langues, produisant un support partiel silencieux. Une seule copie désormais, une seule décision.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Lot J — le volet codable est livré (thèmes, interrupteur, mention)</strong>
|
||
<span><strong>Table d'agrégats</strong> <code>(instance, mois, thème, compteur)</code> : c'est elle qui tient la promesse du §8.4 des CGU. Sans elle le regroupement vit dans la ligne <code>VisitorQuestion</code>, donc la purge du 90<sup>e</sup> jour l'emporte avec la question et le client perd tout au 91<sup>e</sup>. Elle ne porte <strong>que des compteurs</strong> — aucune donnée personnelle, ce qui est précisément ce qui l'autorise à survivre. ⚠️ <code>Insights</code> lit désormais les thèmes <strong>dans cette table</strong>, pas dans les questions de la fenêtre.</span>
|
||
<span>⚠️ <strong>Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA.</strong> Des libellés régénérés à chaque passage donneraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes distinctes, et une courbe qui ne veut rien dire — alors que la table existe pour porter cet historique. Ajouter un thème reste possible, en renommer un coupe l'historique en deux.</span>
|
||
<span>⚠️ <strong>Le plafond de 500 par passage ne perd rien, il retarde</strong> : les plus anciennes d'abord, un passage par jour, et le job tourne à 2 h quand la purge est à 3 h 30. Un avertissement part si le retard dépasse un passage. <strong>Les jetons ne sont pas décomptés du quota client</strong> — il n'a pas demandé ces appels. Un lot en échec n'est <strong>pas</strong> marqué « Autre » pour s'en débarrasser : ce serait une perte définitive maquillée en résultat.</span>
|
||
<span><strong>Interrupteur de collecte</strong> avec UI dans l'écran Guide IA — sans elle le client ne pouvait pas exercer le refus dont il est responsable. <strong>Mention aux visiteurs</strong> accessible depuis l'assistant, là où la collecte a lieu. ⚠️ FR/NL/EN seulement, repli anglais : traduire une mention de protection des données sans relecture serait pire que la servir en anglais. <code>dotnet test</code> 226.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>D5 — une visite incomplète ne s'annonce plus téléchargée</strong>
|
||
<span>Les échecs sont collectés au lieu de disparaître dans un <code>print("NOT SUCCESSS")</code>, et le téléchargement rend un échec si la liste n'est pas vide. Nouvelle clé <code>downloadIncomplete</code> dans les <strong>10 langues</strong> — sans quoi <code>getFromLocale</code> aurait rendu une chaîne vide, le piège déjà rencontré avec <code>event.live</code>.</span>
|
||
<span>⚠️ <strong>Trouvé en câblant l'écran, et c'est pire que le bug d'origine</strong> : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » <strong>indéfiniment</strong>. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée. ✅ Relancer ne re-télécharge que ce qui manque.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Le lot F est clos — canal vocal des stats, et L9 qui n'était pas un chantier</strong>
|
||
<span><strong>Le vocal est un attribut, pas un canal</strong>, comme tranché le 12/08. <code>StatisticsService.track</code> porte <code>isVoice</code>, qui pose <code>"voice": true</code> dans <code>VisitEvent.Metadata</code> — <strong>colonne existante, donc aucune migration et le gel du lot B tient</strong>. Le serveur expose « sessions ayant utilisé le vocal » et <code>statistics_screen</code> lit cet agrégat au lieu d'<code>appTypeDistribution['Voice']</code>, qui ne se serait jamais rempli.</span>
|
||
<span>⚠️ <strong>Le <code>sectionView</code> vocal part après la synthèse, pas avant</strong> : un déclenchement dont le TTS échoue n'a rien fait entendre, le compter serait faux. ⛔ Aucun événement par question posée — elles sont déjà dans <code>VisitorQuestion</code>. ⚠️ <strong>Le test InMemory ne prouve rien ici</strong>, le provider évaluant le filtre côté client : un test Postgres a été ajouté à côté, il se saute sans démon Docker.</span>
|
||
<span>⛔ <strong>L9 était un malentendu de doc, pas du travail.</strong> La « cible Assistant / Persona » du Mode preview est exactement ce que le §5 du Guide IA a livré le 11/08. Les trois autres cibles (iframes mobile/web/kiosk, viewports, preview-token) sont hors V1. ⚠️ <strong>Mais l'aperçu polluait les stats du client</strong> : chaque essai de personnalité par le gestionnaire était journalisé comme une question de visiteur. Corrigé — et le drapeau d'hier, <code>IsAutoTriggered</code>, est renommé <code>IsVisitorQuestion</code> (défaut <code>true</code>) parce que son vrai sens couvrait déjà les deux cas. <code>dotnet test</code> 218.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Trancher la destination des sauvegardes hors site</strong>
|
||
<p>Arrêté et créé le 07/09 : projet GCP <code>myinfomate-backups</code>, bucket <code>gs://unov-myinfomate-backups</code> en <code>EU</code> multi-région, versioning, accès public interdit, lifecycle 400 jours sous <code>pg/</code>. Service account <code>backup-writer</code> en <code>objectCreator</code> + <code>objectViewer</code> — <strong>incapable de supprimer, vérifié par un 403</strong>.</p>
|
||
<p>Les médias pèsent <strong>1,43 Go</strong> (mesuré, pas estimé) : ils tiennent dans le même bucket sous <code>media/</code>, donc pas d'OVH Object Storage. La disposition croisée envisagée le matin même est abandonnée — elle se justifiait sur un volume supposé de 45 Go, qui venait en réalité du stockage Google One personnel.</p>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Une conversation, plusieurs surfaces — miroir vocal et <code>conversationId</code></strong>
|
||
<span>Le chat écrit, le vocal et le proactif partagent désormais <strong>une seule</strong> conversation, portée par <code>VisitAppContext.assistant</code>. Les trois <code>AssistantService</code> séparés ont disparu ; le <code>maxHistory: 6</code> du vocal s'aligne sur 10, deux surfaces qui partagent une conversation ne pouvant pas la tronquer différemment selon le point d'entrée. La concision vocale vient d'<code>isVoice</code>, qui change le prompt côté serveur.</span>
|
||
<span>⚠️ <strong>Le vrai obstacle n'était pas l'affichage mais la forme du chat.</strong> <code>AssistantChatSheet</code> gardait ses messages en <code>List<Widget></code> — des bulles déjà construites. On ne rejoue pas une conversation à partir de widgets, et un tour vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le service porte maintenant les tours <strong>en données</strong> et notifie ; la feuille rend depuis eux et se met à jour en direct.</span>
|
||
<span>⚠️ <strong>Deux listes, délibérément</strong> : celle envoyée au modèle et celle affichée ne coïncident pas. Un message d'erreur se montre sans repartir au guide — il n'a pas à s'excuser d'une panne au tour suivant ; le prompt d'un déclenchement proactif part au guide <strong>sans jamais s'afficher</strong>, seule sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, le visiteur trouverait dans son chat des messages qu'il n'a jamais tapés.</span>
|
||
<span>✅ <strong><code>conversationId</code> est enfin envoyé — et le champ manquait dans le client généré</strong>, ce qui explique que personne ne l'envoyait. Ajouté à la main. Il est renouvelé quand la conversation est vidée, sinon la visite entière d'un visiteur n'en formerait qu'une. 2 tests serveur fixent le lien entre deux tours et le repli <code>Guid.NewGuid()</code>, que rien ne couvrait. <code>dotnet test</code> 215, APK <code>dev</code> ✅, <code>flutter build web</code> ✅.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Les prompts du mode proactif ne polluent plus « Ce que demandent vos visiteurs »</strong>
|
||
<span>Le dernier bloquant du proactif. <code>RecordVisitorQuestion</code> écrivait <code>Question = request.Message</code> : chaque passage devant une œuvre aurait ajouté <strong>une fausse question de visiteur</strong> — une consigne que le système s'écrit à lui-même — dans l'écran fait pour montrer ce que les <strong>humains</strong> demandent. Et <code>HasAnswer</code> se déduisant des sources, un prompt d'accueil sans citation aurait <strong>gonflé le bloc ambre des questions sans réponse</strong>. Même famille que <code>WeatherSyncService</code> noyant le journal d'audit.</span>
|
||
<span><strong>Tranché : ne rien journaliser, plutôt que marquer d'un drapeau.</strong> Une ligne <code>VisitorQuestion</code> sert au rapport de trous de contenu et aux thèmes du lot J ; un prompt machine n'alimente ni l'un ni l'autre, et le garder « marqué » obligerait <strong>chaque futur agrégat</strong> à penser à l'exclure — la classe de panne qu'on venait de fermer sur <code>AuditedTypes</code>. ⚠️ <strong>Mais les jetons restent comptés</strong> : ces tours coûtent de l'argent réel au quota, et un audioguide proactif peut en consommer beaucoup. 2 tests fixent la paire — pas de ligne, compteur incrémenté — parce que c'est la combinaison qui compte.</span>
|
||
<span>Trois repos : <code>manager-service</code> (<code>dotnet test</code> 213), <code>manager-app</code> (<code>manager_api_new</code> étendu à la main, <code>flutter build web</code> ✅ — le client est partagé, donc vérifié) et <code>mymuseum-visitapp</code> (APK <code>dev</code> ✅).</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Déclenchement proactif câblé, et M3 fermé avec lui</strong>
|
||
<span>Quatre branchements : la garde, le cycle de vie, les points GPS, le réglage visiteur. APK <code>dev</code> vert. ⚠️ <strong>La garde recommandée par le plan aurait coûté de l'argent</strong> : <code>_trigger</code> appelle le LLM d'abord et ne parle qu'ensuite via <code>activeVoiceOrchestrator?.ttsEngine</code>, dont le <code>?.</code> avale le cas « aucun mode vocal actif ». Avec <code>proactiveModeEnabled</code> seul, un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons Gemini facturés au client pour une phrase que personne n'entend. La garde retenue interroge l'orchestrateur : la vraie condition n'est pas « quel matériel » mais « y a-t-il quelqu'un pour écouter ».</span>
|
||
<span>⛔ <strong>Il n'y avait pas de troisième mode à inventer</strong> : <code>VoiceMode.voiceOnly</code> existe déjà et construit le <strong>même</strong> orchestrateur que le mode lunettes — l'audioguide sur téléphone était là. Le proactif est donc un <strong>sous-réglage</strong>, pas une tuile : l'opposer aux deux autres forcerait à choisir entre « je pose des questions » et « on me parle ». <code>glassesEnabled</code> est <strong>supprimé</strong>, ses trois usages étaient morts — dont une pastille « Lunettes / Déconnecté » que personne n'avait jamais vue.</span>
|
||
<span><strong>M3</strong> : <code>meterZoneGPS</code> devient le rayon par section, la constante ne servant plus que de défaut. ⚠️ Deux pièges de format vérifiés : <code>currentSections</code> porte des <strong>maps JSON brutes</strong>, pas des DTO, et <code>latitude</code>/<code>longitude</code> y sont des <strong>chaînes</strong>. ⚠️ <strong>Reste un bloquant avant d'activer le mode</strong> — voir la carte « Ce que demandent vos visiteurs » en Bugs ouverts.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>La voix choisie par le client n'était pas celle qu'entendaient les visiteurs</strong>
|
||
<span>⛔ <strong>Le sélecteur Viva / Marco de manager-app écrivait dans le vide.</strong> Il existe depuis le 07/08 et enregistre <code>Instance.GuideVoiceId</code>, mais <code>mymuseum-visitapp</code> ne lisait <strong>jamais</strong> ce champ : il utilisait une constante de build, dont le défaut était <code>Algieba</code> — une voix que personne n'a jamais écoutée, héritée d'avant le sélecteur. <strong>Même forme que W1</strong> : un réglage offert par le CMS que l'app visiteur ignore. La voix vient désormais de l'instance ; le repli passe à <code>Sulafat</code> (Viva).</span>
|
||
<span>⚠️ <strong>Et aucun APK ne parlait avec la voix du produit.</strong> <code>GeminiTtsEngine</code> n'est choisi que si <code>GEMINI_API_KEY</code> est injectée au build — elle ne l'était nulle part, donc tous les builds retombaient <strong>en silence</strong> sur la voix système d'Android. C'est le silence qui l'a rendu invisible des mois. Le repli reste le défaut, et c'est voulu : les tests de terrain ne consomment ni jetons ni argent. Il s'annonce maintenant dans les logs, <code>constants.dart</code> porte la commande, et <code>launch.json</code> a une configuration « Dev + voix Gemini ».</span>
|
||
<span>✅ <strong>Au passage, une ligne périmée de STATUS tombe</strong> : « TTS = ElevenLabs, basculer sur Gemini avant de vendre l'add-on » — <strong>c'est déjà fait</strong>, <code>constants.dart</code> dit « ElevenLabs retiré du pipeline (trop cher) ». Le risque de marge qui motivait cette ligne est écarté. <code>impl/elevenlabs_tts_engine.dart</code> est <strong>conservé</strong> comme option, à rouvrir seulement si un client juge la voix Gemini insuffisante.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Trois services morts supprimés — et une conclusion de STATUS retournée</strong>
|
||
<span><code>wake_word_service.dart</code>, <code>glasses_qr_scanner_service.dart</code> et <code>glasses_tts_service.dart</code> portaient les <strong>4 erreurs Dart</strong> connues (<code>kElevenLabsApiKey</code>, <code>kElevenLabsVoiceId</code>) et l'APK se construisait quand même : personne ne les importait — un îlot hors du graphe de <code>main.dart</code>, même mécanique que les fichiers orphelins de <code>manager_api_new</code>. Supprimés le 13/08 : <strong><code>flutter analyze lib</code> rend zéro erreur, une première pour ce repo.</strong> ⚠️ <strong>Piège d'homonymie</strong> : <code>android/…/WakeWordService.kt</code> porte le même nom et est bien vivant — service natif déclaré au manifeste, c'est lui qui fait tourner le wake word. Seul le Dart est parti.</span>
|
||
<span>⛔ <strong>Mais le §5bis en tirait « l'APK se construit sans le POC dedans », ce qui est faux.</strong> Ces deux fichiers sont les <strong>ancêtres</strong> de <code>Services/Glasses/</code> ; le POC actuel, lui, est bien dans l'APK. Conséquence inverse de celle qui était écrite : <strong>« démontrable » ne suppose pas de remettre les constantes ElevenLabs</strong> — le chemin vivant choisit <code>GeminiTtsEngine</code> et ne les touche jamais. La bascule TTS → Gemini réclamée avant de vendre l'add-on <strong>est déjà faite dans le code</strong>.</span>
|
||
<span>⚠️ <strong>Et ça rétrécit le chantier du miroir vocal</strong> : j'avais compté cinq <code>AssistantService</code> distincts, donc cinq historiques. Deux sont dans ce code mort. <strong>Il y en a trois de vivants</strong> — le chat, le vocal, le proactif.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>FAQ de la landing — le plan à 179 € s'appelait encore « Bundle »</strong>
|
||
<span>41 occurrences en 4 langues (FR, EN, NL, DE) dans <code>src/data/segments.ts</code>, alors que les cartes de tarifs disent « Premium » : un prospect qui lisait la grille puis la FAQ voyait <strong>deux noms pour la même offre</strong>. Les formes composées des langues germaniques suivent (<code>Bundle-abonnementen</code> → <code>Premium-abonnementen</code>, <code>Bundle-Pläne</code> → <code>Premium-Pläne</code>). Vérifié : plus aucun « Bundle » dans <code>myinfomate-landing/src</code>, et les 41 occurrences étaient toutes de la prose — aucun identifiant touché. <code>npm run build</code> vert.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Lot F — le bouton du portail Stripe et le diviseur de questions (manager-app)</strong>
|
||
<span><strong>Portail de facturation</strong> : le bouton manquait, et l'écran <strong>n'affichait rien du tout hors essai</strong> — <code>if (isTrialActive)</code> enveloppait le seul bouton, donc un client payant arrivait sur un écran sans action. C'est désormais Checkout <strong>pendant</strong> l'essai, portail <strong>après</strong> ; les deux ne coexistent jamais. ⚠️ <strong>Le 409 a son propre message</strong> : les 4 instances de la bascule viennent de Mongo et n'ont pas de <code>StripeCustomerId</code> — « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. 4 clés i18n FR/EN/NL.</span>
|
||
<span><strong>Diviseur de questions</strong> : <code>guide_ia_screen</code> lit <code>aiTokensPerQuestion</code> du serveur au lieu du <code>/1000</code> codé en dur, par appel <code>http</code> direct — le motif déjà établi dans cet écran pour <code>knowledge</code> et <code>insights</code>. ⚠️ <strong>Si l'appel échoue, la ligne « ~N questions » disparaît</strong> plutôt que d'afficher un repli : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — ce que le <code>/1000</code> faisait depuis le début, à un facteur 10 près. <code>flutter analyze lib</code> sans erreur, <code>flutter build web</code> ✅.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Lot F backend — portail de facturation Stripe et coût réel d'une question</strong>
|
||
<span><strong>Customer Portal</strong> : <code>StripeService.CreateBillingPortalSessionAsync</code> + <code>POST /api/onboarding/billing-portal</code>, calqué sur <code>checkout-session</code>. ⚠️ <strong>Une garde absente de l'énoncé</strong> : <code>StripeCustomerId</code> peut être nul. <code>CreateCustomerAsync</code> n'est appelée que par l'inscription self-service, donc <strong>les 4 instances de la bascule, venues de Mongo, n'ont jamais vu Stripe</strong> — sans la garde, elles partaient chercher le portail d'un client inexistant et récupéraient une erreur d'API Stripe illisible côté front. C'est un 409, distinct du 404 d'instance inconnue.</span>
|
||
<span><strong>Ratio jetons → questions</strong> : <code>InstanceQuotaDTO.aiTokensPerQuestion</code> est désormais mesuré sur les <code>VisitorQuestion.TokensUsed</code> de l'instance, avec un seuil de 20 questions avant de faire foi — une seule réponse citant un long article doublerait la moyenne, et le crédit restant afficherait moitié moins d'un rafraîchissement à l'autre. ⚠️ <strong>Le <code>/1000</code> de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client</strong> : la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit 10 000 par question. Le gestionnaire lisait un crédit dix fois trop généreux. Le repli serveur est calé sur 10 000. <strong>Reste à brancher le diviseur dans manager-app.</strong></span>
|
||
<span>⛔ <strong>Trois points du lot F étaient déjà faits et n'attendaient qu'une vérification</strong> : le rate limiting (livré, pas seulement décidé), l'endpoint de mise à jour d'<code>ApplicationInstance</code> (CRUD complet existant — ce qui manque est l'écran SuperAdmin, déjà parqué en V2) et les quotas seed (alignés par <code>AlignSubscriptionPlansWithPricing</code>). 6 tests ajoutés, <code>dotnet test</code> 197 passés / 14 sautés / 0 échec.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>C4 — compression des images à l'upload, 2560 px / JPEG q82</strong>
|
||
<span><code>flutter build web</code> vert. Un seul helper appelé par les <strong>deux</strong> chemins d'upload de <code>resources_screen</code> — le motif exact qui a fait diverger <code>Create</code> et <code>Upload</code> côté serveur, sur <code>StoragePath</code> en C1 puis sur le pré-vol de quota en C3. ⚠️ <strong>La compression se fait avant <code>resourceCreate</code>, pas avant l'upload</strong> : le quota de C3 porte sur <code>sizeBytes</code> à la création, donc compresser après aurait fait décider le serveur sur la taille d'origine — et un quota qui compte 12× trop se remplit 12× trop vite.</span>
|
||
<span>⚠️ <strong>Écart assumé à l'énoncé</strong> : un PNG à canal alpha reste un PNG. Le passer en JPEG remplirait la transparence de noir et abîmerait logos et filigranes ; seul l'encodage diffère, le redimensionnement s'applique aux deux, et le type MIME suit. Si le ré-encodage grossit l'original — ce qui arrive sur une petite image déjà bien encodée — l'original est conservé. ⚠️ <strong>Défaut préexistant trouvé au passage</strong> : le second chemin d'upload ne renseignait <code>sizeBytes</code> ni avant ni après. Tout ce qui est passé par là compte <strong>0 octet au quota</strong> sur l'existant.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>K8 — la borne revient à l'accueil après 5 min sans interaction</strong>
|
||
<span>Un visiteur part sans fermer l'écran ; le suivant trouvait le contenu du précédent. <strong>Écrit une seule fois</strong>, dans <code>section_page_detail</code>, qui enveloppe les treize types et portait déjà les crochets des statistiques — dans les écrans, il aurait été écrit treize fois.</span>
|
||
<span><strong>Bénéfice de bord qui n'en est pas un</strong> : le retour passe par le même <code>dispose</code>, donc il émet un <code>sectionLeave</code> avec sa durée réelle. Sans lui, une consultation abandonnée restait ouverte jusqu'au visiteur suivant et gonflait la durée mesurée d'autant — <strong>K8 répare une statistique en plus d'un écran</strong>. <code>Listener</code> plutôt que <code>GestureDetector</code> pour réarmer : il observe les pointeurs sans concurrencer les gestes des écrans en dessous. ⚠️ Non vérifié : le comportement après 5 minutes réelles, et le fait que l'accueil soit bien la première route de la pile.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>La borne couvre 12 des 13 types — SectionArticle (K7) et SectionEvent (K3) livrés</strong>
|
||
<span><code>flutter build apk --debug</code> <strong>vert</strong>, APK produit. Seul <code>SectionParcours</code> reste dehors, et <strong>définitivement</strong> — un parcours guidé fait marcher le visiteur. Les deux écrans suivent la maquette paysage : rail média ancré + colonne de lecture plafonnée pour l'article, bande héros à 26 % + programme + carte vive pour l'événement, détail d'un bloc <strong>sous la carte</strong> et non en <code>showModalBottomSheet</code>.</span>
|
||
<span>⚠️ <strong>La barre de pied de la maquette n'appartenait pas aux écrans</strong> : <code>section_page_detail</code> dessine déjà le bouton retour, avec les clés <code>back</code>/<code>menu</code> selon <code>isFromMenu</code>. Les dessiner aurait donné deux boutons. ⚠️ <strong>Un seul constructeur de marqueurs au lieu des quatre de mymuseum</strong> : <code>MapAnnotationDTO</code> et <code>MapAnnotation</code> ont des champs rigoureusement identiques mais sont deux types Dart distincts. Normalisé par une classe interne à deux fabriques — c'est L5 appliqué au front, et il porte d'autant plus que la convention <code>[lng, lat]</code> est l'endroit exact où une divergence <em>ne lève aucune erreur</em>.</span>
|
||
<span>⚠️ <strong>L'audio d'article se résout dans <code>contents</code> avant l'API</strong> : <code>ContentDTO</code> porte son <code>resource</code> complet, donc l'audio est trouvé sans appel réseau quand il est embarqué — donc hors ligne aussi. Mymuseum appelle l'API systématiquement en ligne. ⚠️ <strong>Clé i18n <code>event.live</code> ajoutée aux 10 langues</strong> — sans les 10, <code>getFromLocale</code> renvoie <code>""</code> et la pastille rendrait une boîte vide. <strong>PL, CN, UK et AR sont de ma main et demandent une relecture humaine.</strong></span>
|
||
<span>⚠️ <strong>Ce qui n'est pas prouvé : le rendu.</strong> Aucun des deux écrans n'a été vu à l'œil — un build vert ne dit rien d'une mise en page, c'est la leçon du §1bis appliquée au design. Et <strong>aucun <code>SectionEvent</code> n'existe en base</strong>, le type étant né avec Postgres v3 : c'est le §19.13 cas E qui en créera un.</span>
|
||
</div>
|
||
|
||
<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>Lot F côté manager-service : les quatre chantiers de dette backend, en une passe</strong>
|
||
<span><code>dotnet test</code> <strong>163 → 200</strong>, aucun test sauté, quatre commits, <code>manager-service</code> seul. <strong>1) Le journal d'audit voyait tout sauf le contenu</strong> : <code>AuditedTypes.Contains(entry.Entity.GetType())</code> exigeait l'égalité <strong>exacte</strong>, or <code>Section</code> est abstraite — aucune des 13 sortes de section n'était journalisée, soit précisément ce que l'écran du 12/08 devait tracer. <code>Resource</code>, <code>Configuration</code>, <code>Device</code>, <code>User</code> et <code>Instance</code> passaient, eux : ils sont concrets, <strong>et c'est ce qui rendait le trou invisible</strong>. <strong>Tranché : normalisation côté serveur</strong> — <code>EntityType</code> porte <code>Section</code>, donc le filtre déjà présent dans l'écran se met à rendre des lignes <strong>sans toucher <code>manager-app</code></strong> ; élargir le filtre front aurait coûté 13 entrées et 39 clés i18n <em>et</em> laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme. Le sous-type n'est pas perdu : le discriminateur TPH est sérialisé dans <code>NewValues</code>, un test le vérifie. 8 tests, dont un <strong>par réflexion</strong> sur les 13 sous-types — la liste d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse.</span>
|
||
<span><strong>2) Plafond de 5 utilisateurs</strong> : <code>CreateUser</code> rend <strong>422</strong>. <strong>5 en dur</strong> (le faire varier par plan serait une colonne, donc une migration après le gel du lot B — dette V1 assumée) et <strong>SuperAdmin exempté</strong>, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client. Rien à retoucher côté front. <strong>3) <code>GetSummary</code> agrège en SQL</strong> au lieu de charger 13 mois en mémoire ; les six agrégats qui se lisent dans le JSON de <code>Metadata</code> ne remontent plus que <strong>deux colonnes</strong>, pour leur seul type d'événement, et les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. ⚠️ <strong>La branche « stats avancées » n'était couverte par aucun test</strong> : les cas existants ne seedent pas d'<code>Instance</code>, donc <code>hasAdvancedStats</code> était toujours faux et la moitié de la méthode n'était jamais exécutée.</span>
|
||
<span><strong>4) Le vector store est éprouvé à DEUX instances, sur un vrai Postgres</strong> — 14 tests Testcontainers sur l'image de <code>Dockerfile.postgres</code>, qui se sautent proprement sans démon Docker (186 passés / 14 sautés / 0 échec). pgvector <strong>0.8.6</strong> : le <code>SET hnsw.iterative_scan</code> que pose <code>SearchAsync</code> est accepté — sur une version antérieure, <strong>toute recherche échouait en production</strong>. À 30 contre 1, la recherche rend le bon nombre de résultats, tous de la bonne instance, aucune fuite. ⚠️ <strong>Deux résultats contraires à ce que le plan supposait</strong> : ce qui protège du post-filtrage <strong>n'est pas le parcours itératif mais l'index sur <code>(InstanceId, ContentType)</code></strong> — le planificateur filtre d'abord et trie exactement, l'index HNSW n'est jamais touché (vérifié à 620 lignes et à 22 000) ; et cet index retiré, <code>relaxed_order</code> <strong>ne rattrape rien</strong>, le parcours s'épuisant après ~335 lignes sans atteindre l'instance minoritaire. Le même jeu de données avec l'index HNSW construit <em>après</em> l'insertion rend bien ses 20 lignes : c'est la connectivité du graphe qui décide, et nos migrations créent l'index sur une table vide. <strong>Outillage</strong> : Testcontainers épinglé en 3.10.0 (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et image construite par le CLI docker — le constructeur de Testcontainers ne sait pas parser le <code>tag@sha256:</code> du Dockerfile, digest qui protège la base d'un changement de glibc sous ses index et ne se retire pas pour un test.</span>
|
||
<span>⚠️ <strong>Et une régression du point 1, trouvée et corrigée le même jour.</strong> <code>WeatherSyncService</code> écrit <code>section.WeatherResult</code> sur cron, à <strong>6 h et 13 h</strong>. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la <strong>prévision OpenWeather complète en avant ET en après</strong> — quelques dizaines de Ko, deux fois par jour, par section météo. Le stockage est le moindre problème : <strong>ce bruit noie les modifications humaines</strong> que l'écran existe pour montrer. Correctif : colonnes machine (<code>WeatherResult</code>, <code>WeatherUpdatedDate</code>, <code>DateUpdate</code>) exclues du journal, et <strong>aucune ligne produite</strong> quand une modification ne touche qu'elles — plutôt qu'une ligne au diff vide, qui aurait déplacé le bruit sans le retirer. <code>DateUpdate</code> y est pour une raison distincte : estampillé à chaque <code>SaveChanges</code>, il figurait dans tous les diffs sans rien y apprendre — <strong>et le signal était déjà là</strong>, un test écrit le matin même devait l'écarter à la main pour rester lisible. <code>AgendaSyncService</code> vérifié au passage : il écrit des <code>EventAgenda</code>, non audités. ⚠️ <strong>Trouvé en cherchant à justifier un index sur <code>Timestamp</code> signalé par réflexe</strong> — la question « pourquoi un index ? » a montré que ce qui faisait grossir la table n'était pas ce que je croyais. <strong>L'index est retiré du backlog</strong> : pas de migration, gel du lot B intact. <code>dotnet test</code> <strong>203/203</strong>.</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>L'app visiteur lit le détail d'instance, en vue réduite</strong>
|
||
<span><strong>Corrigé le 08/09.</strong> <code>GET /api/Instance/{id}</code> renvoyait 403 à <code>mymuseum-visitapp</code>, qui l'appelle au démarrage pour la voix du guide : tout <code>InstanceController</code> porte <code>[Authorize(SuperAdmin)]</code> et <code>GetDetail</code> n'avait pas d'exception, contrairement à <code>slug</code>, <code>byPin</code> et <code>app-key</code>. Une clé API donne désormais accès à <strong>sa seule instance</strong> — clé croisée = 403 — et à une vue réduite : <code>StripCommercialFields</code> retire plan, quotas, usage IA, essai, TVA, facturation et le <code>pinCode</code>, qui ouvre l'appairage des tablettes. Le manager continue de tout voir.<br><br><strong>Le piège, qui vaut pour tout le projet</strong> : le test « est-ce un utilisateur du manager » ne peut PAS se baser sur un claim de permission. <code>AuthorizationMiddleware</code> authentifie avec les schémas de la policy du contrôleur — <code>JwtBearer</code> <em>et</em> <code>ApiKey</code> — et peuple <code>HttpContext.User</code> <strong>avant</strong> de court-circuiter sur <code>[AllowAnonymous]</code>. Une clé API produit donc un <code>User</code> authentifié auquel le handler pose le claim <code>Viewer</code> : la première version laissait passer tout le monde. Elle ne se voyait pas, parce que les champs sensibles de l'instance testée étaient <em>naturellement</em> nuls — c'est le test croisé sur une instance avec un <code>pinCode</code> qui l'a révélée. Le test porte maintenant sur le schéma d'authentification.<br><br>⛔ <strong>Rectification</strong> : la carte annonçait une fuite de <code>StripeCustomerId</code> et <code>StripeSubscriptionId</code>. C'est faux — ils sont sur l'entité mais <strong>pas exposés par <code>ToDTO</code></strong>. Ce qui fuyait vraiment : plan, quotas, TVA, facturation, <code>pinCode</code>.<br><br>Vérifié sur la préprod, cinq cas : 401 sans clé, 200 sur sa propre instance, 403 en croisé dans les deux sens, 200 pour le manager avec le DTO complet. Déployé en <code>version-3.1.3</code>.</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 > 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>Le lien « conditions générales » de l'inscription ne mène nulle part</strong>
|
||
<p>Relevé et corrigé le 10/09. Les CGU sont désormais publiées sur <code>myinfomate.be/conditions-generales</code> (groupe <code>(legal)</code>, page statique), la case de consentement de <code>/signup</code> pointe dessus, et le lien figure aussi dans les pieds de page des 4 langues et dans le sitemap. Deux contradictions du document source ont été corrigées au passage : le §6.1 citait les plans abandonnés (Starter 2 Go / Standard 10 Go) et le §7.1 réservait l'assistant IA aux « plans Standard et Premium ». ⚠️ La <strong>validation juridique</strong> reste due — carte dédiée.</p>
|
||
</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>Rate limiting sur les endpoints IA</strong>
|
||
<span>La clé API publique est <strong>lisible dans le navigateur</strong> dès que visitapp-web est en ligne, et rien n'empêchait d'y boucler jusqu'à vider le quota de jetons d'un client. <code>AddRateLimiter</code> natif .NET 8, <strong>partition par instance</strong> — c'est l'instance qui porte le quota protégé, l'abus chez l'un ne doit pas ralentir les autres — fenêtre fixe 120 req/min, 429 avec <code>Retry-After</code>. Appliqué à <code>chat</code> <strong>et <code>translate</code></strong> : les deux consomment des jetons, et <code>translate</code> est atteignable avec la même clé. Ordre du pipeline choisi, pas subi : <strong>après <code>UseCors</code></strong> (un 429 posé avant les en-têtes CORS s'affiche comme une erreur CORS et le client ne voit jamais le vrai code) et <strong>après <code>UseAuthentication</code></strong> (sinon la partition n'a pas le claim d'instance et tout le monde tombe dans le même seau). <code>dotnet build</code> ✅, <code>dotnet test</code> <strong>203/203</strong>. ⚠️ <strong>Jamais exercé à l'exécution</strong> : le projet n'a aucune infrastructure de test HTTP (pas de <code>WebApplicationFactory</code>), et en monter une pour ce seul contrôle serait disproportionné. Vérification manuelle à faire une fois : une boucle de 130 appels sur <code>/api/AI/chat</code> doit basculer en 429 vers le 121<sup>e</sup>.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>D3 — <code>audio/mpeg</code> reconnu à l'écriture des fichiers</strong>
|
||
<span>La table d'extensions ne mappait que <code>audio/mp3</code>, qui <strong>n'est pas un MIME standard</strong> : les MP3 servis correctement par le serveur atterrissaient en <code>.unknown</code> sur le device. Corrigé sans attendre le test §21 — c'était un défaut certain, lisible dans le code, pas une hypothèse à mesurer.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>D4 — purge des fichiers obsolètes réactivée</strong>
|
||
<span>La liste des ressources utilisées était calculée puis jetée : <code>deleteSync()</code> commenté, le stockage occupé sur le téléphone du visiteur ne diminuait jamais. Réactivé, borné au répertoire de la configuration. À confirmer au §21, seul juge de « elle ne supprime rien d'utile ».</span>
|
||
<span>⚠️ <strong>Deux prémisses du plan étaient fausses.</strong> « Les deux chemins de téléchargement divergent » (le motif <code>Create</code>/<code>Upload</code> de C1/C3) : <strong>il n'y a qu'un seul chemin vivant</strong> — les lignes 328-461 sont dans un bloc commenté, et <code>cleanLocalResources</code> n'est appelée nulle part. Et cette fonction <strong>reste désactivée délibérément</strong> : la table locale <code>resources</code> n'a pas de colonne <code>configurationId</code>, elle est globale à toutes les visites — l'activer sur les ids d'une seule aurait effacé les lignes des autres, pour rien, puisque le rendu retrouve le fichier en listant le répertoire.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>D2 — filtre de fraîcheur sur <code>dateUpdate</code>, de bout en bout</strong>
|
||
<span><code>dateUpdate</code> exposé dans <code>ResourceDTO</code> (<strong>champ DTO, pas une colonne</strong> — le gel du lot B tient), client <code>manager_api_new</code> édité à la main, colonne ajoutée à la base locale du device (<strong>v3 → v4</strong>), et le filtre passe de « le fichier est là » à une comparaison de dates. <code>dotnet test</code> <strong>205/205</strong> (2 tests ajoutés), <code>flutter build web</code> ✅, APK <code>dev</code> ✅, <code>tablet-app</code> ✅.</span>
|
||
<span>⚠️ <strong>La prémisse était fausse, et ça change ce que D2 répare.</strong> « Une image remplacée dans le CMS ne remonte jamais » : <strong>ce cas n'existe pas</strong> — <code>Upload</code> génère un nouvel id à chaque téléversement et le blob vit en <code>pictures/{instanceId}/{resourceId}</code>, donc remplacer une image donne un nouvel id et une nouvelle URL, que l'ancien filtre téléchargeait déjà. ✅ <strong>Le vrai cas de péremption est celui que D3 vient de créer</strong> : les MP3 déjà sur les devices y sont en <code>.unknown</code> et se déclaraient à jour. <strong>Sans D2, D3 ne réparait que les installations neuves.</strong></span>
|
||
<span>⚠️ <strong>Deux pièges tranchés en écrivant</strong> : la montée v4 laisse les dates existantes à <code>NULL</code> et les considère <strong>à jour</strong> (backfiller à zéro aurait fait re-télécharger toutes les visites de tous les visiteurs sur leur réseau mobile) ; et la boucle en masse héritée de D1 <strong>écrasait la date</strong> que la boucle de téléchargement venait d'écrire — <code>DatabaseHelper.insert</code> fait un UPDATE de la ligne entière quand l'id existe.</span>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Back-office XR — le canal VR est administrable</strong>
|
||
<p><strong>Clos le 12/09.</strong> Le chantier tenait en trois lots, et les trois sont retombés : <strong>XR-1</strong> le 11/09 (<code>Device.AppType</code>, migration <code>20260911135527_AddAppTypeToDevice</code>, <code>DeviceController.Create</code> qui résout l'<code>ApplicationInstance</code> sur <code>appType</code>, <code>ApiKeyAppType.VrApp</code> en fin d'enum, 224 tests au vert), <strong>XR-3</strong> qui était déjà fait sans que le plan le sache (export ouvert aux apps depuis <code>9cc45c5</code>), et <strong>XR-2</strong> le 12/09.</p>
|
||
<p>L'écran XR est une coquille à <strong>deux sous-onglets</strong> : « Configuration », qui réutilise <code>AppConfigurationLinkScreen</code> tel quel — il est déjà générique sur l'<code>appType</code>, zéro code neuf —, et « Casques », la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL.</p>
|
||
<p>⚠️ <strong>Deux affirmations du plan étaient fausses, relevées dans le code.</strong> La grille kiosk ne liste pas des <code>Device</code> mais des <code>AppConfigurationLink</code> : le filtre par canal était déjà implicite, et le paramètre <code>appType</code> de <code>/api/device</code> ajouté par XR-1 ne sert pas à cet écran. Et batterie / version / dernier vu ne sont pas dans <code>DeviceDTO</code>, seulement dans <code>DeviceDetailDTO</code> — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités.</p>
|
||
<p>Reste au plan deux puces purement documentaires de XR-3 : figer le JSON d'export comme contrat public versionné, et décider si l'export doit rendre toutes les langues d'un coup pour un casque en borne. Le prochain vrai coût est <strong>XR-4, l'app Unity</strong> — carte séparée.</p>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>Trois plans écrits, deux écrans maquettés</strong>
|
||
<span>Médias & 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 class="done-item">
|
||
<strong>Ressource 360° — et la lecture immersive qu'elle débloque</strong>
|
||
<p><strong>Clos le 12/09.</strong> Trois valeurs ajoutées <strong>en fin</strong> de <code>ResourceType</code> — <code>Image360</code> (11), <code>Video360</code> (12), <code>Model3D</code> (13) — <strong>sans migration</strong> : la colonne est déjà <code>integer</code>, une valeur d'enum n'y change rien. Un test les verrouille une par une, parce que le commentaire du code avertissait du risque (« les PDF deviendraient des JSON ») sans que rien ne l'empêche.</p>
|
||
<p>La carte disait « une valeur d'enum et un branchement dans l'affichage ». <strong>Il manquait les deux morceaux qui comptent.</strong></p>
|
||
<p><strong>Un : comment on déclare une 360.</strong> Aucune extension ne le dit — un panorama est un <code>.jpg</code> comme un autre. La pastille de type du sélecteur de médias devient donc <strong>cliquable</strong> : elle bascule Image ↔ Image 360°, Vidéo ↔ Vidéo 360°, et se colore quand c'est immersif. Le <code>.glb</code>, lui, se déduit seul : un GLB n'est jamais autre chose qu'un modèle 3D.</p>
|
||
<p><strong>Deux, et c'est le vrai piège : la compression.</strong> <code>ImageCompressor</code> ramène toute image à 2560 px de côté long. Une équirectangulaire de 8192×4096 y perd les trois quarts de sa définition — invisible sur un écran, une bouillie dans un casque où elle couvre tout le champ de vision. Les trois types en sont exclus, sur les <strong>deux</strong> chemins d'upload, qui avaient déjà divergé deux fois par le passé.</p>
|
||
<p>✅ <strong>Bonne surprise</strong> : le prérequis « le backend ne sait pas écrire dans le bucket », qui bloque le Studio et le TTS, <strong>ne s'applique pas ici</strong> — <code>manager-app</code> pousse directement dans Firebase Storage puis enregistre l'URL. L'ancienne route serveur <code>/upload</code> plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.</p>
|
||
<p>➡️ <strong>Ce que ça débloque</strong> : <code>E6</code> du lot XR-4, la lecture 360° dans le casque, écrite dans la foulée (<code>SkyboxView</code>, image et vidéo dans le même shader panoramique). C'était le dernier prérequis du POC VR. ⚠️ <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em>, sinon le ciel sort magenta sur le casque et correct dans l'éditeur. Plan de test §25.7 — sa première moitié se joue <strong>sans casque</strong>.</p>
|
||
<p><strong>Non fait, et assumé</strong> : l'exploitation du <code>Model3D</code>. La valeur existe, mais un modèle 3D demande encore un type de section dédié, une position 3D sur les points d'intérêt et un éditeur de placement — c'est <code>E7</code>, et le §9 du plan VR le décrit.</p>
|
||
</div>
|
||
|
||
<div class="done-item">
|
||
<strong>L'appairage d'une nouvelle tablette répondait 403 depuis mars</strong>
|
||
<p><strong>Trouvé et corrigé le 12/09</strong>, en écrivant l'appairage du casque : il butait sur le même mur.</p>
|
||
<p><code>DeviceController</code> porte <code>[Authorize(InstanceAdmin)]</code> <strong>sur la classe</strong>, et <code>Create</code> n'avait aucune exception. Or une clé d'API ne porte que <code>AppRead</code> et <code>Viewer</code> (<code>ApiKeyAuthenticationHandler</code>), et <code>tablet-app</code> <strong>ne s'authentifie jamais autrement</strong> — aucun appel d'authentification dans tout le repo. <code>POST /api/device</code> répondait donc <strong>403 à toute tablette</strong>.</p>
|
||
<p><strong>Depuis quand</strong> : commit <code>a452f4a</code> du <strong>13/03/2026</strong>, dont le message dit lui-même « need to be tested ». <strong>Pourquoi personne ne l'a vu</strong> : une tablette déjà appairée ne rappelle jamais <code>Create</code>. Le parc existant continuait de fonctionner ; seul un <em>nouvel</em> appareil échouait — c'est-à-dire exactement ce qu'on ne fait pas tous les jours.</p>
|
||
<p><strong>Correctif</strong> : <code>Create</code> passe en <code>[AllowAnonymous]</code> + <code>[RequireAppKey]</code>, le filtre posé le même jour pour les routes de contenu. Le cloisonnement, lui, était déjà écrit juste en dessous — une clé ne peut créer un appareil que dans <strong>son</strong> instance.</p>
|
||
🔴 <strong>Et ce n'était pas que l'appairage.</strong> En auditant les clients le 12/09, deux autres maillons du même mur :</p>
|
||
<ul>
|
||
<li><code>GET /api/Device/{id}/detail</code> était fermé aux clés lui aussi — or c'est le <strong>premier appel d'une tablette qui démarre</strong> (<code>tablet-app/lib/main.dart:46</code>) : celui qui lui dit quelle configuration afficher. Une tablette <em>déjà appairée</em> ne retrouvait donc plus son contenu au redémarrage. Ouvert de la même façon.</li>
|
||
<li><code>tablet-app/lib/main.dart:38</code> reconstruisait son client <strong>sans clé</strong> — <code>Client(host)</code> au lieu de <code>Client(host, apiKey: …)</code> — alors que la clé <em>est</em> persistée en base locale (<code>TabletAppContext.toMap</code>). Une ligne, et tous les appels d'après partaient anonymes.</li>
|
||
</ul>
|
||
<p>Les deux se tenaient : la route refusait les clés, et l'app n'en envoyait pas. Corrigés ensemble.</p>
|
||
<p>⚠️ <strong>À vérifier sur le terrain</strong> : appairer une <em>vraie</em> nouvelle tablette, <strong>et redémarrer une tablette déjà appairée</strong>. Le correctif est couvert par les tests, mais les tests appellent le contrôleur directement — <strong>aucun filtre d'autorisation ne s'y exécute</strong>, donc ils ne prouvent rien sur l'accès lui-même.</p>
|
||
</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[6]) { stats[6].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>
|
||
|