-
Notifications
You must be signed in to change notification settings - Fork 0
fix(sidepanel): resolve top 3 critique findings (P0 empty-state, P1 comparison, P1 FilterBar) #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0ae8321
fix(feed): resolve P0 empty-state ambiguity by extracting buildFeedSt…
guyghost e24e17a
refactor(comparison): distill MissionComparison modal to decision-fir…
guyghost cb0dc3f
feat(filters): constrain FilterBar tech chips to top-8 with overflow …
guyghost dea9ab0
docs(design): add side-panel impeccable critique record
guyghost a26022f
fix(ui): address review — overflow count, toggle a11y, stack dict safety
guyghost 5732b99
fix(feed): distinguish filtered-empty from scanned-empty feed story
guyghost 412704c
fix(e2e): import buildFeedStory into FeedPage instance script
guyghost File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
155 changes: 155 additions & 0 deletions
155
.impeccable/critique/2026-07-28T10-26-20Z__apps-extension-src-sidepanel.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| /** | ||
| * 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; | ||
| filterActive: boolean; | ||
| totalMissionCount: number; | ||
| } | ||
|
|
||
| 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, | ||
| filterActive, | ||
| totalMissionCount, | ||
| } = 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 filtered-empty vs scanned-empty vs never-scanned | ||
| if (visibleCount === 0) { | ||
| // Cached missions exist but active filters hide them all — clear filters, | ||
| // do not route to Profile or invite a redundant scan. | ||
| if (filterActive && totalMissionCount > 0) { | ||
| return { | ||
| severity: 'attention' as const, | ||
| statusLabel: 'Filtres sans résultat', | ||
| title: 'Aucune mission ne correspond à vos filtres actifs', | ||
| description: | ||
| 'Des missions sont disponibles mais vos filtres les masquent toutes. Ajustez ou effacez les filtres pour les réafficher.', | ||
| evidence, | ||
| primaryActionLabel: 'Effacer les filtres', | ||
| primaryActionIcon: 'filter-x', | ||
| }; | ||
| } | ||
|
|
||
| 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', | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /** | ||
| * Stack ranking utilities — pure functions for ranking and selecting | ||
| * technology stacks by frequency, with stable sorting and selected-pinning. | ||
| */ | ||
|
|
||
| export interface StackCount { | ||
| stack: string; | ||
| count: number; | ||
| } | ||
|
|
||
| /** | ||
| * Ranks stacks by count descending, tiebreak alphabetically for determinism. | ||
| */ | ||
| export function rankStacksByCount(stackCounts: Record<string, number>): string[] { | ||
| return Object.entries(stackCounts) | ||
| .sort((a, b) => { | ||
| const countDiff = b[1] - a[1]; | ||
| if (countDiff !== 0) { | ||
| return countDiff; | ||
| } | ||
| // Stable tiebreak: alphabetical | ||
| return a[0].localeCompare(b[0]); | ||
| }) | ||
| .map(([stack]) => stack); | ||
| } | ||
|
|
||
| /** | ||
| * Computes the visible stack set: top-N by rank + any selected stacks | ||
| * outside the top-N (pinned). | ||
| * | ||
| * Returns a Set for O(1) membership checks. | ||
| */ | ||
| export function computeVisibleStacks( | ||
| rankedStacks: string[], | ||
| selectedStacks: string[], | ||
| topN: number | ||
| ): Set<string> { | ||
| const visible = new Set(rankedStacks.slice(0, topN)); | ||
| for (const selected of selectedStacks) { | ||
| visible.add(selected); | ||
| } | ||
| return visible; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the number of available stacks that are NOT visible — i.e. neither | ||
| * in the top-N nor pinned as a selected stack. This is the true count the | ||
| * "Voir N autres" toggle represents. | ||
| * | ||
| * The previous `total - topN` formula overcounted when selected stacks were | ||
| * pinned outside the top-N: those pinned stacks are already visible, so they | ||
| * must not be reported as hidden overflow. | ||
| */ | ||
| export function computeOverflowCount( | ||
| availableStacks: readonly string[], | ||
| visibleStacksSet: ReadonlySet<string> | ||
| ): number { | ||
| let hidden = 0; | ||
| for (const stack of availableStacks) { | ||
| if (!visibleStacksSet.has(stack)) { | ||
| hidden += 1; | ||
| } | ||
| } | ||
| return hidden; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.