Skip to content

Large diffs are not rendered by default.

239 changes: 239 additions & 0 deletions apps/extension/src/lib/core/feed/build-feed-story.ts
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) {
Comment thread
guyghost marked this conversation as resolved.
// 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',
};
}
65 changes: 65 additions & 0 deletions apps/extension/src/lib/core/filters/stack-ranking.ts
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;
}
18 changes: 14 additions & 4 deletions apps/extension/src/lib/state/feed-page.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
consumeDeepLinkIntent,
subscribeToNotificationClicked,
} from '$lib/shell/facades/feed-data.facade';
import { rankStacksByCount } from '$lib/core/filters/stack-ranking';
import { getPanelSide } from '$lib/shell/ui/panel-layout';
import { isPromptApiAvailable } from '$lib/shell/ai/capabilities';
import { showToast, showToastAction } from '$lib/shell/notifications/toast-service';
Expand Down Expand Up @@ -382,16 +383,22 @@ export function createFeedPageState(
showNewOnly
);

const availableStacks = $derived.by(() => {
const counts = new SvelteMap<string, number>();
const stackCounts = $derived.by(() => {
// Null-prototype dictionary: stack names are scraped from external pages
// (untrusted), so a plain {} would let keys like "__proto__" / "constructor"
// collide with inherited members. Object.create(null) has no prototype, so
// every key is a plain own property.
const counts: Record<string, number> = Object.create(null);
for (const m of missions) {
for (const s of m.stack) {
counts.set(s, (counts.get(s) ?? 0) + 1);
counts[s] = (counts[s] ?? 0) + 1;
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
return counts;
});

const availableStacks = $derived(rankStacksByCount(stackCounts));

// Shared base filter (enabled connectors + favorites + hidden) reused by both
// sourceCountBaseMissions and dashboardScopeMissions so this prefix runs once.
// Output-equivalent to the previous inline prefix in both deriveds.
Expand Down Expand Up @@ -1584,6 +1591,9 @@ export function createFeedPageState(
get availableStacks() {
return availableStacks;
},
get stackCounts() {
return stackCounts;
},
get displayMissions() {
return displayMissions;
},
Expand Down
Loading
Loading