diff --git a/apps/extension/src/dev/mocks.ts b/apps/extension/src/dev/mocks.ts index 78172a1c..7292c111 100644 --- a/apps/extension/src/dev/mocks.ts +++ b/apps/extension/src/dev/mocks.ts @@ -4,14 +4,13 @@ import type { TJMHistory, TJMRecord, TJMRegion } from '$lib/core/types/tjm'; export const mockProfile: UserProfile = { firstName: 'Alice', - stack: ['TypeScript', 'React', 'Node.js', 'Svelte'], + keywords: ['TypeScript', 'React', 'Node.js', 'Svelte'], tjmMin: 500, tjmMax: 750, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Fullstack', - searchKeywords: [], }; const stacks = [ diff --git a/apps/extension/src/dev/qa-seed.ts b/apps/extension/src/dev/qa-seed.ts index 1933d3a9..648596d5 100644 --- a/apps/extension/src/dev/qa-seed.ts +++ b/apps/extension/src/dev/qa-seed.ts @@ -410,28 +410,26 @@ function buildAlertPreferences(baseDate: Date): ConnectedAlertPreferences { function buildCompleteProfile(): UserProfile { return { firstName: 'Alice', - stack: ['TypeScript', 'React', 'Node.js', 'Svelte'], + keywords: ['TypeScript', 'React', 'Node.js', 'Svelte'], tjmMin: 500, tjmMax: 750, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Fullstack', - searchKeywords: [], }; } function buildIncompleteProfile(): UserProfile { return { firstName: '', - stack: [], + keywords: [], tjmMin: 0, tjmMax: 0, location: '', remote: 'hybrid', seniority: 'senior', jobTitle: '', - searchKeywords: [], }; } diff --git a/apps/extension/src/lib/core/backup/backup.ts b/apps/extension/src/lib/core/backup/backup.ts index c4f89637..28c85b96 100644 --- a/apps/extension/src/lib/core/backup/backup.ts +++ b/apps/extension/src/lib/core/backup/backup.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { UserProfile } from '../types/profile'; +import { UserProfileSchema } from '../types/schemas'; import type { AppSettings } from '../types/app-settings'; // ============================================ @@ -14,24 +15,7 @@ import type { AppSettings } from '../types/app-settings'; export const BackupDataSchema = z.object({ version: z.number().int().min(1), timestamp: z.number().int().positive(), - profile: z.object({ - firstName: z.string(), - stack: z.array(z.string()), - tjmMin: z.number(), - tjmMax: z.number(), - location: z.string(), - remote: z.union([z.enum(['full', 'hybrid', 'onsite']), z.literal('any')]), - seniority: z.enum(['junior', 'confirmed', 'senior']), - jobTitle: z.string(), - scoringWeights: z - .object({ - stack: z.number(), - location: z.number(), - tjm: z.number(), - remote: z.number(), - }) - .optional(), - }), + profile: UserProfileSchema, settings: z.object({ scanIntervalMinutes: z.number(), enabledConnectors: z.array(z.string()), diff --git a/apps/extension/src/lib/core/connectors/search-context.ts b/apps/extension/src/lib/core/connectors/search-context.ts index c7c91ded..6689554d 100644 --- a/apps/extension/src/lib/core/connectors/search-context.ts +++ b/apps/extension/src/lib/core/connectors/search-context.ts @@ -28,10 +28,11 @@ export interface ConnectorSearchContext { * PURE FUNCTION — no I/O, no async, no side effects. * * Strategy: - * - query: derived from searchKeywords ONLY (if explicitly set by user). - * jobTitle is NOT used as fallback because it's too restrictive for API keyword - * search (e.g., "Développeur Fullstack" returns 0 results on most platforms). - * Local scoring (scoreMission) handles relevance matching much better. + * - query: derived from the unified `keywords` list (same terms used for local + * scoring vs `mission.stack`). jobTitle is NOT used as fallback because it's + * too restrictive for API keyword search (e.g., "Développeur Fullstack" + * returns 0 results on most platforms). Local scoring (scoreMission) handles + * relevance matching much better. * - skills: EMPTY — skills are NOT sent as server-side filters because: * 1. APIs use AND logic (each additional skill further narrows results) * 2. Skill names don't match across platforms (e.g. "React.js" vs "React" vs "ReactJS") @@ -44,11 +45,12 @@ export const buildSearchContext = ( profile: UserProfile, lastSync: Date | null ): ConnectorSearchContext => { - // Build query: ONLY from explicit searchKeywords. - // Do NOT fallback to jobTitle — it's too restrictive for server-side keyword search - // (e.g., "Développeur Fullstack Senior" matches almost nothing on Free-Work/Hiway/Collective). - // Relevance is handled locally by scoreMission() with fuzzy matching. - const query = profile.searchKeywords + // Build the free-text query from the unified keywords list. These are the + // same terms scored locally against mission.stack; here they drive the + // server-side search. Do NOT fallback to jobTitle — it's too restrictive + // (e.g., "Développeur Fullstack Senior" matches almost nothing on + // Free-Work/Hiway/Collective). Relevance is refined locally by scoreMission(). + const query = profile.keywords .map((keyword) => keyword.trim().replace(/\s+/g, ' ')) .filter((keyword) => keyword.length > 0) .join(' '); diff --git a/apps/extension/src/lib/core/generation/build-cover-message.ts b/apps/extension/src/lib/core/generation/build-cover-message.ts index 7a5cea92..e1cd811e 100644 --- a/apps/extension/src/lib/core/generation/build-cover-message.ts +++ b/apps/extension/src/lib/core/generation/build-cover-message.ts @@ -30,7 +30,7 @@ Mission: Profil: - Prénom: ${profile.firstName} - Poste: ${profile.jobTitle} -- Stack: ${profile.stack.join(', ')} +- Stack: ${profile.keywords.join(', ')} - Seniorité: ${profile.seniority} - TJM: ${profile.tjmMin}-${profile.tjmMax}€/jour diff --git a/apps/extension/src/lib/core/generation/build-cv-summary.ts b/apps/extension/src/lib/core/generation/build-cv-summary.ts index d4c43203..24845ca6 100644 --- a/apps/extension/src/lib/core/generation/build-cv-summary.ts +++ b/apps/extension/src/lib/core/generation/build-cv-summary.ts @@ -16,7 +16,7 @@ import type { UserProfile } from '../types/profile'; */ export const buildCvSummaryPrompt = (mission: Mission, profile: UserProfile): string => { // Find overlapping stack items - const profileStackLower = profile.stack.map((s) => s.toLowerCase()); + const profileStackLower = profile.keywords.map((s) => s.toLowerCase()); const matchingStack = mission.stack.filter((s) => profileStackLower.includes(s.toLowerCase())); const missingStack = mission.stack.filter((s) => !profileStackLower.includes(s.toLowerCase())); @@ -32,7 +32,7 @@ Mission cible: Ton profil: - Poste: ${profile.jobTitle} -- Stack: ${profile.stack.join(', ')} +- Stack: ${profile.keywords.join(', ')} - Seniorité: ${profile.seniority} ${matchingStack.length > 0 ? `- Compétences matchantes: ${matchingStack.join(', ')}` : ''} ${missingStack.length > 0 ? `- Compétences manquantes (ne pas mentionner): ${missingStack.join(', ')}` : ''} diff --git a/apps/extension/src/lib/core/generation/build-pitch-prompt.ts b/apps/extension/src/lib/core/generation/build-pitch-prompt.ts index 9a542ec4..82bef721 100644 --- a/apps/extension/src/lib/core/generation/build-pitch-prompt.ts +++ b/apps/extension/src/lib/core/generation/build-pitch-prompt.ts @@ -45,7 +45,7 @@ Mission: Profil: - Poste: ${profile.jobTitle} -- Stack: ${profile.stack.join(', ')} +- Stack: ${profile.keywords.join(', ')} - Seniorité: ${profile.seniority} - TJM attendu: ${profile.tjmMin}-${profile.tjmMax}€/jour ${matchContext} diff --git a/apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts b/apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts index 58570f30..d3b19c43 100644 --- a/apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts +++ b/apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts @@ -9,12 +9,12 @@ import type { CanonicalCandidateProfileDraft } from './types'; * Merge semantics: * - jobTitle ← draft.title (overwrite — importing LinkedIn is an explicit * "use as reference" action). - * - stack ← union of the current stack and the draft's skills, deduplicated + * - keywords ← union of the current keywords and the draft's skills, deduplicated * case-insensitively while keeping the first-seen display casing. * - location ← the current location when it is non-empty; otherwise the first * experience that carries a non-empty location; otherwise ''. - * - All other fields (firstName, tjmMin/Max, remote, seniority, searchKeywords, - * scoringWeights) are carried over from the current profile via defaults. + * - All other fields (firstName, tjmMin/Max, remote, seniority, scoringWeights) + * are carried over from the current profile via defaults. * * STRICTLY PURE: no Date, no async, no I/O, no side effects, no chrome.*. */ @@ -24,9 +24,9 @@ export function mergeCandidateProfileIntoUserProfile( ): UserProfile { const base = withProfileDefaults({ ...current }); - const stack = draft.skills.reduce( + const keywords = draft.skills.reduce( (acc, skill) => appendUniqueNormalized(acc, skill.skill), - [...(current?.stack ?? [])] + [...(current?.keywords ?? [])] ); const currentLocation = current?.location ?? ''; @@ -40,7 +40,7 @@ export function mergeCandidateProfileIntoUserProfile( return { ...base, jobTitle: draft.title, - stack, + keywords, location, }; } diff --git a/apps/extension/src/lib/core/profile/defaults.ts b/apps/extension/src/lib/core/profile/defaults.ts index 61e8bed8..fae9bb11 100644 --- a/apps/extension/src/lib/core/profile/defaults.ts +++ b/apps/extension/src/lib/core/profile/defaults.ts @@ -12,14 +12,13 @@ import type { UserProfile } from '../types/profile'; const DEFAULT_PROFILE = { firstName: '', - stack: [], + keywords: [], tjmMin: 0, tjmMax: 9999, location: '', remote: 'any', seniority: 'senior', jobTitle: '', - searchKeywords: [], scoringWeights: { stack: 0, location: 10, @@ -35,8 +34,7 @@ const DEFAULT_PROFILE = { export function createDefaultProfile(): UserProfile { return { ...DEFAULT_PROFILE, - stack: [...DEFAULT_PROFILE.stack], - searchKeywords: [...DEFAULT_PROFILE.searchKeywords], + keywords: [...DEFAULT_PROFILE.keywords], scoringWeights: { ...DEFAULT_PROFILE.scoringWeights }, }; } @@ -44,14 +42,13 @@ export function createDefaultProfile(): UserProfile { export function isDefaultProfile(profile: UserProfile): boolean { return ( profile.firstName === DEFAULT_PROFILE.firstName && - profile.stack.length === 0 && + profile.keywords.length === 0 && profile.tjmMin === DEFAULT_PROFILE.tjmMin && profile.tjmMax === DEFAULT_PROFILE.tjmMax && profile.location === DEFAULT_PROFILE.location && profile.remote === DEFAULT_PROFILE.remote && profile.seniority === DEFAULT_PROFILE.seniority && profile.jobTitle === DEFAULT_PROFILE.jobTitle && - profile.searchKeywords.length === 0 && (profile.scoringWeights?.stack ?? 0) === DEFAULT_PROFILE.scoringWeights.stack && (profile.scoringWeights?.location ?? 0) === DEFAULT_PROFILE.scoringWeights.location && (profile.scoringWeights?.tjm ?? 0) === DEFAULT_PROFILE.scoringWeights.tjm && diff --git a/apps/extension/src/lib/core/profile/normalize-profile.ts b/apps/extension/src/lib/core/profile/normalize-profile.ts index c5be7c08..1ee5bccc 100644 --- a/apps/extension/src/lib/core/profile/normalize-profile.ts +++ b/apps/extension/src/lib/core/profile/normalize-profile.ts @@ -8,9 +8,7 @@ export interface ProfileDraftInput { seniority?: UserProfile['seniority'] | null; tjmMin?: number | string | null; tjmMax?: number | string | null; - stack?: readonly string[] | null; - stackInput?: string | null; - searchKeywords?: readonly string[] | null; + keywords?: readonly string[] | null; keywordInput?: string | null; scoringWeights?: UserProfile['scoringWeights']; } @@ -58,7 +56,7 @@ export function appendUniqueNormalized( export const withProfileDefaults = (profile: Partial): UserProfile => ({ firstName: profile.firstName ?? '', - stack: [...(profile.stack ?? [])], + keywords: [...(profile.keywords ?? [])], tjmMin: profile.tjmMin ?? 0, tjmMax: profile.tjmMax ?? 0, location: profile.location ?? '', @@ -66,7 +64,6 @@ export const withProfileDefaults = (profile: Partial): UserProfile seniority: profile.seniority ?? 'senior', jobTitle: profile.jobTitle ?? '', scoringWeights: profile.scoringWeights, - searchKeywords: [...(profile.searchKeywords ?? [])], }); export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfileResult { @@ -87,8 +84,7 @@ export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfil seniority: input.seniority ?? 'senior', tjmMin, tjmMax, - stack: appendUniqueNormalized(input.stack, input.stackInput), - searchKeywords: appendUniqueNormalized(input.searchKeywords, input.keywordInput), + keywords: appendUniqueNormalized(input.keywords, input.keywordInput), scoringWeights: input.scoringWeights, }), }; diff --git a/apps/extension/src/lib/core/profile/profile-impact.ts b/apps/extension/src/lib/core/profile/profile-impact.ts index 60feb5ee..adc9ff58 100644 --- a/apps/extension/src/lib/core/profile/profile-impact.ts +++ b/apps/extension/src/lib/core/profile/profile-impact.ts @@ -1,25 +1,17 @@ import type { UserProfile } from '../types/profile'; export type ProfileImpactFieldId = - | 'stack' + | 'keywords' | 'tjm-min' | 'remote' | 'location' - | 'search-keywords' | 'job-title' | 'tjm-max' | 'first-name'; export type ProfileImpactInput = Pick< UserProfile, - | 'firstName' - | 'jobTitle' - | 'location' - | 'remote' - | 'tjmMin' - | 'tjmMax' - | 'stack' - | 'searchKeywords' + 'firstName' | 'jobTitle' | 'location' | 'remote' | 'tjmMin' | 'tjmMax' | 'keywords' >; export interface ProfileImpactItem { @@ -51,12 +43,12 @@ interface ProfileImpactDefinition { const PROFILE_IMPACT_DEFINITIONS: ProfileImpactDefinition[] = [ { - id: 'stack', - label: 'Stack technique', - weight: 25, - impact: 'Scoring de pertinence et alertes prioritaires', - action: 'Ajouter 3 à 5 technologies qui déclenchent une vraie décision.', - isComplete: (profile) => profile.stack.length > 0, + id: 'keywords', + label: 'Mots-clés', + weight: 35, + impact: 'Scoring de pertinence, recherche connecteur et alertes ciblées', + action: 'Ajouter technologies, secteurs ou contextes (ex. React, SaaS, fintech).', + isComplete: (profile) => profile.keywords.length > 0, }, { id: 'tjm-min', @@ -82,14 +74,6 @@ const PROFILE_IMPACT_DEFINITIONS: ProfileImpactDefinition[] = [ action: 'Renseigner la zone qui doit servir de référence au radar.', isComplete: (profile) => profile.location.trim().length > 0, }, - { - id: 'search-keywords', - label: 'Mots-clés', - weight: 10, - impact: 'Recherche connecteur et alertes plus ciblées', - action: 'Ajouter les secteurs, contextes ou domaines à remonter en priorité.', - isComplete: (profile) => profile.searchKeywords.length > 0, - }, { id: 'job-title', label: 'Poste cible', @@ -163,7 +147,7 @@ export function buildProfileImpactSimulation(items: ProfileImpactItem[]): Profil prioritizedItems, title: 'Le radar profil utilise déjà tous les signaux clés', description: - 'La stack, le TJM, le remote, la localisation et les mots-clés peuvent alimenter les recherches, le scoring et les alertes.', + 'Les mots-clés, le TJM, le remote et la localisation alimentent les recherches, le scoring et les alertes.', }; } diff --git a/apps/extension/src/lib/core/scoring/relevance.ts b/apps/extension/src/lib/core/scoring/relevance.ts index 6622ceca..11b213d9 100644 --- a/apps/extension/src/lib/core/scoring/relevance.ts +++ b/apps/extension/src/lib/core/scoring/relevance.ts @@ -38,8 +38,12 @@ export const scoreMission = ( const weights = profile.scoringWeights ?? DEFAULT_SCORING_WEIGHTS; const normalizedWeights = normalizeWeights(weights); - // Raw match percentages (0-100) — directly gradable - const stackMatch = rawStackScore(mission.stack, profile.stack); + // Raw match percentages (0-100) — directly gradable. + // NOTE: mission.stack is the platform-parsed tech stack; profile.keywords is + // the unified keyword list (post-unification). The dimension name "stack" in + // ScoringWeights/breakdown is unchanged — it names the scoring axis, not the + // profile field. See models/keywords-unification.model.md. + const stackMatch = rawStackScore(mission.stack, profile.keywords); const locationMatch = rawLocationScore(mission.location, profile.location); const tjmMatch = rawTjmScore(mission.tjm, profile.tjmMin, profile.tjmMax); const remoteMatch = rawRemoteScore(mission.remote, profile.remote); @@ -97,22 +101,28 @@ const normalizeWeights = (weights: ScoringWeights): ScoringWeights => { /** * Raw stack match percentage (0-100). - * Returns % of mission stack that matches the profile. + * Returns % of mission stack that matches the profile keywords. + * + * Scoring-neutral merge invariant: the denominator is `missionStack.length`, + * NOT `profileKeywords.length`. Adding domain keywords (e.g. "SaaS", "fintech") + * that never appear in a mission's parsed stack does not change any mission's + * score — they are simply never matched. This is what makes it safe to merge + * the former `stack` and `searchKeywords` into a single `keywords` list. */ -const rawStackScore = (missionStack: string[], profileStack: string[]): number => { - if (profileStack.length === 0) { +const rawStackScore = (missionStack: string[], profileKeywords: string[]): number => { + if (profileKeywords.length === 0) { return 100; } if (missionStack.length === 0) { return 0; } - // Build the profile once as a Set for O(1) membership checks. This runs once - // per mission during scoring; the previous Array.includes made it O(m) per - // mission-stack entry. Output is identical: each truthy mission-stack entry - // that matches a (lowercased) profile entry counts once toward the numerator, - // and the denominator stays the full missionStack.length. + // Build the profile keywords once as a Set for O(1) membership checks. This + // runs once per mission during scoring; the previous Array.includes made it + // O(m) per mission-stack entry. Output is identical: each truthy mission-stack + // entry that matches a (lowercased) profile keyword counts once toward the + // numerator, and the denominator stays the full missionStack.length. const profileSet = new Set(); - for (const entry of profileStack) { + for (const entry of profileKeywords) { if (entry) { profileSet.add(entry.toLowerCase()); } diff --git a/apps/extension/src/lib/core/scoring/semantic-scoring.ts b/apps/extension/src/lib/core/scoring/semantic-scoring.ts index b2043081..6a378c78 100644 --- a/apps/extension/src/lib/core/scoring/semantic-scoring.ts +++ b/apps/extension/src/lib/core/scoring/semantic-scoring.ts @@ -19,7 +19,7 @@ Mission: Profil: - Poste: ${profile.jobTitle} -- Stack: ${profile.stack.join(', ')} +- Stack: ${profile.keywords.join(', ')} - TJM: ${profile.tjmMin}-${profile.tjmMax} EUR/jour - Lieu: ${profile.location} - Remote: ${profile.remote} diff --git a/apps/extension/src/lib/core/sync/connected-dashboard.ts b/apps/extension/src/lib/core/sync/connected-dashboard.ts index b3e5140f..d96b57a1 100644 --- a/apps/extension/src/lib/core/sync/connected-dashboard.ts +++ b/apps/extension/src/lib/core/sync/connected-dashboard.ts @@ -9,6 +9,7 @@ import type { ConnectorHealthSnapshot } from '../types/health'; import type { GeneratedAsset, GenerationType } from '../types/generation'; import type { Mission, MissionSource, RemoteType } from '../types/mission'; import type { SeniorityLevel, UserProfile } from '../types/profile'; +import { appendUniqueNormalized } from '../profile/normalize-profile'; import type { Grade } from '../types/score'; import type { MissionTracking, StatusTransition } from '../types/tracking'; import { @@ -402,14 +403,25 @@ export function remoteCandidateProfileToUserProfile( return { firstName, - stack: dashboardSkills.length > 0 ? dashboardSkills : [...(existingProfile?.stack ?? [])], + // Dashboard skills are authoritative when present, but the user's manual + // keywords (domain terms, sectors) are preserved by merging with the + // existing unified list. Pre-unification this was `stack` (dashboard or + // existing) + `searchKeywords` (always preserved); both now live in + // `keywords`. Dedup is case-insensitive (first-seen casing wins) and the + // result is capped to 40 to stay within the schema limit. + keywords: + dashboardSkills.length > 0 + ? appendUniqueNormalized([...dashboardSkills, ...(existingProfile?.keywords ?? [])]).slice( + 0, + 40 + ) + : [...(existingProfile?.keywords ?? [])], tjmMin, tjmMax, location: snapshot.location?.trim() || existingProfile?.location || '', remote: snapshot.remote_preference ?? existingProfile?.remote ?? 'any', seniority: snapshot.seniority ?? existingProfile?.seniority ?? 'senior', jobTitle, - searchKeywords: existingProfile ? [...existingProfile.searchKeywords] : [], scoringWeights: existingProfile?.scoringWeights ? { ...existingProfile.scoringWeights } : undefined, @@ -444,11 +456,9 @@ export function shouldClearLocalCandidateProfile( existingProfile.seniority === lastConnectedProfile.seniority && existingProfile.tjmMin === lastConnectedProfile.tjmMin && existingProfile.tjmMax === lastConnectedProfile.tjmMax && - existingProfile.stack.length === lastConnectedProfile.stack.length && - existingProfile.stack.every((item, index) => item === lastConnectedProfile.stack[index]) && - existingProfile.searchKeywords.length === lastConnectedProfile.searchKeywords.length && - existingProfile.searchKeywords.every( - (item, index) => item === lastConnectedProfile.searchKeywords[index] + existingProfile.keywords.length === lastConnectedProfile.keywords.length && + existingProfile.keywords.every( + (item, index) => item === lastConnectedProfile.keywords[index] ) && sameWeights ); diff --git a/apps/extension/src/lib/core/types/profile.ts b/apps/extension/src/lib/core/types/profile.ts index 8cbd4665..3158269e 100644 --- a/apps/extension/src/lib/core/types/profile.ts +++ b/apps/extension/src/lib/core/types/profile.ts @@ -27,7 +27,13 @@ export const DEFAULT_SCORING_WEIGHTS: ScoringWeights = { export interface UserProfile { firstName: string; - stack: string[]; + /** + * Unified keyword list — feeds BOTH local scoring (matched against + * `mission.stack`) and the connector API free-text `query`. Replaces the + * former split between `stack` (scoring) and `searchKeywords` (query). + * See `models/keywords-unification.model.md`. + */ + keywords: string[]; tjmMin: number; tjmMax: number; location: string; @@ -36,6 +42,4 @@ export interface UserProfile { jobTitle: string; /** Optional custom scoring weights. Defaults to DEFAULT_SCORING_WEIGHTS if not provided. */ scoringWeights?: ScoringWeights; - /** User-defined search keywords sent to connector APIs for server-side filtering */ - searchKeywords: string[]; } diff --git a/apps/extension/src/lib/core/types/schemas.ts b/apps/extension/src/lib/core/types/schemas.ts index 11177e36..667b28a2 100644 --- a/apps/extension/src/lib/core/types/schemas.ts +++ b/apps/extension/src/lib/core/types/schemas.ts @@ -114,28 +114,72 @@ export const MissionSerializedSchema = z.object({ }); // ============================================ +/** + * Normalizes legacy profile records into the unified `keywords` shape before + * validation. Records that still carry `stack` and/or `searchKeywords` + * (pre-unification schema) are merged into a single `keywords` list with + * case-insensitive dedup (first-seen casing wins) and trimmed to the 40-entry + * cap so the schema never rejects a migrated record for length. New-shape + * records that already have `keywords` pass through untouched (legacy fields + * are stripped). This makes reads resilient even before the v1→v2 data + * migration has run. See `models/keywords-unification.model.md`. + */ +const normalizeLegacyProfileInput = (data: unknown): unknown => { + if (!data || typeof data !== 'object') { + return data; + } + const record = data as Record; + // Only touch records that carry legacy `stack`/`searchKeywords` fields. + // Records with neither legacy fields nor `keywords` are invalid (missing a + // required field) and must be rejected by the schema, not silently healed. + if (!('stack' in record) && !('searchKeywords' in record)) { + return record; + } + const { stack: legacyStackRaw, searchKeywords: legacyKeywordsRaw, ...rest } = record; + if ('keywords' in record) { + return rest; + } + const legacyStack = Array.isArray(legacyStackRaw) ? legacyStackRaw : []; + const legacyKeywords = Array.isArray(legacyKeywordsRaw) ? legacyKeywordsRaw : []; + const seen = new Set(); + const merged: string[] = []; + for (const entry of [...legacyStack, ...legacyKeywords]) { + if (typeof entry !== 'string' || entry.trim().length === 0) { + continue; + } + const key = entry.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(entry); + } + return { ...rest, keywords: merged.slice(0, 40) }; +}; + export const UserProfileSchema = z - .object({ - firstName: z.string().max(50, 'Le prénom ne doit pas dépasser 50 caractères'), - stack: z - .array(z.string().min(1, 'Chaque compétence doit être non vide')) - .max(20, 'Maximum 20 compétences'), - tjmMin: z - .number() - .min(0, 'Le TJM minimum doit être positif') - .max(5000, 'Le TJM minimum ne doit pas dépasser 5000'), - tjmMax: z - .number() - .min(0, 'Le TJM maximum doit être positif') - .max(5000, 'Le TJM maximum ne doit pas dépasser 5000'), - location: z.string(), - remote: z.union([RemoteTypeSchema, z.literal('any')]), - seniority: SeniorityLevelSchema, - jobTitle: z.string(), - scoringWeights: ScoringWeightsSchema.optional(), - /** User-defined search keywords sent to connector APIs for server-side filtering */ - searchKeywords: z.array(z.string()).default([]), - }) + .preprocess( + normalizeLegacyProfileInput, + z.object({ + firstName: z.string().max(50, 'Le prénom ne doit pas dépasser 50 caractères'), + keywords: z + .array(z.string().min(1, 'Chaque mot-clé doit être non vide')) + .max(40, 'Maximum 40 mots-clés'), + tjmMin: z + .number() + .min(0, 'Le TJM minimum doit être positif') + .max(5000, 'Le TJM minimum ne doit pas dépasser 5000'), + tjmMax: z + .number() + .min(0, 'Le TJM maximum doit être positif') + .max(5000, 'Le TJM maximum ne doit pas dépasser 5000'), + location: z.string(), + remote: z.union([RemoteTypeSchema, z.literal('any')]), + seniority: SeniorityLevelSchema, + jobTitle: z.string(), + scoringWeights: ScoringWeightsSchema.optional(), + }) + ) .refine((p) => p.tjmMax === 0 || p.tjmMax >= p.tjmMin, { message: 'Le TJM maximum doit être supérieur ou égal au TJM minimum', path: ['tjmMax'], diff --git a/apps/extension/src/lib/shell/storage/db.ts b/apps/extension/src/lib/shell/storage/db.ts index 16d1b4a5..b51243b6 100644 --- a/apps/extension/src/lib/shell/storage/db.ts +++ b/apps/extension/src/lib/shell/storage/db.ts @@ -59,7 +59,7 @@ export const DB_VERSION = 4; * Applicative data version. Bump when an entity Zod schema changes shape and * a data migration is appended to `DATA_MIGRATIONS`. */ -export const APP_DATA_VERSION = 1; +export const APP_DATA_VERSION = 2; // ============================================================================ // Single opener with versionchange + blocked handling diff --git a/apps/extension/src/lib/shell/storage/migration-registry.ts b/apps/extension/src/lib/shell/storage/migration-registry.ts index ec872e00..7d7209b3 100644 --- a/apps/extension/src/lib/shell/storage/migration-registry.ts +++ b/apps/extension/src/lib/shell/storage/migration-registry.ts @@ -16,6 +16,7 @@ import type { UserProfile } from '../../core/types/profile'; import { UserProfileSchema } from '../../core/types/schemas'; +import { appendUniqueNormalized } from '../../core/profile/normalize-profile'; // ============================================================================ // Structural migrations (run inside onupgradeneeded) @@ -78,14 +79,48 @@ export type DataMigration = (deps: DataMigrationDeps) => Promise; /** * Ordered data migrations. Index + 1 === the target APP_DATA_VERSION. * - * Today: APP_DATA_VERSION = 1, no data migrations yet. The registry exists so - * the next schema change (e.g. adding a required field to Mission) is a - * one-line append + version bump. + * v1 → v2 (below): unifies the legacy `stack` + `searchKeywords` profile + * fields into a single `keywords: string[]`. Idempotent — safe to re-run on + * already-migrated records (legacy fields are stripped, `keywords` preserved). */ export const DATA_MIGRATIONS: DataMigration[] = [ - // v0 → v1: example shape — currently a no-op placeholder. - // Replace with a real migration when MissionSchema/UserProfileSchema change. - // async (_deps) => { /* idempotent transform */ }, + // v1 → v2: merge `stack` + `searchKeywords` → `keywords` on stored profiles. + async ({ runRW }) => { + await runRW(['profile'], (profileStore) => { + return new Promise((resolve, reject) => { + const cursorReq = profileStore.openCursor(); + cursorReq.onerror = () => reject(cursorReq.error); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) { + resolve(); + return; + } + const record = cursor.value as Record; + const asStrings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + const existingKeywords = asStrings(record.keywords); + const legacyStack = asStrings(record.stack); + const legacySearchKeywords = asStrings(record.searchKeywords); + const merged = appendUniqueNormalized([ + ...existingKeywords, + ...legacyStack, + ...legacySearchKeywords, + ]).slice(0, 40); + const next: Record = { ...record, keywords: merged }; + delete next.stack; + delete next.searchKeywords; + const parsed = UserProfileSchema.safeParse(next); + if (!parsed.success) { + reject(new Error('v1→v2 profile migration produced an invalid profile')); + return; + } + cursor.update(parsed.data); + cursor.continue(); + }; + }); + }); + }, ]; // ============================================================================ diff --git a/apps/extension/src/lib/shell/storage/semantic-cache.ts b/apps/extension/src/lib/shell/storage/semantic-cache.ts index 152081fd..b2fde628 100644 --- a/apps/extension/src/lib/shell/storage/semantic-cache.ts +++ b/apps/extension/src/lib/shell/storage/semantic-cache.ts @@ -44,7 +44,7 @@ const normalizeKeyPart = (value: string | number): string => const buildProfileFingerprint = (profile: UserProfile): string => [ normalizeKeyPart(profile.jobTitle), - profile.stack + profile.keywords .filter(Boolean) .map((item) => normalizeKeyPart(item)) .sort() diff --git a/apps/extension/src/lib/state/feed-page.svelte.ts b/apps/extension/src/lib/state/feed-page.svelte.ts index 3fe9de62..e892a216 100644 --- a/apps/extension/src/lib/state/feed-page.svelte.ts +++ b/apps/extension/src/lib/state/feed-page.svelte.ts @@ -131,8 +131,7 @@ function toProfileImpactInput(profile: UserProfile | null): ProfileImpactInput { remote: profile?.remote ?? 'any', tjmMin: typeof profile?.tjmMin === 'number' ? profile.tjmMin : 0, tjmMax: typeof profile?.tjmMax === 'number' ? profile.tjmMax : 0, - stack: Array.isArray(profile?.stack) ? profile.stack : [], - searchKeywords: Array.isArray(profile?.searchKeywords) ? profile.searchKeywords : [], + keywords: Array.isArray(profile?.keywords) ? profile.keywords : [], }; } diff --git a/apps/extension/src/lib/state/settings-page.svelte.ts b/apps/extension/src/lib/state/settings-page.svelte.ts index ff92a288..506ec0a2 100644 --- a/apps/extension/src/lib/state/settings-page.svelte.ts +++ b/apps/extension/src/lib/state/settings-page.svelte.ts @@ -102,9 +102,7 @@ export class SettingsPageController { seniority = $state('senior'); tjmMin = $state(0); tjmMax = $state(0); - profileStack = $state([]); - stackInput = $state(''); - searchKeywords = $state([]); + profileKeywords = $state([]); keywordInput = $state(''); editingProfile = $state(false); profileSaved = $state(false); @@ -207,8 +205,7 @@ export class SettingsPageController { this.seniority = profile.seniority ?? 'senior'; this.tjmMin = profile.tjmMin ?? 0; this.tjmMax = profile.tjmMax ?? 0; - this.profileStack = profile.stack ?? []; - this.searchKeywords = profile.searchKeywords ?? []; + this.profileKeywords = profile.keywords ?? []; this.profileActor.send({ type: 'PROFILE_UPDATED', profile }); } @@ -353,32 +350,18 @@ export class SettingsPageController { this.editingProfile = !this.editingProfile; } - addStack(): void { - const trimmed = normalizeTextInput(this.stackInput); - if (!trimmed || this.profileStack.includes(trimmed)) { - return; - } - - this.profileStack = [...this.profileStack, trimmed]; - this.stackInput = ''; - } - - removeStack(item: string): void { - this.profileStack = this.profileStack.filter((s) => s !== item); - } - addKeyword(): void { const trimmed = normalizeTextInput(this.keywordInput); - if (!trimmed || this.searchKeywords.includes(trimmed)) { + if (!trimmed || this.profileKeywords.includes(trimmed)) { return; } - this.searchKeywords = [...this.searchKeywords, trimmed]; + this.profileKeywords = [...this.profileKeywords, trimmed]; this.keywordInput = ''; } removeKeyword(item: string): void { - this.searchKeywords = this.searchKeywords.filter((keyword) => keyword !== item); + this.profileKeywords = this.profileKeywords.filter((keyword) => keyword !== item); } async saveProfile(): Promise { @@ -387,8 +370,7 @@ export class SettingsPageController { try { const current = await getProfile(); - const nextStack = appendUniqueNormalized(this.profileStack, this.stackInput); - const nextSearchKeywords = appendUniqueNormalized(this.searchKeywords, this.keywordInput); + const nextKeywords = appendUniqueNormalized(this.profileKeywords, this.keywordInput); const nextTjmMin = normalizeDailyRate(this.tjmMin); const nextTjmMax = normalizeDailyRate(this.tjmMax); @@ -403,11 +385,10 @@ export class SettingsPageController { location: normalizeTextInput(this.profileLocation), tjmMin: nextTjmMin, tjmMax: nextTjmMax, - stack: nextStack, + keywords: nextKeywords, remote: this.profileRemote, seniority: this.seniority, scoringWeights: current?.scoringWeights, - searchKeywords: nextSearchKeywords, }); if (!normalized.ok || !normalized.profile) { @@ -423,9 +404,7 @@ export class SettingsPageController { this.profileLocation = nextProfile.location; this.tjmMin = nextProfile.tjmMin; this.tjmMax = nextProfile.tjmMax; - this.profileStack = nextProfile.stack; - this.stackInput = ''; - this.searchKeywords = nextProfile.searchKeywords; + this.profileKeywords = nextProfile.keywords; this.keywordInput = ''; this.editingProfile = false; this.profileSaved = true; diff --git a/apps/extension/src/models/keywords-unification.model.md b/apps/extension/src/models/keywords-unification.model.md new file mode 100644 index 00000000..ca5826f7 --- /dev/null +++ b/apps/extension/src/models/keywords-unification.model.md @@ -0,0 +1,193 @@ +# Keywords Unification Model + +Source of truth for the unification of the profile's `stack` (compétences) and +`searchKeywords` (mots-clés de recherche) into a single `keywords` list. This +model is the authoritative spec; the implementation in `core/`, `shell/`, `ui/`, +and tests must conform to it. + +## Why + +The profile historically exposed two parallel string lists: + +- `stack: string[]` — fed **local scoring** only (`rawStackScore` matches + `mission.stack` against `profile.stack`). +- `searchKeywords: string[]` — fed **connector API queries** only + (`buildSearchContext` joins them into the free-text `query`). + +This split is invisible to a non-technical user, who does not know whether a +term ("React", "SaaS", "marketplace") should go in "compétences" or +"mots-clés". The result is either duplicate entry or an empty second field and +a degraded experience. We collapse both into one list, **`keywords`**, that +serves both channels. "Il ne restera alors que les mots." + +## Decision (confirmed by product) + +The unified `keywords` list feeds **both**: + +1. **Local scoring** — matched against `mission.stack` (tech terms hit; domain + terms are scoring-neutral, see Invariants). +2. **Connector API query** — joined into the free-text `query` sent to every + platform connector. `skills: []` stays empty by design (AND-logic on skill + arrays over-narrows results; see `search-context.ts` architect note). + +This is a **full merge**, not a cosmetic single input over two hidden fields. + +## Data shape + +### `UserProfile` (after) + +```ts +export interface UserProfile { + firstName: string; + keywords: string[]; // ← was: stack: string[] + tjmMin: number; + tjmMax: number; + location: string; + remote: RemoteType | 'any'; + seniority: SeniorityLevel; + jobTitle: string; + scoringWeights?: ScoringWeights; + // searchKeywords: string[] ← REMOVED (folded into keywords) +} +``` + +### What is NOT renamed (deliberate) + +| Symbol | Why it keeps its name | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Mission.stack` | Parsed from platform HTML; represents the mission's tech stack — a different concept. | +| `ScoringWeights.stack` | Name of the scoring **dimension** (stack-match), not the profile field. | +| `DEFAULT_SCORING_WEIGHTS.stack` | Same — dimension weight. | +| `DeterministicBreakdown.stack` | Score breakdown per dimension. | +| `ConnectedAlertPreferences.requiredStacks` | Cross-app contract: synced to the `apps/dashboard` PostgreSQL `required_stacks` column via `connected-dashboard.ts`. Renaming would break cloud sync. Semantically still "required mission stacks". | +| `AlertHistoryEntry.requiredStacks` | Persisted alert history; shares the contract above. | +| `SmartAlertCriteria.requiredStacks` | Same. | + +The scoring dimension `stack` keeps its name because it answers "how much of +the **mission's** tech stack does the user cover?" — that question is +unchanged. Only the **profile's** input list is renamed `stack` → `keywords`. + +## Scoring semantics (unchanged behavior) + +`rawStackScore(missionStack, profileKeywords)`: + +``` +denominator = missionStack.length +numerator = count of missionStack entries present in profileKeywords (lowercased set) +score = numerator / denominator * 100 +``` + +**Invariant (scoring-neutral merge):** the denominator is `missionStack.length` +and the loop iterates `missionStack`, not `profileKeywords`. Adding domain +terms ("SaaS", "marketplace") to `profileKeywords` that never appear in any +mission's `stack` does **not** change the score — they are simply never matched +in the membership check. Therefore merging `searchKeywords` (domain terms) +into `keywords` is scoring-neutral for every existing profile. + +Edge cases: + +- `profileKeywords.length === 0` → returns `100` (no constraint; same as today). +- `missionStack.length === 0` → returns `0` (same as today). + +## Search semantics + +`buildSearchContext(profile, lastSync)`: + +``` +query = profile.keywords.map(trim + collapse spaces).filter(nonEmpty).join(' ') +skills = [] // unchanged: server-side skill filtering stays disabled +``` + +Behavior is identical to today's `searchKeywords`-derived query; the source +field changes from `searchKeywords` to `keywords`. + +## Profile impact (completion radar) + +The two separate impact items are merged into one: + +| Before | Weight | After | Weight | +| ------------------------------- | ------ | ----------- | ------ | +| `stack` ("Stack technique") | 25 | `keywords` | 35 | +| `search-keywords` ("Mots-clés") | 10 | _(removed)_ | — | + +`ProfileImpactFieldId` loses `'search-keywords'`; `'stack'` → `'keywords'`. +`ProfileImpactInput` follows (`stack` → `keywords`, drop `searchKeywords`). +Total weight budget stays 100 (25+10 → 35; all other items unchanged). + +## State modules + +`settings-page.svelte.ts` and `feed-page.svelte.ts` expose a single keyword +editor surface: + +| Before | After | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `profileStack` / `stackInput` / `addStack` / `removeStack` / `setStack` | `profileKeywords` / `keywordInput` / `addKeyword` / `removeKeyword` / `setKeywords` | +| `searchKeywords` / `keywordInput` / `addKeyword` / `removeKeyword` | _(removed — folded into above)_ | + +The two former input buffers collapse into one (`keywordInput`). + +## UI + +- **Onboarding** (`OnboardingWizard.svelte`): the "compétences" step becomes a + single "Mots-clés" input. The onboarding still seeds + `ConnectedAlertPreferences.requiredStacks` from the profile's keywords + (existing wiring; `requiredStacks: keywords`). The field name + `requiredStacks` is unchanged (cross-app contract). +- **Profile** (`ProfileSection.svelte`): the two sections ("Stack technique" + + "Mots-clés") become a single "Mots-clés" section. +- Labels are non-technical: placeholder copy invites both technologies and + domains ("React, Node, SaaS, marketplace, fintech…"). + +## Persistence & migration + +Two independent resilience layers (belt + suspenders). Both are required. + +### Layer 1 — Read-time schema shim (resilience) + +`UserProfileSchema` gains a `z.preprocess` step: when raw data has no +`keywords` field but has `stack` and/or `searchKeywords`, it merges them into +`keywords` (dedup, case-insensitive, first-seen casing wins — same rule as +`appendUniqueNormalized`) before validation. This makes `getProfile()` / +`parseUserProfile()` tolerate legacy records even if the data migration has +not run yet (downgrade-reupgrade, skipped migration, synced-from-cloud edge). + +### Layer 2 — Data migration v1 → v2 + +`APP_DATA_VERSION` bumps `1 → 2`. A new entry in `DATA_MIGRATIONS` +(`migration-registry.ts`) rewrites the `profile` store record: + +1. Read the single `profile` record (`keyPath: 'id'`, key `'current'`). +2. If it already has `keywords` → no-op (idempotent). +3. Else merge `[...(record.stack ?? []), ...(record.searchKeywords ?? [])]` + using `appendUniqueNormalized` semantics, set `record.keywords`, delete + `record.stack` and `record.searchKeywords`, `put` back. +4. If no record exists → no-op. + +The migration is pure-data, idempotent, and never delegates decisions to an +LLM (db-migration.model.md invariant). `DB_VERSION` (structural) is unchanged +— no store/index is added. + +### Write path + +`saveProfile()` writes the new shape (`keywords`, no `stack`/`searchKeywords`). +`UserProfileSchema.safeParse` in `saveProfile` validates the new shape; the +preprocess shim is a no-op for already-new records. + +## Invariants + +1. **No silent data loss.** A legacy profile (`stack` + `searchKeywords`) is + always readable, via the shim (Layer 1) and/or the migration (Layer 2). +2. **Scoring-neutral merge.** Adding domain keywords never lowers an existing + mission's stack-match score (denominator is `missionStack.length`). +3. **Idempotent migration.** Running v1→v2 twice is a no-op. +4. **FC&IS.** `keywords` lives on the pure `UserProfile` type; all I/O + (migration, save/load) stays in `shell/`. Core never imports shell. +5. **Cross-app contracts preserved.** `requiredStacks` (alerts) and the + dashboard sync are untouched. +6. **No `any`.** The preprocess shim is typed via `unknown` + narrowing. + +## Out of scope + +- Renaming `Mission.stack`, `ScoringWeights.stack`, `requiredStacks` (see table). +- Changing alert filtering semantics (`SmartAlertCriteria` unchanged). +- Dashboard (`apps/dashboard`) schema changes — none required. diff --git a/apps/extension/src/ui/organisms/OnboardingWizard.svelte b/apps/extension/src/ui/organisms/OnboardingWizard.svelte index 6fb2b61b..18af8488 100644 --- a/apps/extension/src/ui/organisms/OnboardingWizard.svelte +++ b/apps/extension/src/ui/organisms/OnboardingWizard.svelte @@ -49,8 +49,8 @@ let firstName = $state(''); let jobTitle = $state(''); let location = $state(''); - let stack = $state([]); - let stackInput = $state(''); + let keywords = $state([]); + let keywordInput = $state(''); let tjm = $state(600); let currentStep = $state('understand'); let alertThreshold = $state(80); @@ -88,18 +88,18 @@ const currentStepIndex = $derived(onboardingSteps.findIndex((step) => step.id === currentStep)); const currentStepDefinition = $derived(onboardingSteps[currentStepIndex] ?? onboardingSteps[0]); - function addStack() { - const trimmed = stackInput.trim(); - if (trimmed && !stack.includes(trimmed)) { - stack = [...stack, trimmed]; - stackInput = ''; - onUpdateProfile?.({ stack }); + function addKeyword() { + const trimmed = keywordInput.trim(); + if (trimmed && !keywords.includes(trimmed)) { + keywords = [...keywords, trimmed]; + keywordInput = ''; + onUpdateProfile?.({ keywords }); } } - function removeStack(item: string) { - stack = stack.filter((s) => s !== item); - onUpdateProfile?.({ stack }); + function removeKeyword(item: string) { + keywords = keywords.filter((s) => s !== item); + onUpdateProfile?.({ keywords }); } function handleComplete() { @@ -107,12 +107,11 @@ firstName, jobTitle, location, - stack, + keywords, tjmMin: tjm, tjmMax: tjm + 150, remote: 'any', seniority: 'senior', - searchKeywords: [], }); if (result.ok && result.profile) { @@ -122,7 +121,7 @@ } const canSubmit = $derived( - firstName.trim().length > 0 && jobTitle.trim().length > 0 && stack.length > 0 + firstName.trim().length > 0 && jobTitle.trim().length > 0 && keywords.length > 0 ); function handleSubmit() { @@ -162,7 +161,7 @@ enabled: true, scoreThreshold: alertThreshold, minDailyRate: tjm, - requiredStacks: stack, + requiredStacks: keywords, maxResults: 5, mutedUntil: null, }); @@ -375,8 +374,8 @@

Personnalisez vos résultats

- Cette étape est facultative. Ajoutez au moins votre poste et votre stack pour mieux classer - les missions. + Cette étape est facultative. Ajoutez au moins votre poste et vos mots-clés pour mieux + classer les missions.

@@ -408,34 +407,34 @@
- Mots-clés
{ if (e.key === 'Enter') { - addStack(); + addKeyword(); } }} />
- {#if stack.length > 0} + {#if keywords.length > 0}
- {#each stack as tech} - removeStack(tech)} /> + {#each keywords as tech} + removeKeyword(tech)} /> {/each}
{/if} @@ -497,10 +496,8 @@
{/if} - {#if firstName.trim().length > 0 && jobTitle.trim().length > 0 && stack.length === 0} -

- Ajoutez au moins une technologie pour activer le scoring. -

+ {#if firstName.trim().length > 0 && jobTitle.trim().length > 0 && keywords.length === 0} +

Ajoutez au moins un mot-clé pour activer le scoring.

{/if}
diff --git a/apps/extension/src/ui/organisms/ProfileSection.svelte b/apps/extension/src/ui/organisms/ProfileSection.svelte index 01d17559..37670a66 100644 --- a/apps/extension/src/ui/organisms/ProfileSection.svelte +++ b/apps/extension/src/ui/organisms/ProfileSection.svelte @@ -15,9 +15,7 @@ seniority = $bindable('senior'), tjmMin = $bindable(0), tjmMax = $bindable(0), - profileStack = $bindable([]), - stackInput = $bindable(''), - searchKeywords = $bindable([]), + profileKeywords = $bindable([]), keywordInput = $bindable(''), editing, isSaving = false, @@ -25,8 +23,6 @@ profileError, onToggleEdit, onSave, - onAddStack, - onRemoveStack, onAddKeyword, onRemoveKeyword, }: { @@ -37,9 +33,7 @@ seniority: SeniorityLevel; tjmMin: number; tjmMax: number; - profileStack: string[]; - stackInput: string; - searchKeywords: string[]; + profileKeywords: string[]; keywordInput: string; editing: boolean; isSaving?: boolean; @@ -47,8 +41,6 @@ profileError: string | null; onToggleEdit: () => void; onSave: () => void | Promise; - onAddStack: () => void; - onRemoveStack: (tech: string) => void; onAddKeyword: () => void; onRemoveKeyword: (keyword: string) => void; } = $props(); @@ -160,54 +152,13 @@
-

- Stack technique -

+

Mots-clés

{ - if (e.key === 'Enter') { - onAddStack(); - } - }} - /> - - - -
- {#if profileStack.length > 0} -
- {#each profileStack as tech} - onRemoveStack(tech)} /> - {/each} -
- {/if} -
- -
-

- Mots-clés de recherche -

-
- { @@ -217,21 +168,21 @@ }} />
- {#if searchKeywords.length > 0} + {#if profileKeywords.length > 0}
- {#each searchKeywords as keyword} + {#each profileKeywords as keyword} onRemoveKeyword(keyword)} /> {/each}
@@ -275,27 +226,17 @@ {#if tjmMin > 0 || tjmMax > 0}

TJM : {tjmMin} – {tjmMax} €/jour

{/if} - {#if profileStack.length > 0} + {#if profileKeywords.length > 0}
- {#each profileStack as tech} + {#each profileKeywords as keyword} {tech} - {/each} -
- {:else} -

Aucune technologie renseignée

- {/if} - {#if searchKeywords.length > 0} -
- {#each searchKeywords as keyword} - {keyword} {/each}
+ {:else} +

Aucun mot-clé renseigné

{/if}
{/if} diff --git a/apps/extension/src/ui/pages/CvPage.svelte b/apps/extension/src/ui/pages/CvPage.svelte index 1fbccc63..6b6f267a 100644 --- a/apps/extension/src/ui/pages/CvPage.svelte +++ b/apps/extension/src/ui/pages/CvPage.svelte @@ -72,7 +72,7 @@ let linkedInImportResult = $state(null); let verificationResults = $state>(new Map()); let selectedFieldIds = $state>( - new Set(['title', 'summary', 'stack', 'location', 'remote', 'tjm']) + new Set(['title', 'summary', 'keywords', 'location', 'remote', 'tjm']) ); const platforms: ProfilePlatform[] = [ @@ -111,13 +111,7 @@ id: 'summary', label: 'Résumé', value: buildSummary(profile), - quality: profile?.jobTitle && profile.stack.length > 0 ? 'ready' : 'missing', - }, - { - id: 'stack', - label: 'Stack', - value: profile?.stack.join(', ') ?? '', - quality: profile && profile.stack.length > 0 ? 'ready' : 'missing', + quality: profile?.jobTitle && profile.keywords.length > 0 ? 'ready' : 'missing', }, { id: 'location', @@ -143,8 +137,8 @@ { id: 'keywords', label: 'Mots-clés', - value: profile?.searchKeywords.join(', ') ?? '', - quality: profile && profile.searchKeywords.length > 0 ? 'ready' : 'missing', + value: profile?.keywords.join(', ') ?? '', + quality: profile && profile.keywords.length > 0 ? 'ready' : 'missing', }, ]); @@ -294,12 +288,12 @@ return ''; } - const stack = value.stack.slice(0, 5).join(', '); + const keywords = value.keywords.slice(0, 5).join(', '); const seniority = value.seniority === 'senior' ? 'senior' : value.seniority; const title = value.jobTitle || 'Freelance'; const location = value.location ? ` basé à ${value.location}` : ''; - return `${title} ${seniority}${location}. Stack principale: ${stack || 'à compléter'}. TJM cible: ${value.tjmMin}-${value.tjmMax} EUR/j.`; + return `${title} ${seniority}${location}. Mots-clés: ${keywords || 'à compléter'}. TJM cible: ${value.tjmMin}-${value.tjmMax} EUR/j.`; } function formatRemote(value: UserProfile['remote']): string { diff --git a/apps/extension/src/ui/pages/ProfilePage.svelte b/apps/extension/src/ui/pages/ProfilePage.svelte index aec6cd0a..93199c61 100644 --- a/apps/extension/src/ui/pages/ProfilePage.svelte +++ b/apps/extension/src/ui/pages/ProfilePage.svelte @@ -34,8 +34,7 @@ remote: settings.profileRemote, tjmMin: settings.tjmMin, tjmMax: settings.tjmMax, - stack: settings.profileStack, - searchKeywords: settings.searchKeywords, + keywords: settings.profileKeywords, }); }); @@ -92,10 +91,10 @@ severity: missingProfileItems.length === 0 ? 'success' : 'attention', }, { - label: 'Stack', - value: settings.profileStack.length, + label: 'Mots-clés', + value: settings.profileKeywords.length, icon: 'layers', - severity: settings.profileStack.length > 0 ? 'success' : 'incident', + severity: settings.profileKeywords.length > 0 ? 'success' : 'incident', }, ]; @@ -319,9 +318,7 @@ bind:seniority={settings.seniority} bind:tjmMin={settings.tjmMin} bind:tjmMax={settings.tjmMax} - bind:profileStack={settings.profileStack} - bind:stackInput={settings.stackInput} - bind:searchKeywords={settings.searchKeywords} + bind:profileKeywords={settings.profileKeywords} bind:keywordInput={settings.keywordInput} editing={settings.editingProfile} isSaving={settings.isSavingProfile} @@ -329,8 +326,6 @@ profileError={settings.profileError} onToggleEdit={() => settings.toggleProfileEditing()} onSave={handleSave} - onAddStack={() => settings.addStack()} - onRemoveStack={(tech) => settings.removeStack(tech)} onAddKeyword={() => settings.addKeyword()} onRemoveKeyword={(keyword) => settings.removeKeyword(keyword)} /> diff --git a/apps/extension/src/ui/pages/SettingsPage.svelte b/apps/extension/src/ui/pages/SettingsPage.svelte index 03b942a9..c197195a 100644 --- a/apps/extension/src/ui/pages/SettingsPage.svelte +++ b/apps/extension/src/ui/pages/SettingsPage.svelte @@ -222,7 +222,7 @@ const profileReadyForBackup = $derived( settings.firstName.trim().length > 0 || settings.jobTitle.trim().length > 0 || - settings.profileStack.length > 0 + settings.profileKeywords.length > 0 ); const exportStory = $derived.by(() => { @@ -624,7 +624,7 @@ { await page.keyboard.type('Développeur'); await page.keyboard.press('Tab'); - await expect(page.locator('#ob-stack')).toBeFocused(); + await expect(page.locator('#ob-keywords')).toBeFocused(); await page.keyboard.type('React'); await page.keyboard.press('Enter'); diff --git a/apps/extension/tests/e2e/helpers.ts b/apps/extension/tests/e2e/helpers.ts index 9915b756..8c28e586 100644 --- a/apps/extension/tests/e2e/helpers.ts +++ b/apps/extension/tests/e2e/helpers.ts @@ -255,9 +255,9 @@ export async function fillOnboardingForm(page: Page, profile: Partial { await expect(page.locator('#ob-firstname')).toBeVisible(); await page.locator('#ob-firstname').fill('Guy'); await page.locator('#ob-jobtitle').fill('Dev React Senior'); - await page.locator('#ob-stack').fill('React'); - await page.getByRole('button', { name: 'Ajouter la stack technique' }).click(); + await page.locator('#ob-keywords').fill('React'); + await page.getByRole('button', { name: 'Ajouter le mot-clé' }).click(); await expect(page.getByRole('button', { name: 'React' })).toBeVisible(); await page.locator('#ob-location').fill('Paris'); await expect(page.getByRole('button', { name: 'Sauvegarder mon profil' })).toBeEnabled(); diff --git a/apps/extension/tests/e2e/settings.test.ts b/apps/extension/tests/e2e/settings.test.ts index fbe5bbdb..106bbb6a 100644 --- a/apps/extension/tests/e2e/settings.test.ts +++ b/apps/extension/tests/e2e/settings.test.ts @@ -52,7 +52,7 @@ test.describe('Settings Flow', () => { const profileSection = page.locator('.section-card').filter({ hasText: 'Vos informations' }); await profileSection.locator('input[placeholder="Prénom"]').fill(''); await profileSection.locator('input[placeholder^="Poste"]').fill('Architecte Svelte'); - await profileSection.locator('#stack-input').fill('Svelte Save'); + await profileSection.locator('#profile-keywords-input').fill('Svelte Save'); await profileSection.getByRole('button', { name: 'Enregistrer le profil' }).click(); await navButton(page, 'Feed').click(); @@ -80,14 +80,14 @@ test.describe('Settings Flow', () => { await expect(reloadedProfileSection.getByText('Svelte Save')).toBeVisible(); }); - test('profile stack editor adds and removes technologies', async ({ page }) => { + test('profile keywords editor adds and removes technologies', async ({ page }) => { await navButton(page, 'Profil').click(); await page.getByRole('button', { name: 'Modifier le profil' }).first().click(); const profileSection = page.locator('.section-card').filter({ hasText: 'Vos informations' }); - const stackInput = page.locator('#stack-input'); - await expect(stackInput).toBeVisible(); - await stackInput.fill('TypeScript E2E'); + const keywordInput = page.locator('#profile-keywords-input'); + await expect(keywordInput).toBeVisible(); + await keywordInput.fill('TypeScript E2E'); await page.keyboard.press('Enter'); await expect(profileSection.getByRole('button', { name: 'TypeScript E2E' })).toBeVisible(); @@ -95,13 +95,13 @@ test.describe('Settings Flow', () => { await expect(profileSection.getByRole('button', { name: 'TypeScript E2E' })).not.toBeVisible(); }); - test('adding stack item via Enter key works', async ({ page }) => { + test('adding keyword item via Enter key works', async ({ page }) => { await navButton(page, 'Profil').click(); await page.getByRole('button', { name: 'Modifier le profil' }).first().click(); const profileSection = page.locator('.section-card').filter({ hasText: 'Vos informations' }); - const stackInput = page.locator('#stack-input'); - await stackInput.fill('Svelte E2E'); + const keywordInput = page.locator('#profile-keywords-input'); + await keywordInput.fill('Svelte E2E'); await page.keyboard.press('Enter'); await expect(profileSection.getByRole('button', { name: 'Svelte E2E' })).toBeVisible(); }); diff --git a/apps/extension/tests/unit/background/index.test.ts b/apps/extension/tests/unit/background/index.test.ts index ae7880bd..c8275d3f 100644 --- a/apps/extension/tests/unit/background/index.test.ts +++ b/apps/extension/tests/unit/background/index.test.ts @@ -82,14 +82,13 @@ const makeMission = (overrides: Partial = {}): Mission => ({ const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'mission svelte'], tjmMin: 650, tjmMax: 900, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], }; const makeTracking = (overrides: Partial = {}): MissionTracking => ({ @@ -503,7 +502,7 @@ describe('background auto-scan notifications', () => { expect(saveProfile).toHaveBeenCalledWith( expect.objectContaining({ jobTitle: 'Lead Frontend Svelte', - stack: expect.arrayContaining(['Svelte', 'TypeScript', 'React']), + keywords: expect.arrayContaining(['Svelte', 'TypeScript', 'React']), }) ); expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ diff --git a/apps/extension/tests/unit/backup/backup.test.ts b/apps/extension/tests/unit/backup/backup.test.ts index c60fc533..f4e2cd36 100644 --- a/apps/extension/tests/unit/backup/backup.test.ts +++ b/apps/extension/tests/unit/backup/backup.test.ts @@ -21,10 +21,9 @@ function createTestProfile(overrides?: Partial): UserProfile { location: 'Paris', remote: 'hybrid', seniority: 'senior', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript'], tjmMin: 400, tjmMax: 600, - searchKeywords: [], ...overrides, }; } diff --git a/apps/extension/tests/unit/connectors/search-context.test.ts b/apps/extension/tests/unit/connectors/search-context.test.ts index f60b0f85..19515551 100644 --- a/apps/extension/tests/unit/connectors/search-context.test.ts +++ b/apps/extension/tests/unit/connectors/search-context.test.ts @@ -4,32 +4,30 @@ import type { UserProfile } from '../../../src/lib/core/types/profile'; /** * Helper to create a valid UserProfile with defaults and overrides. - * searchKeywords is now a required field in UserProfile. + * keywords is now the unified field on UserProfile. */ function makeProfile(overrides: Partial = {}): UserProfile { return { firstName: 'Test', - stack: ['TypeScript', 'React'], + keywords: ['TypeScript', 'React'], tjmMin: 500, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Fullstack', - searchKeywords: [], // Default to empty array ...overrides, }; } describe('buildSearchContext', () => { /** - * Test 1: With searchKeywords — query should be searchKeywords joined by space + * Test 1: With keywords — query should be keywords joined by space */ - describe('query from searchKeywords', () => { - it('builds query from searchKeywords array joined by space', () => { + describe('query from keywords', () => { + it('builds query from keywords array joined by space', () => { const profile = makeProfile({ - searchKeywords: ['React', 'Developer'], - stack: ['React', 'TypeScript'], + keywords: ['React', 'Developer'], }); const lastSync = new Date('2026-03-20T10:00:00Z'); @@ -39,18 +37,18 @@ describe('buildSearchContext', () => { expect(context.skills).toEqual([]); }); - it('handles single searchKeyword', () => { + it('handles single keyword', () => { const profile = makeProfile({ - searchKeywords: ['Fullstack'], + keywords: ['Fullstack'], }); const context = buildSearchContext(profile, null); expect(context.query).toBe('Fullstack'); }); - it('handles searchKeywords with many terms', () => { + it('handles keywords with many terms', () => { const profile = makeProfile({ - searchKeywords: ['React', 'Node.js', 'Fullstack', 'Freelance'], + keywords: ['React', 'Node.js', 'Fullstack', 'Freelance'], }); const context = buildSearchContext(profile, null); @@ -59,14 +57,14 @@ describe('buildSearchContext', () => { }); /** - * Test 2: Without searchKeywords (empty array), with jobTitle — query should be EMPTY + * Test 2: Without keywords (empty array), with jobTitle — query should be EMPTY * jobTitle is NOT used as fallback because it's too restrictive for API keyword search. * Relevance is handled by local scoring (scoreMission), not server-side filtering. */ describe('query does NOT fallback to jobTitle', () => { - it('returns empty query when searchKeywords is empty (even if jobTitle exists)', () => { + it('returns empty query when keywords is empty (even if jobTitle exists)', () => { const profile = makeProfile({ - searchKeywords: [], + keywords: [], jobTitle: 'Développeur Frontend', }); const context = buildSearchContext(profile, null); @@ -76,7 +74,7 @@ describe('buildSearchContext', () => { it('returns empty query for special character jobTitle (no fallback)', () => { const profile = makeProfile({ - searchKeywords: [], + keywords: [], jobTitle: 'Développeur C#/.NET', }); const context = buildSearchContext(profile, null); @@ -86,12 +84,12 @@ describe('buildSearchContext', () => { }); /** - * Test 3: Without searchKeywords or jobTitle — query should be empty string + * Test 3: Without keywords or jobTitle — query should be empty string */ describe('query empty fallback', () => { - it('returns empty string when both searchKeywords and jobTitle are empty', () => { + it('returns empty string when both keywords and jobTitle are empty', () => { const profile = makeProfile({ - searchKeywords: [], + keywords: [], jobTitle: '', }); const context = buildSearchContext(profile, null); @@ -101,7 +99,7 @@ describe('buildSearchContext', () => { it('trims leading/trailing whitespace from query', () => { const profile = makeProfile({ - searchKeywords: [' React ', ' TypeScript '], + keywords: [' React ', ' TypeScript '], }); const context = buildSearchContext(profile, null); @@ -111,9 +109,9 @@ describe('buildSearchContext', () => { expect(context.query.endsWith(' ')).toBe(false); }); - it('ignores blank searchKeywords when building query', () => { + it('ignores blank keywords when building query', () => { const profile = makeProfile({ - searchKeywords: ['React', ' ', '', '\t', 'TypeScript'], + keywords: ['React', ' ', '', '\t', 'TypeScript'], }); const context = buildSearchContext(profile, null); @@ -129,25 +127,25 @@ describe('buildSearchContext', () => { describe('skills mapping', () => { it('returns empty skills array (skills not sent to APIs)', () => { const profile = makeProfile({ - stack: ['React', 'TypeScript', 'Node.js', 'PostgreSQL'], + keywords: ['React', 'TypeScript', 'Node.js', 'PostgreSQL'], }); const context = buildSearchContext(profile, null); expect(context.skills).toEqual([]); }); - it('returns empty skills array even when stack is empty', () => { + it('returns empty skills array even when keywords is empty', () => { const profile = makeProfile({ - stack: [], + keywords: [], }); const context = buildSearchContext(profile, null); expect(context.skills).toEqual([]); }); - it('returns empty skills regardless of stack contents', () => { + it('returns empty skills regardless of keywords contents', () => { const profile = makeProfile({ - stack: ['Vue.js', 'Nuxt', 'TypeScript', 'GraphQL'], + keywords: ['Vue.js', 'Nuxt', 'TypeScript', 'GraphQL'], }); const context = buildSearchContext(profile, null); @@ -263,8 +261,7 @@ describe('buildSearchContext', () => { it('builds full context with all fields populated', () => { const profile = makeProfile({ firstName: 'Jean', - searchKeywords: ['React', 'TypeScript', 'Senior'], - stack: ['React', 'TypeScript', 'Node.js', 'PostgreSQL'], + keywords: ['React', 'TypeScript', 'Senior'], tjmMin: 600, tjmMax: 800, location: 'Paris', @@ -290,8 +287,7 @@ describe('buildSearchContext', () => { describe('minimal profile', () => { it('builds context with minimal profile (empty arrays, empty strings)', () => { const profile = makeProfile({ - searchKeywords: [], - stack: [], + keywords: [], location: '', jobTitle: '', remote: 'any', @@ -310,27 +306,27 @@ describe('buildSearchContext', () => { * Test 10: Edge cases */ describe('edge cases', () => { - it('handles searchKeywords with special characters', () => { + it('handles keywords with special characters', () => { const profile = makeProfile({ - searchKeywords: ['C#', '.NET', 'Azure DevOps'], + keywords: ['C#', '.NET', 'Azure DevOps'], }); const context = buildSearchContext(profile, null); expect(context.query).toBe('C# .NET Azure DevOps'); }); - it('handles searchKeywords with accented characters', () => { + it('handles keywords with accented characters', () => { const profile = makeProfile({ - searchKeywords: ['Développeur', 'Ingénieur', 'Études'], + keywords: ['Développeur', 'Ingénieur', 'Études'], }); const context = buildSearchContext(profile, null); expect(context.query).toBe('Développeur Ingénieur Études'); }); - it('trims leading/trailing whitespace from searchKeywords', () => { + it('trims leading/trailing whitespace from keywords', () => { const profile = makeProfile({ - searchKeywords: [' React ', ' TypeScript '], + keywords: [' React ', ' TypeScript '], }); const context = buildSearchContext(profile, null); diff --git a/apps/extension/tests/unit/dev/profile-save-propagation.test.ts b/apps/extension/tests/unit/dev/profile-save-propagation.test.ts index e1106e64..fa7c69e0 100644 --- a/apps/extension/tests/unit/dev/profile-save-propagation.test.ts +++ b/apps/extension/tests/unit/dev/profile-save-propagation.test.ts @@ -23,14 +23,13 @@ describe('dev chrome stub — SAVE_PROFILE propagation', () => { // Distinctive stack so a profile-influenced rescore is observable. const savedProfile: UserProfile = { firstName: 'Rustacean', - stack: ['rust', 'wasm'], + keywords: ['rust', 'wasm'], tjmMin: 550, tjmMax: 950, location: 'Paris', remote: 'full', seniority: 'senior', jobTitle: 'Systems Engineer', - searchKeywords: [], }; type DevMessage = { type: string; payload?: unknown }; diff --git a/apps/extension/tests/unit/dev/qa-seed.test.ts b/apps/extension/tests/unit/dev/qa-seed.test.ts index 53793e20..d495da65 100644 --- a/apps/extension/tests/unit/dev/qa-seed.test.ts +++ b/apps/extension/tests/unit/dev/qa-seed.test.ts @@ -127,9 +127,9 @@ describe('buildQaSeed — favorites / hidden / seen / views / profile', () => { it('exposes a complete and an incomplete profile variant', () => { const seed = buildQaSeed(FIXED_NOW); - expect(seed.profile.stack.length).toBeGreaterThan(0); + expect(seed.profile.keywords.length).toBeGreaterThan(0); expect(seed.profile.jobTitle.length).toBeGreaterThan(0); - expect(seed.profileIncomplete.stack).toEqual([]); + expect(seed.profileIncomplete.keywords).toEqual([]); expect(seed.profileIncomplete.jobTitle).toBe(''); }); @@ -216,7 +216,7 @@ describe('applyQaSeedToLocalStorage — writer', () => { expect(missions).toHaveLength(500); const profile = JSON.parse(sink.getItem(QA_LOCALSTORAGE_KEYS.profile) ?? 'null'); - expect(profile.stack).toContain('TypeScript'); + expect(profile.keywords).toContain('TypeScript'); const trackings = JSON.parse(sink.getItem(QA_LOCALSTORAGE_KEYS.trackings) ?? '[]'); expect(trackings).toHaveLength(9); @@ -235,7 +235,7 @@ describe('applyQaSeedToLocalStorage — writer', () => { const sink = makeMemStorage(); applyQaSeedToLocalStorage(FIXED_NOW, 'incomplete', sink); const profile = JSON.parse(sink.getItem(QA_LOCALSTORAGE_KEYS.profile) ?? 'null'); - expect(profile.stack).toEqual([]); + expect(profile.keywords).toEqual([]); expect(profile.jobTitle).toBe(''); }); }); diff --git a/apps/extension/tests/unit/facades/feed-data-facade.test.ts b/apps/extension/tests/unit/facades/feed-data-facade.test.ts index 1d19908f..6ed8d636 100644 --- a/apps/extension/tests/unit/facades/feed-data-facade.test.ts +++ b/apps/extension/tests/unit/facades/feed-data-facade.test.ts @@ -28,14 +28,13 @@ import { const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte'], + keywords: ['Svelte', 'mission svelte'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], }; describe('feed data facade profile bridge', () => { diff --git a/apps/extension/tests/unit/facades/settings-facade.test.ts b/apps/extension/tests/unit/facades/settings-facade.test.ts index a2cc8929..630d8690 100644 --- a/apps/extension/tests/unit/facades/settings-facade.test.ts +++ b/apps/extension/tests/unit/facades/settings-facade.test.ts @@ -19,14 +19,13 @@ import { const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'mission svelte'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], }; const settings: AppSettings = { diff --git a/apps/extension/tests/unit/generation/generation.test.ts b/apps/extension/tests/unit/generation/generation.test.ts index bab2fa94..5a24a4ed 100644 --- a/apps/extension/tests/unit/generation/generation.test.ts +++ b/apps/extension/tests/unit/generation/generation.test.ts @@ -37,14 +37,13 @@ function makeMission(overrides: Partial = {}): Mission { function makeProfile(): UserProfile { return { firstName: 'Alice', - stack: ['Go', 'TypeScript', 'PostgreSQL', 'Docker', 'Kubernetes'], + keywords: ['Go', 'TypeScript', 'PostgreSQL', 'Docker', 'Kubernetes'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Backend Senior', - searchKeywords: [], }; } diff --git a/apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts b/apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts index 6c8d4719..9befa44e 100644 --- a/apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts +++ b/apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts @@ -27,14 +27,13 @@ function makeDraft( function makeProfile(overrides: Partial = {}): UserProfile { return { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'mission svelte'], tjmMin: 650, tjmMax: 900, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], ...overrides, }; } @@ -66,9 +65,9 @@ describe('mergeCandidateProfileIntoUserProfile', () => { expect(merged.jobTitle).toBe('Nouveau titre LinkedIn'); }); - it('unions the current stack with draft skills, deduping case-insensitively while keeping first casing', () => { + it('unions the current keywords with draft skills, deduping case-insensitively while keeping first casing', () => { const merged = mergeCandidateProfileIntoUserProfile( - makeProfile({ stack: ['Svelte', 'TypeScript'] }), + makeProfile({ keywords: ['Svelte', 'TypeScript'] }), makeDraft({ skills: [ { skill: 'svelte', source: 'linkedin', confidence: 0.9 }, @@ -78,7 +77,7 @@ describe('mergeCandidateProfileIntoUserProfile', () => { }) ); - expect(merged.stack).toEqual(['Svelte', 'TypeScript', 'React']); + expect(merged.keywords).toEqual(['Svelte', 'TypeScript', 'React']); }); it('keeps the current location when it is non-empty', () => { @@ -123,7 +122,7 @@ describe('mergeCandidateProfileIntoUserProfile', () => { expect(merged).toEqual({ firstName: '', - stack: [], + keywords: [], tjmMin: 0, tjmMax: 0, location: '', @@ -131,18 +130,17 @@ describe('mergeCandidateProfileIntoUserProfile', () => { seniority: 'senior', jobTitle: 'Solo Title', scoringWeights: undefined, - searchKeywords: [], }); }); - it('preserves tjm, remote, seniority and searchKeywords from the current profile', () => { + it('preserves tjm, remote, seniority and keywords from the current profile', () => { const merged = mergeCandidateProfileIntoUserProfile( makeProfile({ tjmMin: 700, tjmMax: 950, remote: 'full', seniority: 'confirmed', - searchKeywords: ['react', 'remote'], + keywords: ['react', 'remote'], }), makeDraft({ title: 'Nouveau titre' }) ); @@ -151,7 +149,7 @@ describe('mergeCandidateProfileIntoUserProfile', () => { expect(merged.tjmMax).toBe(950); expect(merged.remote).toBe('full'); expect(merged.seniority).toBe('confirmed'); - expect(merged.searchKeywords).toEqual(['react', 'remote']); + expect(merged.keywords).toEqual(['react', 'remote']); expect(merged.firstName).toBe('Guy'); }); @@ -166,14 +164,14 @@ describe('mergeCandidateProfileIntoUserProfile', () => { }); it('does not mutate the input current profile', () => { - const current = makeProfile({ stack: ['Svelte'] }); + const current = makeProfile({ keywords: ['Svelte'] }); const merged = mergeCandidateProfileIntoUserProfile( current, makeDraft({ skills: [{ skill: 'React', source: 'linkedin', confidence: 0.9 }] }) ); - expect(current.stack).toEqual(['Svelte']); - expect(merged.stack).not.toBe(current.stack); - expect(merged.stack).toEqual(['Svelte', 'React']); + expect(current.keywords).toEqual(['Svelte']); + expect(merged.keywords).not.toBe(current.keywords); + expect(merged.keywords).toEqual(['Svelte', 'React']); }); }); diff --git a/apps/extension/tests/unit/profile/normalize-profile.test.ts b/apps/extension/tests/unit/profile/normalize-profile.test.ts index 7b24cfce..5c4d4df7 100644 --- a/apps/extension/tests/unit/profile/normalize-profile.test.ts +++ b/apps/extension/tests/unit/profile/normalize-profile.test.ts @@ -34,7 +34,7 @@ describe('normalize profile helpers', () => { it('fills missing profile fields with save-safe defaults', () => { expect(withProfileDefaults({ firstName: 'Guy' })).toEqual({ firstName: 'Guy', - stack: [], + keywords: [], tjmMin: 0, tjmMax: 0, location: '', @@ -42,7 +42,6 @@ describe('normalize profile helpers', () => { seniority: 'senior', jobTitle: '', scoringWeights: undefined, - searchKeywords: [], }); }); @@ -51,10 +50,8 @@ describe('normalize profile helpers', () => { firstName: ' Guy ', jobTitle: ' Architecte Svelte ', location: ' Paris ', - stack: [' Svelte '], - stackInput: 'TypeScript', - searchKeywords: [' mission '], - keywordInput: 'front', + keywords: [' Svelte ', ' mission '], + keywordInput: 'TypeScript', tjmMin: 600, tjmMax: 750, remote: 'hybrid', @@ -66,8 +63,7 @@ describe('normalize profile helpers', () => { firstName: 'Guy', jobTitle: 'Architecte Svelte', location: 'Paris', - stack: ['Svelte', 'TypeScript'], - searchKeywords: ['mission', 'front'], + keywords: ['Svelte', 'mission', 'TypeScript'], tjmMin: 600, tjmMax: 750, remote: 'hybrid', diff --git a/apps/extension/tests/unit/profile/profile-impact.test.ts b/apps/extension/tests/unit/profile/profile-impact.test.ts index cf8e0b32..0ed14668 100644 --- a/apps/extension/tests/unit/profile/profile-impact.test.ts +++ b/apps/extension/tests/unit/profile/profile-impact.test.ts @@ -14,8 +14,7 @@ function makeProfile(overrides: Partial = {}): ProfileImpact remote: 'any', tjmMin: 0, tjmMax: 0, - stack: [], - searchKeywords: [], + keywords: [], ...overrides, }; } @@ -25,26 +24,26 @@ describe('profile impact model', () => { const items = buildProfileImpactItems(makeProfile()); expect(items.map((item) => item.id).slice(0, 5)).toEqual([ - 'stack', + 'keywords', 'tjm-min', 'remote', 'location', - 'search-keywords', + 'job-title', ]); - expect(items.map((item) => item.weight).slice(0, 5)).toEqual([25, 20, 15, 15, 10]); + expect(items.map((item) => item.weight).slice(0, 5)).toEqual([35, 20, 15, 15, 8]); }); it('computes weighted completion from completed impact fields', () => { const items = buildProfileImpactItems( makeProfile({ firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript'], tjmMin: 650, location: 'Paris', }) ); - expect(computeProfileImpactCompletion(items)).toBe(62); + expect(computeProfileImpactCompletion(items)).toBe(72); }); it('simulates the gain from the three highest-impact missing fields', () => { @@ -52,14 +51,14 @@ describe('profile impact model', () => { const simulation = buildProfileImpactSimulation(items); expect(simulation.currentCompletion).toBe(2); - expect(simulation.nextCompletion).toBe(62); - expect(simulation.delta).toBe(60); + expect(simulation.nextCompletion).toBe(72); + expect(simulation.delta).toBe(70); expect(simulation.prioritizedItems.map((item) => item.id)).toEqual([ - 'stack', + 'keywords', 'tjm-min', 'remote', ]); - expect(simulation.title).toContain('Stack technique, TJM minimum, Mode de travail'); + expect(simulation.title).toContain('Mots-clés, TJM minimum, Mode de travail'); }); it('marks a fully specified profile as ready for scoring and alerts', () => { @@ -71,8 +70,7 @@ describe('profile impact model', () => { remote: 'hybrid', tjmMin: 650, tjmMax: 850, - stack: ['Svelte', 'TypeScript'], - searchKeywords: ['SaaS'], + keywords: ['Svelte', 'TypeScript', 'SaaS'], }) ); const simulation = buildProfileImpactSimulation(items); diff --git a/apps/extension/tests/unit/scan/rescore.test.ts b/apps/extension/tests/unit/scan/rescore.test.ts index 38295b5e..5cbdb3ad 100644 --- a/apps/extension/tests/unit/scan/rescore.test.ts +++ b/apps/extension/tests/unit/scan/rescore.test.ts @@ -37,13 +37,12 @@ import { rescoreStoredMissions } from '../../../src/lib/shell/scan/rescore'; const profile: UserProfile = { firstName: 'Guy', jobTitle: 'Dev', - stack: ['TypeScript'], + keywords: ['TypeScript'], tjmMin: 500, tjmMax: 900, location: 'Paris', remote: 'any', seniority: 'senior', - searchKeywords: [], scoringWeights: { stack: 40, location: 20, diff --git a/apps/extension/tests/unit/scoring/relevance.test.ts b/apps/extension/tests/unit/scoring/relevance.test.ts index c91b1fe4..41ffb636 100644 --- a/apps/extension/tests/unit/scoring/relevance.test.ts +++ b/apps/extension/tests/unit/scoring/relevance.test.ts @@ -5,7 +5,7 @@ import type { UserProfile } from '../../../src/lib/core/types/profile'; const profile: UserProfile = { firstName: 'Test', - stack: ['TypeScript', 'React', 'Node.js'], + keywords: ['TypeScript', 'React', 'Node.js'], tjmMin: 500, tjmMax: 700, location: 'Paris', @@ -121,7 +121,7 @@ describe('scoreMission', () => { }); it('gives full stack weight when profile has no stack (does not penalize user)', () => { - const profileNoStack: UserProfile = { ...profile, stack: [] }; + const profileNoStack: UserProfile = { ...profile, keywords: [] }; const mission = makeMission({ stack: ['React', 'TypeScript', 'Node.js'], tjm: 600, @@ -189,7 +189,7 @@ describe('scoreMission', () => { describe('regression: undefined safety', () => { const baseProfile: UserProfile = { firstName: 'Test', - stack: ['TypeScript', 'React'], + keywords: ['TypeScript', 'React'], location: 'Paris', tjmMin: 500, tjmMax: 800, @@ -210,7 +210,7 @@ describe('scoreMission', () => { it('should not crash when profile has undefined entries in stack array', () => { const profileWithUndefined: UserProfile = { ...baseProfile, - stack: ['TypeScript', undefined, 'React'] as any, + keywords: ['TypeScript', undefined, 'React'] as any, }; const mission = makeMission({ stack: ['React', 'TypeScript'] }); const s = score(mission, profileWithUndefined); @@ -240,7 +240,7 @@ describe('scoreMission', () => { describe('location nearby scoring', () => { const parisProfile: UserProfile = { firstName: 'Test', - stack: ['TypeScript', 'React'], + keywords: ['TypeScript', 'React'], location: 'Paris', tjmMin: 500, tjmMax: 800, diff --git a/apps/extension/tests/unit/scoring/semantic-scoring.test.ts b/apps/extension/tests/unit/scoring/semantic-scoring.test.ts index 2693e3f9..8d2044d9 100644 --- a/apps/extension/tests/unit/scoring/semantic-scoring.test.ts +++ b/apps/extension/tests/unit/scoring/semantic-scoring.test.ts @@ -8,14 +8,13 @@ import type { UserProfile } from '../../../src/lib/core/types/profile'; const profile: UserProfile = { firstName: 'Guy', - stack: ['TypeScript', 'React', 'Node.js'], + keywords: ['TypeScript', 'React', 'Node.js'], tjmMin: 500, tjmMax: 700, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Developpeur Fullstack', - searchKeywords: [], }; const mission: Mission = { diff --git a/apps/extension/tests/unit/state/profile-store.test.ts b/apps/extension/tests/unit/state/profile-store.test.ts index ba6f3bc6..9afdc643 100644 --- a/apps/extension/tests/unit/state/profile-store.test.ts +++ b/apps/extension/tests/unit/state/profile-store.test.ts @@ -8,14 +8,13 @@ import { const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'mission svelte'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], }; function waitForSnapshot( diff --git a/apps/extension/tests/unit/storage/connected-profile-cache.test.ts b/apps/extension/tests/unit/storage/connected-profile-cache.test.ts index b4e47fd0..b74fb9e7 100644 --- a/apps/extension/tests/unit/storage/connected-profile-cache.test.ts +++ b/apps/extension/tests/unit/storage/connected-profile-cache.test.ts @@ -8,14 +8,13 @@ import type { UserProfile } from '../../../src/lib/core/types/profile'; const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'svelte mission'], tjmMin: 650, tjmMax: 900, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Architecte frontend', - searchKeywords: ['svelte mission'], scoringWeights: { stack: 40, location: 20, tjm: 25, remote: 15 }, }; diff --git a/apps/extension/tests/unit/storage/profile-db.test.ts b/apps/extension/tests/unit/storage/profile-db.test.ts index cef40364..f4ccf67f 100644 --- a/apps/extension/tests/unit/storage/profile-db.test.ts +++ b/apps/extension/tests/unit/storage/profile-db.test.ts @@ -5,14 +5,13 @@ import { clearProfile, getProfile, saveProfile } from '../../../src/lib/shell/st const profile: UserProfile = { firstName: '', - stack: ['Svelte Save'], + keywords: ['Svelte Save', 'mission svelte'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Architecte Svelte', - searchKeywords: ['mission svelte'], }; describe('profile IndexedDB store', () => { diff --git a/apps/extension/tests/unit/storage/profile-validation.test.ts b/apps/extension/tests/unit/storage/profile-validation.test.ts index 2970e766..6f2a9e66 100644 --- a/apps/extension/tests/unit/storage/profile-validation.test.ts +++ b/apps/extension/tests/unit/storage/profile-validation.test.ts @@ -8,14 +8,13 @@ import type { UserProfile } from '../../../src/lib/core/types/profile'; function validProfile(): UserProfile { return { firstName: 'Guy', - stack: ['React', 'TypeScript'], + keywords: ['React', 'TypeScript'], tjmMin: 500, tjmMax: 700, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur React Senior', - searchKeywords: [], }; } @@ -84,21 +83,21 @@ describe('UserProfileSchema — validation Zod', () => { expect(result.success).toBe(false); }); - it('rejette plus de 20 compétences dans stack', () => { + it('rejette plus de 40 mots-clés dans keywords', () => { const profile = { ...validProfile(), - stack: Array.from({ length: 21 }, (_, i) => `Skill${i}`), + keywords: Array.from({ length: 41 }, (_, i) => `Skill${i}`), }; const result = UserProfileSchema.safeParse(profile); expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((i) => i.message).join(', '); - expect(messages).toContain('20'); + expect(messages).toContain('40'); } }); - it('rejette une compétence vide dans stack', () => { - const profile = { ...validProfile(), stack: ['React', ''] }; + it('rejette un mot-clé vide dans keywords', () => { + const profile = { ...validProfile(), keywords: ['React', ''] }; const result = UserProfileSchema.safeParse(profile); expect(result.success).toBe(false); }); diff --git a/apps/extension/tests/unit/storage/semantic-cache.test.ts b/apps/extension/tests/unit/storage/semantic-cache.test.ts index cac8d33b..4ef2f928 100644 --- a/apps/extension/tests/unit/storage/semantic-cache.test.ts +++ b/apps/extension/tests/unit/storage/semantic-cache.test.ts @@ -36,14 +36,13 @@ vi.stubGlobal('chrome', { const baseProfile: UserProfile = { firstName: 'Guy', - stack: ['TypeScript', 'React'], + keywords: ['TypeScript', 'React'], tjmMin: 500, tjmMax: 700, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Developpeur Fullstack', - searchKeywords: [], }; describe('semantic cache', () => { @@ -70,7 +69,7 @@ describe('semantic cache', () => { await expect( getCachedSemanticScores(['mission-1'], { ...baseProfile, - stack: ['Go', 'Rust'], + keywords: ['Go', 'Rust'], jobTitle: 'Developpeur backend', }) ).resolves.toEqual(new Map()); diff --git a/apps/extension/tests/unit/types/type-guards.test.ts b/apps/extension/tests/unit/types/type-guards.test.ts index e4040ba8..f7ed6aa9 100644 --- a/apps/extension/tests/unit/types/type-guards.test.ts +++ b/apps/extension/tests/unit/types/type-guards.test.ts @@ -56,14 +56,13 @@ const makeValidMission = (overrides: Partial = {}): Mission => ({ const makeValidProfile = (overrides: Partial = {}): UserProfile => ({ firstName: 'John', - stack: ['TypeScript', 'React'], + keywords: ['TypeScript', 'React'], tjmMin: 500, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Developer', - searchKeywords: [], ...overrides, }); @@ -154,13 +153,13 @@ describe('isUserProfile', () => { }); it('returns false for missing required fields', () => { - const { stack, ...partial } = makeValidProfile(); + const { keywords, ...partial } = makeValidProfile(); expect(isUserProfile(partial)).toBe(false); }); - it('returns true for profile with empty stack array', () => { + it('returns true for profile with empty keywords array', () => { const profile = makeValidProfile({ - stack: [], + keywords: [], }); expect(isUserProfile(profile)).toBe(true); }); @@ -325,11 +324,11 @@ describe('parseUserProfile', () => { const input = makeValidProfile(); const result = parseUserProfile(input); expect(result).not.toBeNull(); - expect(result!.stack).toEqual(['TypeScript', 'React']); + expect(result!.keywords).toEqual(['TypeScript', 'React']); }); it('returns null for invalid profile', () => { - const input = { stack: 'not-an-array' }; + const input = { keywords: 'not-an-array' }; expect(parseUserProfile(input)).toBeNull(); }); @@ -339,7 +338,7 @@ describe('parseUserProfile', () => { it('handles corrupted data gracefully', () => { const corrupted = { - stack: ['React'], + keywords: ['React'], seniority: 'invalid-level', }; expect(parseUserProfile(corrupted)).toBeNull(); diff --git a/apps/extension/tests/unit/ui/BackupRestoreModal.test.ts b/apps/extension/tests/unit/ui/BackupRestoreModal.test.ts index 63e62557..df16f9c6 100644 --- a/apps/extension/tests/unit/ui/BackupRestoreModal.test.ts +++ b/apps/extension/tests/unit/ui/BackupRestoreModal.test.ts @@ -7,14 +7,13 @@ import type { AppSettings } from '../../../src/lib/core/types/app-settings'; const profile: UserProfile = { firstName: 'Guy', - stack: ['Svelte'], + keywords: ['Svelte'], tjmMin: 600, tjmMax: 800, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: [], }; const settings: AppSettings = { diff --git a/apps/extension/tests/unit/ui/CvPageSync.test.ts b/apps/extension/tests/unit/ui/CvPageSync.test.ts index 168976ab..fcfe42f6 100644 --- a/apps/extension/tests/unit/ui/CvPageSync.test.ts +++ b/apps/extension/tests/unit/ui/CvPageSync.test.ts @@ -12,14 +12,13 @@ import { vi.mock('../../../src/lib/shell/messaging/bridge', () => { const mockProfile = { firstName: 'Guy', - stack: ['Svelte', 'TypeScript'], + keywords: ['Svelte', 'TypeScript', 'mission svelte'], tjmMin: 650, tjmMax: 900, location: 'Paris', remote: 'hybrid', seniority: 'senior', jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], }; const mockDraft = { title: 'Lead Frontend Svelte', diff --git a/apps/extension/tests/unit/ui/OnboardingWizard.test.ts b/apps/extension/tests/unit/ui/OnboardingWizard.test.ts index 3a29a712..cbb5b6a0 100644 --- a/apps/extension/tests/unit/ui/OnboardingWizard.test.ts +++ b/apps/extension/tests/unit/ui/OnboardingWizard.test.ts @@ -154,11 +154,9 @@ describe('OnboardingWizard', () => { target.querySelector('#ob-firstname')?.dispatchEvent(new Event('input', { bubbles: true })); (target.querySelector('#ob-jobtitle') as HTMLInputElement).value = ' Dev React Senior '; target.querySelector('#ob-jobtitle')?.dispatchEvent(new Event('input', { bubbles: true })); - (target.querySelector('#ob-stack') as HTMLInputElement).value = 'React'; - target.querySelector('#ob-stack')?.dispatchEvent(new Event('input', { bubbles: true })); - ( - target.querySelector('button[aria-label="Ajouter la stack technique"]') as HTMLButtonElement - ).click(); + (target.querySelector('#ob-keywords') as HTMLInputElement).value = 'React'; + target.querySelector('#ob-keywords')?.dispatchEvent(new Event('input', { bubbles: true })); + (target.querySelector('button[aria-label="Ajouter le mot-clé"]') as HTMLButtonElement).click(); (target.querySelector('#ob-location') as HTMLInputElement).value = ' Paris '; target.querySelector('#ob-location')?.dispatchEvent(new Event('input', { bubbles: true })); await tick(); @@ -171,7 +169,7 @@ describe('OnboardingWizard', () => { firstName: 'Guy', jobTitle: 'Dev React Senior', location: 'Paris', - stack: ['React'], + keywords: ['React'], tjmMin: 600, tjmMax: 750, }) @@ -181,22 +179,20 @@ describe('OnboardingWizard', () => { // B-1: incremental stack edits must signal onUpdateProfile so the hosting // page can propagate them (previously the wizard emitted the callback but // OnboardingPage never wired it, so every call was a no-op). - it('emits incremental profile updates when stack chips change', async () => { + it('emits incremental profile updates when keyword chips change', async () => { const onUpdateProfile = vi.fn(); const target = mountWizard({ onUpdateProfile }); await tick(); - const stackInput = target.querySelector('#ob-stack') as HTMLInputElement; - stackInput.value = 'React'; - stackInput.dispatchEvent(new Event('input', { bubbles: true })); - ( - target.querySelector('button[aria-label="Ajouter la stack technique"]') as HTMLButtonElement - ).click(); + const keywordInput = target.querySelector('#ob-keywords') as HTMLInputElement; + keywordInput.value = 'React'; + keywordInput.dispatchEvent(new Event('input', { bubbles: true })); + (target.querySelector('button[aria-label="Ajouter le mot-clé"]') as HTMLButtonElement).click(); await tick(); - expect(onUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ stack: ['React'] })); + expect(onUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ keywords: ['React'] })); - // Removing a chip also propagates the updated stack. + // Removing a chip also propagates the updated keywords. onUpdateProfile.mockClear(); ( [...target.querySelectorAll('button')].find((b) => @@ -205,6 +201,6 @@ describe('OnboardingWizard', () => { ).click(); await tick(); - expect(onUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ stack: [] })); + expect(onUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ keywords: [] })); }); }); diff --git a/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts b/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts index e938b809..f09e2c7e 100644 --- a/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts +++ b/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts @@ -312,12 +312,13 @@ describe('operational UI constraints', () => { const profileSource = readFileSync('src/ui/pages/ProfilePage.svelte', 'utf8'); const impactSource = readFileSync('src/lib/core/profile/profile-impact.ts', 'utf8'); - expect(impactSource).toContain("id: 'stack'"); + expect(impactSource).toContain("id: 'keywords'"); expect(impactSource).toContain("id: 'tjm-min'"); expect(impactSource).toContain("id: 'remote'"); expect(impactSource).toContain("id: 'location'"); - expect(impactSource).toContain("id: 'search-keywords'"); - expect(impactSource.indexOf("id: 'stack'")).toBeLessThan(impactSource.indexOf("id: 'remote'")); + expect(impactSource.indexOf("id: 'keywords'")).toBeLessThan( + impactSource.indexOf("id: 'remote'") + ); expect(profileSource).toContain('buildProfileImpactItems'); expect(profileSource).toContain('buildProfileImpactSimulation'); expect(profileSource).toContain('Priorités d’impact'); diff --git a/openspec/changes/keywords-unification/proposal.md b/openspec/changes/keywords-unification/proposal.md new file mode 100644 index 00000000..52f56d93 --- /dev/null +++ b/openspec/changes/keywords-unification/proposal.md @@ -0,0 +1,112 @@ +# Proposal: Unifier compétences et mots-clés en une seule liste `keywords` + +## Why — Produit + +Le profil expose aujourd'hui **deux listes parallèles** : + +- `stack` (compétences) → alimente **uniquement le scoring local** (`rawStackScore` vs `mission.stack`). +- `searchKeywords` (mots-clés) → alimente **uniquement la requête API connecteur** (`buildSearchContext`). + +Cette distinction est invisible pour un utilisateur non technique, qui ne sait +pas si « SaaS » ou « React » va dans « compétences » ou « mots-clés ». Résultat : +saisie dupliquée ou second champ vide et expérience dégradée. On collapse les +deux en **une seule liste `keywords`** qui alimente les deux canaux. + +## Decision (confirmée produit) + +Liste unifiée `keywords` alimente **à la fois** : + +1. le **scoring local** (match contre `mission.stack` — les termes de domaine + sont scoring-neutres, cf. modèle) ; +2. la **requête API** (joint en free-text `query`, `skills: []` inchangé). + +Merge complet, pas un input cosmétique sur deux champs cachés. + +## Source de vérité + +`apps/extension/src/models/keywords-unification.model.md` — modèle autoritatif +(états, invariants, migration, périmètre). Cette proposal ne le duplique pas. + +## What Changes + +### Core (pur) + +| Fichier | Changement | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core/types/profile.ts` | `UserProfile.stack` → `keywords` ; supprime `searchKeywords`. | +| `core/profile/normalize-profile.ts` | `ProfileDraftInput` : `stack`/`stackInput` → `keywords`/`keywordInput` ; supprime `searchKeywords`/`keywordInput` (l'ancien). `withProfileDefaults` + `normalizeProfileDraft` suivent. | +| `core/profile/defaults.ts` | `DEFAULT_PROFILE.stack: []` → `keywords: []` ; supprime `searchKeywords` ; `isDefaultProfile` suit. | +| `core/profile/profile-impact.ts` | Fusionne les items `stack` (25) + `search-keywords` (10) → `keywords` (35). `ProfileImpactFieldId` / `ProfileImpactInput` suivent. | +| `core/connectors/search-context.ts` | `query` dérivé de `profile.keywords` (was `searchKeywords`). Comportement identique. | +| `core/scoring/relevance.ts` | `rawStackScore(missionStack, profileKeywords)` — param renommé, logique inchangée. `ScoringWeights.stack` / `DeterministicBreakdown.stack` **inchangés** (nom de dimension). | +| `core/types/schemas.ts` | `UserProfileSchema` : `stack`+`searchKeywords` → `keywords` + preprocess shim (legacy `stack`/`searchKeywords` → `keywords`). | +| `core/types/type-guards.ts` | Hérite le shim via `UserProfileSchema` (pas de code supplémentaire). | + +### Shell (I/O) + +| Fichier | Changement | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `shell/storage/migration-registry.ts` | Ajoute data migration v1→v2 : merge `stack`+`searchKeywords` → `keywords` dans le store `profile`. Idempotente. | +| `shell/storage/db.ts` | `APP_DATA_VERSION` 1 → 2. `saveProfile`/`getProfile` écrivent/lisent la nouvelle forme (shim en lecture). | +| `shell/messaging/bridge.ts` + `schemas.ts` | Messages profil : `stack` → `keywords`, supprime `searchKeywords`. | +| `shell/ai/build-cv-summary.ts` | `profile.stack` → `profile.keywords`. | +| `shell/notifications/notify-missions.ts` | Si lit `profile.stack` → `profile.keywords`. `requiredStacks` (alert prefs) **inchangé**. | +| `shell/notifications/daily-digest.ts` | Idem. `requiredStacks` **inchangé**. | +| `shell/sync/connected-dashboard.ts` | Vérifier sync profil : si envoie `stack`/`searchKeywords`, adapter. `required_stacks` (alert prefs) **inchangé**. | +| `background/index.ts` | `profileStacks` (TJM) → `profileKeywords`. | +| `shell/.../tjm.facade.ts` | Suit. | +| LinkedIn extractor (`merge-candidate-profile.ts`/similaire) | `stack` → `keywords`. | + +### State (runes) + +| Fichier | Changement | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lib/state/settings-page.svelte.ts` | `profileStack`/`stackInput`/`addStack`/`removeStack`/`setStack` → `profileKeywords`/`keywordInput`/`addKeyword`/`removeKeyword`/`setKeywords`. Supprime l'ancien éditeur `searchKeywords` (folded). | +| `lib/state/feed-page.svelte.ts` | Suit. | + +### UI + +| Fichier | Changement | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `ui/organisms/ProfileSection.svelte` | Fusionne les deux sections (stack + mots-clés) → **une** section « Mots-clés ». | +| `ui/organisms/OnboardingWizard.svelte` | Step « compétences » → « Mots-clés ». `requiredStacks: stack` → `requiredStacks: keywords` (nom `requiredStacks` inchangé). | +| `ui/pages/ProfilePage.svelte` | Passe `keywords` au lieu de `stack`+`searchKeywords`. | +| `ui/pages/FeedPage.svelte` | `preferences.requiredStacks` **inchangé** (alert prefs, pas profil). | +| `ui/molecules/AlertBuilderCard.svelte` | **Inchangé** — `requiredStacks` est un concept d'alerte (contract dashboard). | +| `ui/pages/CvPage.svelte`, `TJMPage.svelte`, `SettingsPage.svelte` | `profile.stack` → `profile.keywords`. | + +### Dev / mocks / tests + +- `dev/mocks.ts`, `dev/qa-seed.ts`, `dev/chrome-stubs.ts` : forme profil mise à jour. +- Tous les tests référençant `profile.stack` / `searchKeywords` / `profileStack` / `stackInput` / `addStack` / `removeStack` / items d'impact `stack`+`search-keywords` : mis à jour vers `keywords`. +- Nouveau test : data migration v1→v2 (idempotence, merge, no-op sur record manquant). +- Nouveau test : shim preprocess (legacy `stack`+`searchKeywords` → `keywords` via `parseUserProfile`). +- `profile-impact.test.ts` : un seul item `keywords` (poids 35). + +## Non-renommé (volontaire — cf. modèle) + +`Mission.stack`, `ScoringWeights.stack`, `DEFAULT_SCORING_WEIGHTS.stack`, +`DeterministicBreakdown.stack`, `ConnectedAlertPreferences.requiredStacks`, +`AlertHistoryEntry.requiredStacks`, `SmartAlertCriteria.requiredStacks`. + +## Constraints + +- FC&IS : `keywords` sur le type pur `UserProfile` ; toute I/O (migration, save/load) en `shell/`. Core n'importe jamais shell. +- TS strict, pas de `any` (shim typé via `unknown` + narrowing). +- Pas de `tailwind.config.js` ; TailwindCSS 4 CSS-first ; Svelte 5 runes uniquement. +- `DB_VERSION` (structurel) **inchangé** — aucun store/index ajouté. Seul `APP_DATA_VERSION` monte. +- Conventional commit : `refactor(profile): unify stack and searchKeywords into keywords` + +## Tests + +- `pnpm --filter @pulse/extension typecheck` +- `pnpm --filter @pulse/extension lint` +- `pnpm --filter @pulse/extension test` (unitaires, dont migration + shim + impact + scoring + search-context) +- `pnpm --filter @pulse/extension test:regression` (parsers inchangés — doit rester vert sans regen) +- `pnpm ci:check` (pre-push gate) + +## Risque + +- **Perte de profil au upgrade** : mitigé par shim (Layer 1) + migration (Layer 2). Invariant 1. +- **Sync dashboard** : `requiredStacks`/`required_stacks` inchangés. Si le dashboard sync le **profil** (pas seulement les alertes), vérifier `connected-dashboard.ts` avant de casser le contrat. +- **Blast radius** : ~50 fichiers. Mécanique mais vaste — exécuter par couches (Core → Shell → State → UI → Dev → Tests) avec typecheck entre chaque.