From 0ae8321f7fecfb8990b2b51816e6540ea63199cc Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Tue, 28 Jul 2026 13:12:21 +0200 Subject: [PATCH 1/7] fix(feed): resolve P0 empty-state ambiguity by extracting buildFeedStory to core The feed hero story's precedence rules (error vs offline vs broken sources vs new/priority vs empty) lived inline in FeedPage as a derived, making them untestable without mounting the whole page and conflating shell wiring with presentation logic. Extract `buildFeedStory` to `src/lib/core/feed/build-feed-story.ts` as a pure resolver (no I/O, no async, no Date.now()). Add a `hasCompletedScan` input to disambiguate the `scanned-empty` attention state from the pre-scan idle state (P0 from the side-panel critique: the hero previously could not tell "nothing matched" apart from "we haven't scanned yet"). Author `src/models/feed-story.model.md` as the presentation model of record (six states, strict precedence, copy decisions). FeedPage now imports the pure function; the operational-ui-constraints guard test is updated to assert the new-mission cue against the core module that now owns it. Tests: 30/30 in build-feed-story.test.ts. Typecheck + lint green. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/lib/core/feed/build-feed-story.ts | 220 +++++++++ apps/extension/src/models/feed-story.model.md | 178 +++++++ apps/extension/src/ui/pages/FeedPage.svelte | 201 +------- .../tests/unit/feed/build-feed-story.test.ts | 434 ++++++++++++++++++ 4 files changed, 846 insertions(+), 187 deletions(-) create mode 100644 apps/extension/src/lib/core/feed/build-feed-story.ts create mode 100644 apps/extension/src/models/feed-story.model.md create mode 100644 apps/extension/tests/unit/feed/build-feed-story.test.ts diff --git a/apps/extension/src/lib/core/feed/build-feed-story.ts b/apps/extension/src/lib/core/feed/build-feed-story.ts new file mode 100644 index 00000000..5b41f719 --- /dev/null +++ b/apps/extension/src/lib/core/feed/build-feed-story.ts @@ -0,0 +1,220 @@ +/** + * Pure resolver for the feed operational story. + * + * Model: src/models/feed-story.model.md + * + * Extracted from FeedPage so the precedence rules (error vs offline vs broken + * sources vs new/priority vs empty states) are unit-testable without mounting + * the whole page. Shell/page wiring assembles the inputs; this function owns + * the decision tree and the copy. + * + * PURE: no I/O, no async, no Date.now(), no chrome.*, no randomness. + */ + +import type { IconName as FeedIconName } from '@pulse/ui'; + +export type FeedStorySeverity = 'critical' | 'incident' | 'attention' | 'success' | 'neutral'; + +export interface OperationalEvidence { + label: string; + value: string | number; + icon?: FeedIconName; + severity?: 'critical' | 'success' | 'attention' | 'neutral'; +} + +export interface FeedStory { + severity: FeedStorySeverity; + statusLabel: string; + title: string; + description: string; + evidence: OperationalEvidence[]; + primaryActionLabel: string; + primaryActionIcon: FeedIconName; +} + +export interface FeedStoryInput { + error: string | null; + isOffline: boolean; + brokenConnectorCount: number; + firstBrokenConnectorName: string | null; + newCount: number; + highScoreCount: number; + visibleCount: number; + alertEnabled: boolean; + alertScoreThreshold: number; + hasCompletedScan: boolean; +} + +function formatStoryMissionCount(count: number): string { + return `${count} mission${count > 1 ? 's' : ''}`; +} + +export function buildFeedStory(input: FeedStoryInput): FeedStory { + const { + error, + isOffline, + brokenConnectorCount, + firstBrokenConnectorName, + newCount, + highScoreCount, + visibleCount, + alertEnabled, + alertScoreThreshold, + hasCompletedScan, + } = input; + + const evidence: OperationalEvidence[] = [ + { + label: 'Nouvelles', + value: newCount, + icon: 'sparkles', + severity: newCount > 0 ? 'attention' : 'neutral', + }, + { + label: `Prioritaires ${alertScoreThreshold}+`, + value: highScoreCount, + icon: 'target', + severity: highScoreCount > 0 ? 'success' : 'neutral', + }, + { + label: 'Sources en erreur', + value: brokenConnectorCount, + icon: brokenConnectorCount > 0 ? 'triangle-alert' : 'shield-check', + severity: brokenConnectorCount > 0 ? 'critical' : 'success', + }, + ]; + + // Precedence order (model): error > offline > broken sources > new > priority + // > scanned-empty > never-scanned > feed-ready + + if (error) { + // The feed list still renders cached missions, so degrade the hero + // story to a warning rather than a critical "impossible to retrieve" + // incident. Only escalate to critical when nothing is visible. + if (visibleCount > 0) { + return { + severity: 'incident', + statusLabel: 'Données en cache', + title: 'Récupération interrompue — affichage en cache', + description: `Les ${formatStoryMissionCount(visibleCount)} déjà récupérées restent disponibles. Réessayez le scan ou vérifiez vos sources.`, + evidence, + primaryActionLabel: 'Réessayer le scan', + primaryActionIcon: 'refresh-cw', + }; + } + return { + severity: 'critical', + statusLabel: 'Incident', + title: 'Impossible de récupérer les missions', + description: 'Réessayez le scan ou vérifiez vos sources pour récupérer les missions.', + evidence, + primaryActionLabel: 'Réessayer le scan', + primaryActionIcon: 'refresh-cw', + }; + } + + if (isOffline) { + return { + severity: 'incident' as const, + statusLabel: 'Hors ligne', + title: 'Pulse affiche les données en cache', + description: + 'Le scan est suspendu. Vous pouvez encore qualifier, filtrer et ouvrir les missions déjà stockées.', + evidence, + primaryActionLabel: + visibleCount > 0 + ? `Voir les ${formatStoryMissionCount(visibleCount)} en cache` + : 'Hors ligne', + primaryActionIcon: visibleCount > 0 ? 'chevron-down' : 'database', + }; + } + + if (brokenConnectorCount > 0) { + return { + severity: 'critical' as const, + statusLabel: 'Action requise', + title: `${brokenConnectorCount} source${brokenConnectorCount > 1 ? 's' : ''} à corriger avant de traiter les missions`, + description: `${firstBrokenConnectorName ?? 'Une source'} ne remonte plus correctement. Le feed peut manquer des opportunités.`, + evidence, + primaryActionLabel: 'Relancer le diagnostic', + primaryActionIcon: 'refresh-cw', + }; + } + + if (newCount > 0) { + return { + severity: 'attention' as const, + statusLabel: 'À traiter', + title: + highScoreCount > 0 + ? `${highScoreCount} mission${highScoreCount > 1 ? 's' : ''} prioritaire${highScoreCount > 1 ? 's' : ''} à examiner` + : `${newCount} nouvelle${newCount > 1 ? 's' : ''} mission${newCount > 1 ? 's' : ''} à examiner`, + description: + highScoreCount > 0 + ? `${newCount} nouvelle${newCount > 1 ? 's' : ''} mission${newCount > 1 ? 's' : ''} au total. Commencez par celles qui dépassent le seuil ${alertScoreThreshold}+.` + : 'Aucune urgence détectée, mais les nouvelles missions méritent une qualification rapide.', + evidence, + primaryActionLabel: + highScoreCount > 0 + ? `Voir les ${formatStoryMissionCount(highScoreCount)} prioritaires` + : `Voir les ${formatStoryMissionCount(newCount)} nouvelles`, + primaryActionIcon: 'chevron-down', + }; + } + + if (alertEnabled && highScoreCount > 0) { + return { + severity: 'success' as const, + statusLabel: 'Priorités prêtes', + title: `${highScoreCount} opportunité${highScoreCount > 1 ? 's' : ''} prioritaire${highScoreCount > 1 ? 's' : ''} prête${highScoreCount > 1 ? 's' : ''}`, + description: `Elles dépassent votre seuil ${alertScoreThreshold}+. Comparez-les avant de mettre une mission en suivi.`, + evidence, + primaryActionLabel: + alertScoreThreshold >= 80 + ? `Voir les ${formatStoryMissionCount(highScoreCount)} prioritaire${highScoreCount > 1 ? 's' : ''}` + : `Voir les ${formatStoryMissionCount(highScoreCount)} prioritaires`, + primaryActionIcon: 'chevron-down', + }; + } + + // Empty states — distinguish never-scanned vs scanned-empty (P0 fix) + if (visibleCount === 0) { + if (hasCompletedScan) { + // Scanned, found nothing matching — attention state, route to Profile + return { + severity: 'attention' as const, + statusLabel: 'Aucune correspondance', + title: 'Aucune mission ne correspond à votre profil actuel', + description: + 'Ajustez vos critères de recherche, compétences ou localisation dans votre profil pour élargir les résultats.', + evidence, + primaryActionLabel: 'Ajuster le profil', + primaryActionIcon: 'user', + }; + } + + // Never scanned — neutral state, invite to scan + return { + severity: 'neutral' as const, + statusLabel: 'Aucune donnée', + title: 'Lancez un premier scan pour voir vos missions', + description: + 'Connectez ou vérifiez les sources, puis lancez un scan pour obtenir les premières recommandations.', + evidence, + primaryActionLabel: 'Lancer le scan', + primaryActionIcon: 'play', + }; + } + + // Feed ready — missions available, no action required + return { + severity: 'success' as const, + statusLabel: 'Normal', + title: `${visibleCount} mission${visibleCount > 1 ? 's' : ''} disponible${visibleCount > 1 ? 's' : ''}, aucune priorité critique`, + description: + 'Le système est stable. Continuez par les favoris ou relancez un scan si la veille doit être rafraîchie.', + evidence, + primaryActionLabel: `Voir les ${formatStoryMissionCount(visibleCount)}`, + primaryActionIcon: 'chevron-down', + }; +} diff --git a/apps/extension/src/models/feed-story.model.md b/apps/extension/src/models/feed-story.model.md new file mode 100644 index 00000000..d88954ac --- /dev/null +++ b/apps/extension/src/models/feed-story.model.md @@ -0,0 +1,178 @@ +# Feed Story Model — Operational hero card presentation + +> Modèle de présentation pour la **story card opérationnelle** en haut du feed. +> Pure projection — introduit aucune transition produit. Source de vérité pour +> l'état visuel du hero. + +## Statut + +Ce modèle est **une projection de présentation pure**. Il ne définit **aucune +nouvelle transition produit**. L'état canonique des missions et du scan reste +dans `scan-lifecycle.model.md` et `feed-page.svelte.ts`. `buildFeedStory()` ne +fait que projeter les faits du contrôleur/page en copy/sévérité/action. + +**Le modèle décide ; la présentation reflète.** + +## Périmètre + +Card opérationnelle affichée en haut du feed (slot hero). Réagit aux états du +feed : erreurs, offline, sources cassées, nouvelles missions, prioritaires, +empty states. **Jamais** un widget permanent — c'est un call-to-action contextuel. + +## États de présentation + +Six états distincts, avec **précédence stricte** (de haut en bas) : + +| État | Sévérité | Condition | Intention | +| --------------------- | ----------- | ------------------------------------------------------ | ------------------------------------------------- | +| `error-cached` | `incident` | `error != null && visibleCount > 0` | Données en cache disponibles, scan interrompu | +| `error-critical` | `critical` | `error != null && visibleCount === 0` | Aucune donnée disponible, scan impossible | +| `offline` | `incident` | `isOffline` | Hors ligne, données en cache disponibles | +| `broken-sources` | `critical` | `brokenConnectorCount > 0` | Sources cassées, feed incomplet | +| `new-missions` | `attention` | `newCount > 0` | Nouvelles missions à traiter, prioritaires ou non | +| `priority-ready` | `success` | `alertEnabled && highScoreCount > 0 && newCount === 0` | Missions prioritaires disponibles (seuil dépassé) | +| `scanned-empty` | `attention` | `visibleCount === 0 && hasCompletedScan` | **Scan terminé, 0 résultat — ajuster le profil** | +| `never-scanned-empty` | `neutral` | `visibleCount === 0 && !hasCompletedScan` | Premier lancement, inviter au scan | +| `feed-ready` | `success` | _défaut final_ | Feed prêt, aucune action requise | + +**Précédence** : erreur > offline > sources cassées > nouvelles > prioritaires > +scanned-empty > never-scanned > feed-ready. + +### Nouveauté : distinction des empty states + +Avant ce modèle, `visibleCount === 0` affichait toujours un neutral CTA "Lancez +un premier scan" — ambiguïté P0 quand un scan a **déjà terminé** et légitimement +trouvé zéro match. + +**Signal discriminant** : `hasCompletedScan` (dérivé de `controller.lastScanAt != null`) + +- `lastScanAt === null` → jamais scanné → `never-scanned-empty` (neutral, CTA scan) +- `lastScanAt != null` → déjà scanné → `scanned-empty` (attention, CTA profil) + +Invariant : `lastScanAt` est monotone (uniquement mis à jour sur succès scan), +donc `hasCompletedScan` est un edge-detector fiable. Cf. `scan-lifecycle.model.md` +et `scan-completion-delight.model.md` pour le contrat de `lastScanAt`. + +## Entrées (pures) + +```ts +interface FeedStoryInput { + error: string | null; + isOffline: boolean; + brokenConnectorCount: number; + firstBrokenConnectorName: string | null; + newCount: number; + highScoreCount: number; + visibleCount: number; + alertEnabled: boolean; + alertScoreThreshold: number; + hasCompletedScan: boolean; // ← ajouté pour discriminer les empty states +} +``` + +## Sorties + +```ts +interface FeedStory { + severity: 'critical' | 'incident' | 'attention' | 'success' | 'neutral'; + statusLabel: string; + title: string; + description: string; + evidence: OperationalEvidence[]; + primaryActionLabel: string; + primaryActionIcon: IconName; +} +``` + +## Matrice de copie (FR) + +### `scanned-empty` (attention) — NOUVEAU + +- **statusLabel** : `Aucune correspondance` +- **title** : `Aucune mission ne correspond à votre profil actuel` +- **description** : `Ajustez vos critères de recherche, compétences ou localisation dans votre profil pour élargir les résultats.` +- **primaryActionLabel** : `Ajuster le profil` +- **primaryActionIcon** : `user` +- **evidence** : inchangée (Nouvelles=0, Prioritaires=0, Sources=0) + +### `never-scanned-empty` (neutral) — existant, conservé + +- **statusLabel** : `Aucune donnée` +- **title** : `Lancez un premier scan pour voir vos missions` +- **description** : `Connectez ou vérifiez les sources, puis lancez un scan pour obtenir les premières recommandations.` +- **primaryActionLabel** : `Lancer le scan` +- **primaryActionIcon** : `play` +- **evidence** : inchangée + +## Actions primaires + +| État | Action primaire | Handler | +| --------------------- | ------------------------- | --------------------------------------- | +| `error-*` | `Réessayer le scan` | `handleMissionFeedScanAction()` | +| `offline` | `Voir les N en cache` | `scrollToMissionFeed()` | +| `broken-sources` | `Relancer le diagnostic` | `controller.recheckConnector(id)` | +| `new-missions` | `Voir les N nouvelles` | `toggleNewOnly() + scroll` | +| `priority-ready` | `Voir les N prioritaires` | `showAlertOnly = true + scroll` | +| **`scanned-empty`** | **`Ajuster le profil`** | **`appNavigation.navigate('profile')`** | +| `never-scanned-empty` | `Lancer le scan` | `handleMissionFeedScanAction()` | +| `feed-ready` | `Voir le feed` | `scrollToMissionFeed()` | + +**Nouveauté** : `scanned-empty` route vers la page **Profile** pour permettre +l'ajustement des critères — pas vers un nouveau scan. + +## Implémentation — résolveur pur + +`buildFeedStory(input: FeedStoryInput): FeedStory` est une **fonction pure** +dans le Core (`src/lib/core/feed/build-feed-story.ts` si extraite, ou inline +dans `FeedPage.svelte` module script). Zéro I/O, zéro async, zéro chrome.\*. + +**Injection** : `hasCompletedScan` est passé depuis le shell via le `$derived` +de FeedPage : + +```ts +const feedStory = $derived( + buildFeedStory({ + // ...inputs existants + hasCompletedScan: controller.lastScanAt != null, + }) +); +``` + +## Invariants + +1. La story **ne bloque jamais** le feed — elle est un guide contextuel. +2. Aucune transition produit n'est créée ici — pure projection. +3. `buildFeedStory` est **testable sans mocks** (fonction pure). +4. `scanned-empty` et `never-scanned-empty` sont **mutuellement exclusifs** : + `hasCompletedScan` est le discriminant booléen. +5. Le handler `scanned-empty` **ne lance jamais de scan** — il route vers Profile. +6. `hasCompletedScan` est monotone (une fois `true`, reste `true` sauf reset app). + +## Cas de test obligatoires + +- `error + visibleCount > 0` → `error-cached` (incident) +- `error + visibleCount === 0` → `error-critical` (critical) +- `isOffline` → `offline` (incident) +- `brokenConnectorCount > 0` → `broken-sources` (critical) +- `newCount > 0` → `new-missions` (attention) +- `alertEnabled && highScoreCount > 0 && newCount === 0` → `priority-ready` (success) +- **`visibleCount === 0 && hasCompletedScan === true`** → **`scanned-empty`** (attention) +- **`visibleCount === 0 && hasCompletedScan === false`** → **`never-scanned-empty`** (neutral) +- `visibleCount > 0 && newCount === 0 && highScoreCount === 0` → `feed-ready` (success) + +## Changements requis dans le code + +1. **Type** : ajouter `hasCompletedScan: boolean` à `FeedStoryInput` +2. **Fonction** : insérer la branche `scanned-empty` avant `never-scanned-empty` + dans l'arbre de décision de `buildFeedStory` +3. **Page** : passer `hasCompletedScan: controller.lastScanAt != null` au + `buildFeedStory` dans le `$derived` +4. **Handler** : ajouter une branche dans `handleFeedStoryPrimaryAction` pour + détecter `scanned-empty` et appeler `appNavigation.navigate('profile')` +5. **Tests** : couvrir les deux nouveaux cas empty dans les unit tests + +## Références + +- **Signal canonique** : `scan-lifecycle.model.md` (contrat de `lastScanAt`) +- **Edge detection** : `scan-completion-delight.model.md` (usage de `lastScanAt` monotone) +- **Navigation** : `app-navigation.svelte.ts` (`navigate(page: Page)`) diff --git a/apps/extension/src/ui/pages/FeedPage.svelte b/apps/extension/src/ui/pages/FeedPage.svelte index 42e5f338..44da9710 100644 --- a/apps/extension/src/ui/pages/FeedPage.svelte +++ b/apps/extension/src/ui/pages/FeedPage.svelte @@ -1,191 +1,11 @@