diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index fce5892a..05e7f6fd 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -872,7 +872,7 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse try { const draft = message.payload.profile; const current = await getProfile(); - const merged = mergeCandidateProfileIntoUserProfile(current, draft); + const merged = mergeCandidateProfileIntoUserProfile(current, draft, Date.now()); await saveProfile(merged); chrome.runtime.sendMessage({ type: 'PROFILE_UPDATED', payload: merged }).catch(() => { // Side panel not open, ignore diff --git a/apps/extension/src/dev/chrome-stubs.ts b/apps/extension/src/dev/chrome-stubs.ts index 94115cd6..e76fdfca 100644 --- a/apps/extension/src/dev/chrome-stubs.ts +++ b/apps/extension/src/dev/chrome-stubs.ts @@ -399,7 +399,7 @@ function createChromeStubs() { case 'SYNC_LINKEDIN_PROFILE_IMPORT': { const draft = (message.payload as { profile: CanonicalCandidateProfileDraft }).profile; const current = readDevStorage(DEV_PROFILE_STORAGE_KEY, mockProfile); - const merged = mergeCandidateProfileIntoUserProfile(current, draft); + const merged = mergeCandidateProfileIntoUserProfile(current, draft, Date.now()); writeDevStorage(DEV_PROFILE_STORAGE_KEY, merged); storage.profile = merged; emitRuntimeMessage({ type: 'PROFILE_UPDATED', payload: merged }); diff --git a/apps/extension/src/dev/mocks.ts b/apps/extension/src/dev/mocks.ts index 7292c111..dffb4a68 100644 --- a/apps/extension/src/dev/mocks.ts +++ b/apps/extension/src/dev/mocks.ts @@ -11,6 +11,55 @@ export const mockProfile: UserProfile = { remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Fullstack', + experiences: [ + { + id: 'exp-mock-1', + title: 'Lead Frontend', + company: 'Fintech Scale-up', + location: 'Paris', + startDate: '2023-03', + endDate: null, + isCurrent: true, + description: + 'Refonte de la plateforme client en Svelte 5, mise en place du design system et accompagnement d’une équipe de 4 développeurs.', + skills: ['Svelte', 'TypeScript', 'Vite', 'TailwindCSS'], + source: 'manual', + sourceExternalId: null, + positionIndex: 0, + updatedAt: 1710000000000, + }, + { + id: 'exp-mock-2', + title: 'Développeur Fullstack', + company: 'Agence Web Lyon', + location: 'Lyon', + startDate: '2020-09', + endDate: '2023-02', + isCurrent: false, + description: + 'Conception et développement de dashboards SaaS pour des clients B2B, API Node.js + React.', + skills: ['React', 'Node.js', 'PostgreSQL', 'Docker'], + source: 'linkedin', + sourceExternalId: 'li-123', + positionIndex: 1, + updatedAt: 1710000000000, + }, + { + id: 'exp-mock-3', + title: 'Développeur Frontend', + company: 'Startup SaaS', + location: 'Remote', + startDate: '2018-01', + endDate: '2020-08', + isCurrent: false, + description: 'Intégration d’une bibliothèque de composants et migration Angular vers React.', + skills: ['Angular', 'React', 'TypeScript'], + source: 'linkedin', + sourceExternalId: 'li-456', + positionIndex: 2, + updatedAt: 1710000000000, + }, + ], }; const stacks = [ diff --git a/apps/extension/src/dev/qa-seed.ts b/apps/extension/src/dev/qa-seed.ts index 648596d5..23ccec82 100644 --- a/apps/extension/src/dev/qa-seed.ts +++ b/apps/extension/src/dev/qa-seed.ts @@ -417,6 +417,39 @@ function buildCompleteProfile(): UserProfile { remote: 'hybrid', seniority: 'senior', jobTitle: 'Développeur Fullstack', + experiences: [ + { + id: 'exp-seed-1', + title: 'Lead Frontend', + company: 'Fintech Scale-up', + location: 'Paris', + startDate: '2023-03', + endDate: null, + isCurrent: true, + description: + 'Refonte de la plateforme client en Svelte 5 et mise en place du design system.', + skills: ['Svelte', 'TypeScript', 'Vite', 'TailwindCSS'], + source: 'manual', + sourceExternalId: null, + positionIndex: 0, + updatedAt: 1710000000000, + }, + { + id: 'exp-seed-2', + title: 'Développeur Fullstack', + company: 'Agence Web Lyon', + location: 'Lyon', + startDate: '2020-09', + endDate: '2023-02', + isCurrent: false, + description: 'Dashboards SaaS pour des clients B2B, API Node.js + React.', + skills: ['React', 'Node.js', 'PostgreSQL', 'Docker'], + source: 'linkedin', + sourceExternalId: 'li-123', + positionIndex: 1, + updatedAt: 1710000000000, + }, + ], }; } @@ -430,6 +463,7 @@ function buildIncompleteProfile(): UserProfile { remote: 'hybrid', seniority: 'senior', jobTitle: '', + experiences: [], }; } diff --git a/apps/extension/src/lib/core/backup/backup.ts b/apps/extension/src/lib/core/backup/backup.ts index 28c85b96..96e23d54 100644 --- a/apps/extension/src/lib/core/backup/backup.ts +++ b/apps/extension/src/lib/core/backup/backup.ts @@ -66,10 +66,14 @@ export function createBackup( hidden: Record, timestamp: number ): BackupData { + const normalizedProfile: UserProfile = { + ...profile, + experiences: profile.experiences ?? [], + }; return { version: CURRENT_BACKUP_VERSION, timestamp, - profile, + profile: normalizedProfile, settings, favorites, hidden, diff --git a/apps/extension/src/lib/core/cv/experience-helpers.ts b/apps/extension/src/lib/core/cv/experience-helpers.ts new file mode 100644 index 00000000..112f370f --- /dev/null +++ b/apps/extension/src/lib/core/cv/experience-helpers.ts @@ -0,0 +1,231 @@ +/** + * Pure helpers for the CV experience feed and cross-platform sync. + * + * STRICTLY PURE: no Date, no async, no I/O, no side effects. Non-deterministic + * values (`now`, id generation) are injected by the shell caller. + * + * See `apps/extension/src/models/cv-experience-sync.model.md` for the state + * machine that consumes these helpers. + */ +import type { Experience, ExperienceSource } from '../types/profile'; +import type { CandidateExperienceDraft } from '../profile-extractors/types'; + +/** A platform target for the sync push (LinkedIn + mission connectors). */ +export interface PlatformSyncTarget { + id: string; + name: string; + profileUrl: string; +} + +/** Build the per-platform text payload (same CV block for every target). */ +export function buildPlatformPayloads( + experiences: readonly Experience[], + targets: readonly PlatformSyncTarget[] +): Map { + const payload = formatExperiencePayload(experiences); + const map = new Map(); + for (const target of targets) { + map.set(target.id, payload); + } + return map; +} + +/** Format the experiences into a copy-pasteable CV block. */ +export function formatExperiencePayload(experiences: readonly Experience[]): string { + if (experiences.length === 0) { + return ''; + } + + const blocks = experiences.map((exp) => { + const head = [exp.title, exp.company].filter((part) => part && part.length > 0).join(' — '); + const range = formatExperienceDateRange(exp); + const lines: string[] = [head + (range ? ` · ${range}` : '')]; + if (exp.location) { + lines.push(exp.location); + } + if (exp.description) { + lines.push(exp.description); + } + if (exp.skills.length > 0) { + lines.push(`Stack: ${exp.skills.join(', ')}`); + } + return lines.join('\n'); + }); + + return blocks.join('\n\n'); +} + +/** "2023 — présent" / "2023 — 2025" / "2023" / "" when no dates. */ +export function formatExperienceDateRange( + exp: Pick +): string { + const start = exp.startDate ?? ''; + if (!start) { + return ''; + } + if (exp.isCurrent) { + return `${start} — présent`; + } + const end = exp.endDate ?? ''; + return end ? `${start} — ${end}` : start; +} + +/** + * Normalize a raw/edited experience draft into a canonical {@link Experience}. + * Enforces the `isCurrent ↔ endDate === null` invariant and trims all text. + * Does NOT assign `positionIndex` (that is {@link recomputePositionIndex}'s job). + */ +export function normalizeExperience( + draft: Partial & { title: string }, + now: number, + generateId: () => string +): Experience { + const title = draft.title.trim(); + const isCurrent = draft.isCurrent ?? false; + const endDate = isCurrent ? null : (trimToNull(draft.endDate) ?? null); + const source: ExperienceSource = draft.source ?? 'manual'; + + return { + id: draft.id ?? generateId(), + title, + company: trimToNull(draft.company) ?? null, + location: trimToNull(draft.location) ?? null, + startDate: trimToNull(draft.startDate) ?? null, + endDate, + isCurrent, + description: (draft.description ?? '').trim(), + skills: (draft.skills ?? []).map((s) => s.trim()).filter((s) => s.length > 0), + source, + sourceExternalId: draft.sourceExternalId ?? null, + positionIndex: draft.positionIndex ?? 0, + updatedAt: now, + }; +} + +/** + * Recompute gapless `positionIndex` (0 = most recent). Sorts by `startDate` + * descending; entries without a start date keep their relative order at the + * end. Stable: ties preserve the input order. + */ +export function recomputePositionIndex(experiences: readonly Experience[]): Experience[] { + const indexed = experiences.map((exp, originalIndex) => ({ exp, originalIndex })); + indexed.sort((a, b) => { + const sa = a.exp.startDate ?? ''; + const sb = b.exp.startDate ?? ''; + if (sa !== sb) { + return sb < sa ? -1 : 1; // descending start date + } + return a.originalIndex - b.originalIndex; // stable tiebreak + }); + return indexed.map(({ exp }, i) => ({ ...exp, positionIndex: i })); +} + +/** + * Merge imported draft experiences into the current persisted list. + * + * Dedup key: `(company, title, startDate)` case-insensitively. On match, the + * local entry is kept (id, positionIndex, source, description when manual) and + * its skills are unioned with the draft's. New drafts become `source: 'linkedin'` + * entries with a `now`-seeded id. The result is position-indexed via + * {@link recomputePositionIndex}. + */ +export function mergeExperiences( + current: readonly Experience[], + incoming: readonly CandidateExperienceDraft[], + now: number +): Experience[] { + const result: Experience[] = current.map((exp) => ({ ...exp, skills: [...exp.skills] })); + + incoming.forEach((draft, importIndex) => { + // Normalize imported dates (LinkedIn yields YYYY-MM-DD) to the canonical + // YYYY-MM month format so they dedupe against manual entries and are valid + // when edited through the month input. + const draftStart = normalizeDateToMonth(draft.startDate); + const draftEnd = normalizeDateToMonth(draft.endDate); + const key = experienceKey(draft.company, draft.title, draftStart); + const existingIdx = result.findIndex( + (exp) => experienceKey(exp.company, exp.title, exp.startDate) === key + ); + + if (existingIdx >= 0) { + const existing = result[existingIdx]; + const keepDescription = existing.source === 'manual' || draft.description.length === 0; + const mergedIsCurrent = existing.isCurrent || draft.isCurrent; + result[existingIdx] = { + ...existing, + skills: unionSkills(existing.skills, draft.skills), + description: keepDescription ? existing.description : draft.description, + location: existing.location ?? draft.location, + endDate: mergedIsCurrent ? null : (existing.endDate ?? draftEnd ?? null), + isCurrent: mergedIsCurrent, + sourceExternalId: existing.sourceExternalId ?? draft.sourceExternalId, + }; + return; + } + + result.push({ + id: `exp-${now}-${importIndex}`, + title: draft.title, + company: draft.company, + location: draft.location, + startDate: draftStart, + endDate: draft.isCurrent ? null : draftEnd, + isCurrent: draft.isCurrent, + description: draft.description, + skills: [...draft.skills], + source: 'linkedin', + sourceExternalId: draft.sourceExternalId, + positionIndex: draft.positionIndex, + updatedAt: now, + }); + }); + + return recomputePositionIndex(result); +} + +/** + * Normalize a date string to the canonical `YYYY-MM` month format used by + * {@link Experience}. Accepts `YYYY-MM`, `YYYY-M`, `YYYY-MM-DD`, and `YYYY-M-D`. + * Unknown formats are returned trimmed (unchanged) so manual entries are not + * corrupted. Returns null for empty/whitespace input. + */ +export function normalizeDateToMonth(date: string | null | undefined): string | null { + if (!date) { + return null; + } + const trimmed = date.trim(); + if (trimmed.length === 0) { + return null; + } + const match = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?$/.exec(trimmed); + if (!match) { + return trimmed; + } + return `${match[1]}-${match[2].padStart(2, '0')}`; +} + +function experienceKey(company: string | null, title: string, startDate: string | null): string { + return `${(company ?? '').toLowerCase()}|${title.toLowerCase()}|${startDate ?? ''}`; +} + +function unionSkills(current: readonly string[], incoming: readonly string[]): string[] { + const result = [...current]; + for (const skill of incoming) { + const trimmed = skill.trim(); + if (trimmed.length === 0) { + continue; + } + if (!result.some((existing) => existing.toLowerCase() === trimmed.toLowerCase())) { + result.push(trimmed); + } + } + return result; +} + +function trimToNull(value: string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} 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 d3b19c43..377db203 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 @@ -1,5 +1,6 @@ import type { UserProfile } from '../types/profile'; import { appendUniqueNormalized, withProfileDefaults } from '../profile/normalize-profile'; +import { mergeExperiences } from '../cv/experience-helpers'; import type { CanonicalCandidateProfileDraft } from './types'; /** @@ -13,14 +14,20 @@ import type { CanonicalCandidateProfileDraft } from './types'; * 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 ''. + * - experiences ← {@link mergeExperiences} of the current experiences with the + * draft's experiences. Dedup by (company, title, startDate); manual entries + * are preserved, skills are unioned, positionIndex is recomputed. * - 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.*. + * The `now` timestamp is injected by the caller so merge results are deterministic + * under test. */ export function mergeCandidateProfileIntoUserProfile( current: UserProfile | null, - draft: CanonicalCandidateProfileDraft + draft: CanonicalCandidateProfileDraft, + now: number ): UserProfile { const base = withProfileDefaults({ ...current }); @@ -37,10 +44,13 @@ export function mergeCandidateProfileIntoUserProfile( })?.location ?? ''; const location = currentLocation.trim().length > 0 ? currentLocation : draftLocation; + const experiences = mergeExperiences(current?.experiences ?? [], draft.experiences, now); + return { ...base, jobTitle: draft.title, keywords, location, + experiences, }; } diff --git a/apps/extension/src/lib/core/profile/defaults.ts b/apps/extension/src/lib/core/profile/defaults.ts index fae9bb11..10a56e1d 100644 --- a/apps/extension/src/lib/core/profile/defaults.ts +++ b/apps/extension/src/lib/core/profile/defaults.ts @@ -19,6 +19,7 @@ const DEFAULT_PROFILE = { remote: 'any', seniority: 'senior', jobTitle: '', + experiences: [], scoringWeights: { stack: 0, location: 10, @@ -35,6 +36,7 @@ export function createDefaultProfile(): UserProfile { return { ...DEFAULT_PROFILE, keywords: [...DEFAULT_PROFILE.keywords], + experiences: [], scoringWeights: { ...DEFAULT_PROFILE.scoringWeights }, }; } @@ -49,6 +51,7 @@ export function isDefaultProfile(profile: UserProfile): boolean { profile.remote === DEFAULT_PROFILE.remote && profile.seniority === DEFAULT_PROFILE.seniority && profile.jobTitle === DEFAULT_PROFILE.jobTitle && + (profile.experiences ?? []).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 1ee5bccc..4fcf1052 100644 --- a/apps/extension/src/lib/core/profile/normalize-profile.ts +++ b/apps/extension/src/lib/core/profile/normalize-profile.ts @@ -1,4 +1,4 @@ -import type { UserProfile } from '../types/profile'; +import type { Experience, UserProfile } from '../types/profile'; export interface ProfileDraftInput { firstName?: string | null; @@ -11,6 +11,7 @@ export interface ProfileDraftInput { keywords?: readonly string[] | null; keywordInput?: string | null; scoringWeights?: UserProfile['scoringWeights']; + experiences?: readonly Experience[] | null; } export interface NormalizeProfileResult { @@ -64,6 +65,7 @@ export const withProfileDefaults = (profile: Partial): UserProfile seniority: profile.seniority ?? 'senior', jobTitle: profile.jobTitle ?? '', scoringWeights: profile.scoringWeights, + experiences: [...(profile.experiences ?? [])], }); export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfileResult { @@ -86,6 +88,7 @@ export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfil tjmMax, keywords: appendUniqueNormalized(input.keywords, input.keywordInput), scoringWeights: input.scoringWeights, + experiences: input.experiences ? [...input.experiences] : [], }), }; } diff --git a/apps/extension/src/lib/core/sync/connected-dashboard.ts b/apps/extension/src/lib/core/sync/connected-dashboard.ts index d96b57a1..40a526cc 100644 --- a/apps/extension/src/lib/core/sync/connected-dashboard.ts +++ b/apps/extension/src/lib/core/sync/connected-dashboard.ts @@ -422,6 +422,9 @@ export function remoteCandidateProfileToUserProfile( remote: snapshot.remote_preference ?? existingProfile?.remote ?? 'any', seniority: snapshot.seniority ?? existingProfile?.seniority ?? 'senior', jobTitle, + // The connected-dashboard snapshot does not carry CV experiences; preserve + // the user's existing entries so syncing the dashboard does not wipe them. + experiences: [...(existingProfile?.experiences ?? [])], scoringWeights: existingProfile?.scoringWeights ? { ...existingProfile.scoringWeights } : undefined, diff --git a/apps/extension/src/lib/core/types/profile.ts b/apps/extension/src/lib/core/types/profile.ts index 3158269e..3875e9b5 100644 --- a/apps/extension/src/lib/core/types/profile.ts +++ b/apps/extension/src/lib/core/types/profile.ts @@ -2,6 +2,40 @@ export type SeniorityLevel = 'junior' | 'confirmed' | 'senior'; import type { RemoteType } from './mission'; +/** + * Origin of a professional experience entry. + * - 'linkedin': imported from a LinkedIn profile extraction. + * - 'manual': created by hand in the CV tab. + * - 'connector-import': ingested from a mission platform profile. + */ +export type ExperienceSource = 'linkedin' | 'manual' | 'connector-import'; + +/** + * A single professional experience, persisted on {@link UserProfile.experiences}. + * + * Pure data: no methods, no I/O. Non-deterministic values (id, updatedAt) are + * injected by the shell when normalizing/merging. + */ +export interface Experience { + id: string; + title: string; + company: string | null; + location: string | null; + /** ISO month "YYYY-MM" or null when unknown. */ + startDate: string | null; + /** ISO month "YYYY-MM" or null when {@link isCurrent} is true. */ + endDate: string | null; + isCurrent: boolean; + description: string; + skills: string[]; + source: ExperienceSource; + sourceExternalId: string | null; + /** Gapless stable ordering (0 = most recent). Recomputed on every save. */ + positionIndex: number; + /** Epoch ms of the last edit. Injected by the shell. */ + updatedAt: number; +} + /** * Configurable weights for mission scoring. * Each weight is a number 0-100 representing the portion of the total score. @@ -42,4 +76,12 @@ export interface UserProfile { jobTitle: string; /** Optional custom scoring weights. Defaults to DEFAULT_SCORING_WEIGHTS if not provided. */ scoringWeights?: ScoringWeights; + /** + * Professional experiences, edited in the CV tab and pushed to LinkedIn and + * the mission connectors. The schema's `.default([])` and + * {@link withProfileDefaults} guarantee this is always present on parsed or + * constructed profiles; legacy stored records without it are normalized to + * `[]` by the migration registry and profile preprocessor. + */ + experiences: Experience[]; } diff --git a/apps/extension/src/lib/core/types/schemas.ts b/apps/extension/src/lib/core/types/schemas.ts index 667b28a2..3f054f98 100644 --- a/apps/extension/src/lib/core/types/schemas.ts +++ b/apps/extension/src/lib/core/types/schemas.ts @@ -178,6 +178,25 @@ export const UserProfileSchema = z seniority: SeniorityLevelSchema, jobTitle: z.string(), scoringWeights: ScoringWeightsSchema.optional(), + experiences: z + .array( + z.object({ + id: z.string(), + title: z.string(), + company: z.string().nullable(), + location: z.string().nullable(), + startDate: z.string().nullable(), + endDate: z.string().nullable(), + isCurrent: z.boolean(), + description: z.string(), + skills: z.array(z.string()), + source: z.enum(['linkedin', 'manual', 'connector-import']), + sourceExternalId: z.string().nullable(), + positionIndex: z.number().int().min(0), + updatedAt: z.number().int().min(0), + }) + ) + .default([]), }) ) .refine((p) => p.tjmMax === 0 || p.tjmMax >= p.tjmMin, { diff --git a/apps/extension/src/lib/shell/facades/cv-experience.facade.ts b/apps/extension/src/lib/shell/facades/cv-experience.facade.ts new file mode 100644 index 00000000..ad59d877 --- /dev/null +++ b/apps/extension/src/lib/shell/facades/cv-experience.facade.ts @@ -0,0 +1,89 @@ +/** + * CV experience facade — wires shell I/O (profile bridge, clipboard, tabs) into + * the {@link CvExperienceDeps} consumed by the runes store. + * + * The side panel never touches `chrome.*` or IndexedDB directly; everything + * crosses the service-worker bridge via `sendMessage`. Clipboard is the one + * browser API the side panel may call directly (it is the sync transport). + */ +import type { Experience } from '$lib/core/types/profile'; +import type { PlatformSyncTarget } from '$lib/core/cv/experience-helpers'; +import type { CvExperienceDeps } from '$lib/state/cv-experience.svelte'; +import { getProfile, saveProfile } from './settings.facade'; +import { getConnectorsMeta, openExternalUrl } from './feed-data.facade'; + +const LINKEDIN_PROFILE_URL = 'https://www.linkedin.com/in/'; + +/** Build the sync target list (LinkedIn + the 6 mission connectors). */ +export function getCvSyncTargets(): PlatformSyncTarget[] { + const connectors = getConnectorsMeta(); + return [ + { + id: 'linkedin', + name: 'LinkedIn', + profileUrl: LINKEDIN_PROFILE_URL, + }, + ...connectors.map((connector) => ({ + id: connector.id, + name: connector.name, + profileUrl: connector.url, + })), + ]; +} + +/** Default shell deps for the CV experience store. Mockable in tests. */ +export function createCvExperienceDeps(): CvExperienceDeps { + return { + async loadExperiences(): Promise { + const profile = await getProfile(); + return profile?.experiences ?? []; + }, + + async saveExperiences(experiences: Experience[]): Promise { + const current = await getProfile(); + const profile = { + ...(current ?? createBlankProfile()), + experiences, + }; + await saveProfile(profile); + }, + + async copyToClipboard(text: string): Promise { + if (!navigator?.clipboard) { + throw new Error('Presse-papiers indisponible dans ce contexte.'); + } + await navigator.clipboard.writeText(text); + }, + + async openUrl(url: string): Promise { + await openExternalUrl(url); + }, + + platforms: getCvSyncTargets(), + + now(): number { + return Date.now(); + }, + + generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `exp-${crypto.randomUUID()}`; + } + return `exp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + }, + }; +} + +function createBlankProfile() { + return { + firstName: '', + keywords: [] as string[], + tjmMin: 0, + tjmMax: 0, + location: '', + remote: 'any' as const, + seniority: 'senior' as const, + jobTitle: '', + experiences: [] as Experience[], + }; +} diff --git a/apps/extension/src/lib/shell/messaging/schemas.ts b/apps/extension/src/lib/shell/messaging/schemas.ts index 581888c5..8487a734 100644 --- a/apps/extension/src/lib/shell/messaging/schemas.ts +++ b/apps/extension/src/lib/shell/messaging/schemas.ts @@ -233,7 +233,7 @@ const ProfilePayloadSchema = z tjmMax: z.number().optional(), }) .passthrough() - .refine(maxBytes(10_240), { message: 'SAVE_PROFILE payload exceeds 10KB limit' }); + .refine(maxBytes(80_000), { message: 'SAVE_PROFILE payload exceeds 80KB limit' }); const LinkedInTabPayloadSchema = z.object({ tabId: z.number().int().positive().optional() }); diff --git a/apps/extension/src/lib/state/cv-experience.svelte.ts b/apps/extension/src/lib/state/cv-experience.svelte.ts new file mode 100644 index 00000000..0d175d26 --- /dev/null +++ b/apps/extension/src/lib/state/cv-experience.svelte.ts @@ -0,0 +1,425 @@ +/** + * CV experience feed + cross-platform sync store. + * + * Implements the three cooperating state machines defined in + * `apps/extension/src/models/cv-experience-sync.model.md`: + * - Feed: loading / ready / error + * - Edit: idle / adding / editing / saving / deleting / error + * - Sync: idle / preparing / syncing / cancelled / synced / partial / error + * + * The store is the Imperative Shell: it owns async + side effects and delegates + * all computation to pure helpers in `$lib/core/cv/experience-helpers`. The LLM + * never decides a transition here — it only produces signals (imported drafts) + * that the model merges via `mergeExperiences`. + * + * Svelte 5 runes only. + */ +import type { Experience } from '$lib/core/types/profile'; +import { + buildPlatformPayloads, + normalizeExperience, + recomputePositionIndex, + type PlatformSyncTarget, +} from '$lib/core/cv/experience-helpers'; + +export type FeedStatus = 'loading' | 'ready' | 'error'; +export type EditStatus = 'idle' | 'adding' | 'editing' | 'saving' | 'deleting' | 'error'; +export type SyncStatus = + | 'idle' + | 'preparing' + | 'syncing' + | 'cancelled' + | 'synced' + | 'partial' + | 'error'; +export type PlatformSyncStatus = + | 'pending' + | 'copying' + | 'done' + | 'error' + | 'auth-required' + | 'blocked' + | 'skipped'; + +export interface CvExperienceDeps { + loadExperiences(): Promise; + /** Persist the full experiences list to the user profile. */ + saveExperiences(experiences: Experience[]): Promise; + copyToClipboard(text: string): Promise; + openUrl(url: string): Promise; + platforms: PlatformSyncTarget[]; + now(): number; + generateId(): string; +} + +export interface CvExperienceStore { + // reactive snapshot + readonly experiences: Experience[]; + readonly feedStatus: FeedStatus; + readonly editStatus: EditStatus; + readonly draft: Experience | null; + readonly editingId: string | null; + readonly syncStatus: SyncStatus; + readonly platformStatuses: Map; + readonly lastSyncedAt: number | null; + readonly feedError: string | null; + readonly editError: string | null; + readonly syncError: string | null; + readonly canSync: boolean; + readonly isSyncing: boolean; + // feed + load(): void; + reload(): void; + applyProfileUpdate(experiences: Experience[]): void; + // edit + newExperience(): void; + editExperience(id: string): void; + cancelEdit(): void; + saveExperience(draft: Experience): void; + deleteExperience(id: string): void; + // sync + startSync(): void; + cancelSync(): void; +} + +const SYNC_COPY_DENIED = 'Coller le CV dans le presse-papiers a été refusé par le navigateur.'; +const SYNC_EMPTY = 'Aucune expérience à synchroniser. Ajoutez-en au moins une.'; + +export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceStore { + let experiences = $state([]); + let feedStatus = $state('loading'); + let editStatus = $state('idle'); + let draft = $state(null); + let editingId = $state(null); + let syncStatus = $state('idle'); + let platformStatuses = $state>(new Map()); + let lastSyncedAt = $state(null); + let feedError = $state(null); + let editError = $state(null); + let syncError = $state(null); + + const canSync = $derived(experiences.length > 0 && !isSyncBusy(syncStatus)); + const isSyncing = $derived(syncStatus === 'preparing' || syncStatus === 'syncing'); + + // Reads the live sync status without TS narrowing (cancelSync can mutate it + // across an await boundary, which the compiler's control-flow cannot see). + function readSyncStatus(): SyncStatus { + return syncStatus; + } + + function setPlatformStatus(id: string, status: PlatformSyncStatus): void { + const next = new Map(platformStatuses); + next.set(id, status); + platformStatuses = next; + } + + function resetPlatformStatuses(): void { + const next = new Map(); + for (const target of deps.platforms) { + next.set(target.id, 'pending'); + } + platformStatuses = next; + } + + // ── Feed machine ──────────────────────────────────────────────────────── + async function load(): Promise { + feedStatus = 'loading'; + feedError = null; + try { + const result = await deps.loadExperiences(); + experiences = recomputePositionIndex(result); + feedStatus = 'ready'; + } catch (err) { + feedError = errorMessage(err, 'Impossible de charger vos expériences.'); + feedStatus = 'error'; + } + } + + function reload(): void { + void load(); + } + + // ── Edit machine ──────────────────────────────────────────────────────── + function newExperience(): void { + if (editStatus !== 'idle' && editStatus !== 'error') { + return; // invariant 1: one edit session at a time + } + const now = deps.now(); + draft = normalizeExperience( + { + title: '', + company: null, + location: null, + startDate: null, + endDate: null, + isCurrent: false, + description: '', + skills: [], + source: 'manual', + sourceExternalId: null, + positionIndex: 0, + }, + now, + deps.generateId + ); + editingId = null; + editStatus = 'adding'; + editError = null; + } + + function editExperience(id: string): void { + if (editStatus !== 'idle' && editStatus !== 'error') { + return; // invariant 1 + } + const target = experiences.find((exp) => exp.id === id); + if (!target) { + return; + } + draft = { ...target, skills: [...target.skills] }; + editingId = id; + editStatus = 'editing'; + editError = null; + } + + function cancelEdit(): void { + if (editStatus !== 'adding' && editStatus !== 'editing' && editStatus !== 'error') { + return; + } + draft = null; + editingId = null; + editStatus = 'idle'; + editError = null; + } + + async function saveExperience(draftInput: Experience): Promise { + if (editStatus !== 'adding' && editStatus !== 'editing' && editStatus !== 'error') { + return; // invariant 2: no re-entrancy + } + const isNew = editingId === null; + const normalized = normalizeExperience( + { ...draftInput, id: draftInput.id || editingId || undefined }, + deps.now(), + deps.generateId + ); + draft = normalized; + editStatus = 'saving'; + editError = null; + try { + const next = isNew + ? [...experiences, normalized] + : experiences.map((exp) => (exp.id === normalized.id ? normalized : exp)); + const recomputed = recomputePositionIndex(next); + await deps.saveExperiences(recomputed); + experiences = recomputed; + draft = null; + editingId = null; + editStatus = 'idle'; + feedStatus = 'ready'; + } catch (err) { + editError = errorMessage(err, 'Impossible d’enregistrer l’expérience.'); + editStatus = 'error'; + } + } + + async function deleteExperience(id: string): Promise { + if (editStatus !== 'idle' && editStatus !== 'error') { + return; // invariant 2 + } + editingId = id; + editStatus = 'deleting'; + editError = null; + try { + const next = recomputePositionIndex(experiences.filter((exp) => exp.id !== id)); + await deps.saveExperiences(next); + experiences = next; + editingId = null; + editStatus = 'idle'; + feedStatus = 'ready'; + } catch (err) { + editError = errorMessage(err, 'Impossible de supprimer l’expérience.'); + editStatus = 'error'; + } + } + + // ── Sync machine ──────────────────────────────────────────────────────── + async function startSync(): Promise { + if (isSyncBusy(syncStatus)) { + return; // already running + } + if (experiences.length === 0) { + syncStatus = 'error'; + syncError = SYNC_EMPTY; + resetPlatformStatuses(); + return; + } + syncStatus = 'preparing'; + syncError = null; + resetPlatformStatuses(); + + // Pure prepare in core. + const payloads = buildPlatformPayloads(experiences, deps.platforms); + const sample = payloads.values().next().value ?? ''; + + // Global clipboard probe — fail fast if the browser denies write access. + try { + await deps.copyToClipboard(sample); + } catch { + for (const target of deps.platforms) { + setPlatformStatus(target.id, 'error'); + } + syncStatus = 'error'; + syncError = SYNC_COPY_DENIED; + return; + } + + // Cancellation may have landed during the clipboard probe. + if (readSyncStatus() === 'cancelled') { + for (const target of deps.platforms) { + setPlatformStatus(target.id, 'skipped'); + } + return; + } + + syncStatus = 'syncing'; + + let done = 0; + let failed = 0; + for (const target of deps.platforms) { + if (readSyncStatus() === 'cancelled') { + setPlatformStatus(target.id, 'skipped'); + continue; + } + setPlatformStatus(target.id, 'copying'); + try { + // Payload is already on the clipboard from the global probe above. + // Re-copying here would fail once the first opened tab steals focus + // (no clipboardWrite permission; navigator.clipboard needs transient + // activation), so we only open the profile URL and let the user paste. + await deps.openUrl(target.profileUrl); + setPlatformStatus(target.id, 'done'); + done += 1; + } catch { + setPlatformStatus(target.id, 'error'); + failed += 1; + } + } + + if (readSyncStatus() === 'cancelled') { + // already cancelled; keep cancelled status + return; + } + + if (done === deps.platforms.length) { + syncStatus = 'synced'; + lastSyncedAt = deps.now(); + } else if (done > 0) { + syncStatus = 'partial'; + lastSyncedAt = deps.now(); + } else { + syncStatus = 'error'; + } + + if (failed > 0 && done === 0) { + syncError = 'La synchronisation a échoué sur toutes les plateformes.'; + } + } + + function cancelSync(): void { + if (syncStatus !== 'syncing' && syncStatus !== 'preparing') { + return; + } + syncStatus = 'cancelled'; + // remaining platforms will be marked skipped by the loop + } + + // ── PROFILE_UPDATED (external merge) ──────────────────────────────────── + function applyProfileUpdate(incoming: Experience[]): void { + // invariant 3: dropped during in-flight save/delete/sync and active edit. + if ( + editStatus === 'adding' || + editStatus === 'editing' || + editStatus === 'saving' || + editStatus === 'deleting' || + syncStatus === 'preparing' || + syncStatus === 'syncing' + ) { + return; + } + experiences = recomputePositionIndex(incoming); + feedStatus = 'ready'; + feedError = null; + editError = null; + editStatus = 'idle'; + editingId = null; + draft = null; + // External update (e.g. LinkedIn import) invalidates prior sync state — + // reset the sync machine so stale statuses/lastSyncedAt don't persist. + syncStatus = 'idle'; + syncError = null; + lastSyncedAt = null; + platformStatuses = new Map(); + } + + return { + get experiences() { + return experiences; + }, + get feedStatus() { + return feedStatus; + }, + get editStatus() { + return editStatus; + }, + get draft() { + return draft; + }, + get editingId() { + return editingId; + }, + get syncStatus() { + return syncStatus; + }, + get platformStatuses() { + return platformStatuses; + }, + get lastSyncedAt() { + return lastSyncedAt; + }, + get feedError() { + return feedError; + }, + get editError() { + return editError; + }, + get syncError() { + return syncError; + }, + get canSync() { + return canSync; + }, + get isSyncing() { + return isSyncing; + }, + load, + reload, + applyProfileUpdate, + newExperience, + editExperience, + cancelEdit, + saveExperience, + deleteExperience, + startSync, + cancelSync, + }; +} + +function isSyncBusy(status: SyncStatus): boolean { + return status === 'preparing' || status === 'syncing'; +} + +function errorMessage(err: unknown, fallback: string): string { + if (err instanceof Error && err.message.length > 0) { + return err.message; + } + return fallback; +} diff --git a/apps/extension/src/lib/state/settings-page.svelte.ts b/apps/extension/src/lib/state/settings-page.svelte.ts index 506ec0a2..f5c1102d 100644 --- a/apps/extension/src/lib/state/settings-page.svelte.ts +++ b/apps/extension/src/lib/state/settings-page.svelte.ts @@ -389,6 +389,7 @@ export class SettingsPageController { remote: this.profileRemote, seniority: this.seniority, scoringWeights: current?.scoringWeights, + experiences: current?.experiences, }); if (!normalized.ok || !normalized.profile) { diff --git a/apps/extension/src/models/cv-experience-sync.model.md b/apps/extension/src/models/cv-experience-sync.model.md new file mode 100644 index 00000000..0206270c --- /dev/null +++ b/apps/extension/src/models/cv-experience-sync.model.md @@ -0,0 +1,289 @@ +# CV Experience & Sync Model + +Source of truth for the CV tab behavior: an editable feed of professional +experiences and a cross-platform sync that pushes them to LinkedIn and the +mission connectors. Modeled as three cooperating state machines in a Svelte 5 +runes module (`src/lib/state/cv-experience.svelte.ts`), per the project standard +(runes over XState — see `profile-state.model.md`). + +The LLM never decides a transition. It may propose adapted summary copy inside a +dedicated AI worker; the model decides whether that copy is accepted and when +sync runs. **Le LLM produit des signaux ; le modèle décide.** + +## Domain entities + +### Experience (canonical, persisted on `UserProfile.experiences`) + +```ts +interface Experience { + id: string; // `${idPrefix}-${positionIndex}` or generated UUID + title: string; // e.g. "Lead Frontend" + company: string | null; // e.g. "Acme" + location: string | null; + startDate: string | null; // ISO month "YYYY-MM" or null + endDate: string | null; // null when isCurrent + isCurrent: boolean; + description: string; + skills: string[]; + source: 'linkedin' | 'manual' | 'connector-import'; + sourceExternalId: string | null; + positionIndex: number; // gapless, stable ordering (0 = most recent) + updatedAt: number; // epoch ms (injected by shell) +} +``` + +`UserProfile` is extended with `experiences: Experience[]` (default `[]`). The +existing flat fields (jobTitle, stack, tjm, location, …) are unchanged; scoring +ignores `experiences`. + +### PlatformSyncTarget + +LinkedIn + the 6 mission connectors (`getConnectorsMeta()`). Each target has an +independent status during a sync run. + +```ts +type PlatformSyncStatus = + | 'pending' + | 'copying' // clipboard write in flight + | 'done' // copied + URL opened + | 'error' // clipboard denied or open failed + | 'auth-required' // platform page returned a login wall (verify path) + | 'blocked' // platform page unreadable + | 'skipped'; // user cancelled before this platform started +``` + +## Machines + +The store composes three machines. They share the experiences list but their +statuses are independent: editing an experience does not block sync, and a +running sync does not block editing (edits queue on the next save). + +### 1. Feed machine — `feedStatus` + +``` +loading ──LOAD_RESULT(ok)──► ready +loading ──LOAD_ERROR──► error +error ──RETRY──► loading +ready ──RELOAD──► loading +* ──PROFILE_UPDATED──► ready (experiences replaced, see Merge) +``` + +`FeedStatus = 'loading' | 'ready' | 'error'`. `ready` with 0 experiences renders +the empty state (first-run: "Importez LinkedIn ou ajoutez une expérience"). + +### 2. Edit machine — `editStatus` (one session at a time) + +``` +idle ──NEW────────► adding (draft = blank Experience) +idle ──EDIT(id)───► editing (draft = copy of experience) +adding ──CANCEL──► idle +editing ──CANCEL──► idle +adding ──SUBMIT──► saving +editing ──SUBMIT──► saving +saving ──SAVE_RESULT(ok)──► ready (draft committed, positionIndex recomputed) +saving ──SAVE_ERROR──► error (draft retained for RETRY) +error ──RETRY──► saving (guard: hasDraft) +error ──CANCEL──► idle +idle ──DELETE(id)──► deleting +deleting──DELETE_RESULT(ok)──► ready +deleting──DELETE_ERROR──► error +``` + +`EditStatus = 'idle' | 'adding' | 'editing' | 'saving' | 'deleting' | 'error'`. + +### 3. Sync machine — `syncStatus` + +``` +idle ──SYNC_START──► preparing +preparing ──PREPARE_DONE──► syncing (per-platform: pending) +preparing ──PREPARE_ERROR──► error (e.g. no experiences to push) +syncing ──PLATFORM_START(id)──► syncing (platform → copying) +syncing ──PLATFORM_DONE(id)──► syncing (platform → done) +syncing ──PLATFORM_ERROR(id)──► syncing (platform → error|auth-required|blocked) +syncing ──SYNC_CANCEL──► cancelled (remaining platforms → skipped) +syncing ──ALL_SETTLED──► synced | partial | error + (synced: all done; partial: ≥1 done, ≥1 error; + error: 0 done) +cancelled ──SYNC_START──► preparing +synced|partial|error ──SYNC_START──► preparing +``` + +`SyncStatus = 'idle' | 'preparing' | 'syncing' | 'cancelled' | 'synced' | 'partial' | 'error'`. + +Per-platform status lives in `Map` and is only +meaningful while `syncStatus` is `syncing`/terminal. It resets on `SYNC_START`. + +## Context + +```ts +interface CvExperienceContext { + experiences: Experience[]; // canonical, persisted + feedStatus: FeedStatus; + editStatus: EditStatus; + draft: Experience | null; // non-null in adding/editing/saving/error + editingId: string | null; // which experience is open (null for adding) + syncStatus: SyncStatus; + platformStatuses: Map; + lastSyncedAt: number | null; // epoch ms + feedError: string | null; // feed machine error copy + editError: string | null; // edit machine error copy + syncError: string | null; // sync machine error copy +} +``` + +## Events + +```ts +type CvEvent = + // Feed + | { type: 'LOAD' } + | { type: 'RELOAD' } + | { type: 'RETRY' } + | { type: 'PROFILE_UPDATED'; experiences: Experience[] } + // Edit + | { type: 'NEW' } + | { type: 'EDIT'; id: string } + | { type: 'CANCEL' } + | { type: 'SUBMIT'; draft: Experience } + | { type: 'DELETE'; id: string } + | { type: 'SAVE_RESULT'; experience: Experience } + | { type: 'SAVE_ERROR'; message: string } + | { type: 'DELETE_RESULT'; id: string } + | { type: 'DELETE_ERROR'; message: string } + // Sync + | { type: 'SYNC_START' } + | { type: 'PREPARE_DONE'; payloads: Map } + | { type: 'PREPARE_ERROR'; message: string } + | { type: 'PLATFORM_START'; id: string } + | { type: 'PLATFORM_DONE'; id: string } + | { type: 'PLATFORM_ERROR'; id: string; status: 'error' | 'auth-required' | 'blocked' } + | { type: 'SYNC_CANCEL' } + | { type: 'ALL_SETTLED' }; +``` + +## Transition table (edit + sync; feed is trivial above) + +| From \ Event | NEW | EDIT | CANCEL | SUBMIT | SAVE_RESULT | SAVE_ERROR | DELETE | DELETE_RESULT | DELETE_ERROR | SYNC_START | PROFILE_UPDATED | +| -------------- | ------- | ------- | ------- | ---------- | ----------- | ---------- | -------- | ------------- | ------------ | ---------- | --------------- | +| `idle` | adding | editing | - | - | - | - | deleting | - | - | (sync) | ready (merge) | +| `adding` | - | - | idle | saving | - | - | - | - | - | allowed\* | dropped | +| `editing` | - | - | idle | saving | - | - | - | - | - | allowed\* | dropped | +| `saving` | ignored | ignored | ignored | ignored | ready | error | ignored | - | - | ignored | dropped | +| `deleting` | ignored | ignored | ignored | ignored | - | - | ignored | ready | error | ignored | dropped | +| `error` (edit) | adding | editing | idle | saving\*\* | - | - | deleting | - | - | allowed\* | ready (merge) | + +\* `SYNC_START` is allowed while an edit session is open (edit draft is +preserved; sync operates on the committed experiences list, not the draft). The +two machines are independent. + +\*\* `SUBMIT` from `error` re-enters `saving` with the retained `draft` +(equivalent to RETRY for the edit machine). + +| From \ Event (sync) | SYNC_START | PREPARE_DONE | PLATFORM\_\* | SYNC_CANCEL | ALL_SETTLED | PROFILE_UPDATED | +| ------------------- | ---------- | ------------ | ------------ | ----------- | ----------- | --------------- | +| `idle` | preparing | - | - | - | - | idle (merge) | +| `preparing` | ignored | syncing | - | idle | - | dropped | +| `syncing` | ignored | - | updates pm | cancelled | terminal | dropped | +| `cancelled` | preparing | - | - | - | - | idle (merge) | +| `synced/partial/er` | preparing | - | - | - | - | idle (merge) | + +## Side effects (shell — `deps`) + +- **Enter `loading`**: `deps.loadExperiences()` → reads `UserProfile.experiences` + via the profile bridge. Resolves → `ready`; rejects → `error`. +- **Enter `saving`** (SUBMIT): `deps.saveExperience(draft)`. + - New (`editingId === null`): append, assign `positionIndex = 0`, shift others + +1, persist. Resolves → `SAVE_RESULT(experience)`. + - Existing: replace in place, keep `positionIndex`, bump `updatedAt`. Resolves + → `SAVE_RESULT(experience)`. + - Rejects → `SAVE_ERROR(message)`, `draft` retained. +- **Enter `deleting`** (DELETE): `deps.deleteExperience(id)`. Removes, recomputes + gapless `positionIndex`, persists. Resolves → `DELETE_RESULT(id)`; rejects → + `DELETE_ERROR`. +- **Enter `preparing`** (SYNC_START): pure `buildPlatformPayloads(experiences, +targets)` in core. If `experiences.length === 0` → `PREPARE_ERROR`. Else → + `PREPARE_DONE(payloads)`. +- **Enter `syncing`** (PREPARE_DONE): `deps.pushAll(payloads)` iterates targets. + Clipboard write is attempted **once globally** before any platform opens: + - Clipboard denied → every platform → `error`, `ALL_SETTLED` → `error`. + - Else, per platform: `PLATFORM_START` → `deps.copy(payload)` + `deps.open(url)` + → `PLATFORM_DONE` (or `PLATFORM_ERROR` if open rejects). Platforms run + sequentially (one clipboard write at a time) so the user can paste in each + opened tab before the next. +- **`PROFILE_UPDATED`** (LinkedIn re-import or external profile save): + `mergeExperiences(current, incoming)` (pure, core) dedups by + `(company, title, startDate)` case-insensitively, keeping the local copy's + `id`/`positionIndex`/`description` edits and unioning `skills`. Manual entries + (`source: 'manual'`) are never overwritten by an import, only supplemented. + +## Invariants + +1. At most one edit session: `NEW`/`EDIT` are accepted only from `idle` or + `error`. `adding`/`editing`/`saving`/`deleting` ignore them. +2. `saving`/`deleting` ignore all events until settled (no re-entrancy). +3. `PROFILE_UPDATED` during `saving`/`deleting`/`syncing` is **dropped** (never + clobbers an in-flight op). It is applied only from `idle`/`ready`/terminal. +4. During `syncing`, one platform's failure never aborts the others (except a + global clipboard denial, which fails all before any tab opens). +5. `positionIndex` is gapless and unique after every save/delete (recomputed). +6. An experience with `isCurrent: true` has `endDate === null`. The form enforces + this; `SAVE_RESULT` normalizes it. +7. `syncStatus` terminal state (`synced`/`partial`/`error`) is derived from the + per-platform statuses at `ALL_SETTLED`, not stored independently of them. +8. The sync operates on the **committed** experiences list, never on an unsaved + `draft`. An open edit draft does not participate in sync. +9. Error slots are per-machine (`feedError`/`editError`/`syncError`): a failure in + one machine never overwrites another machine's terminal error copy. + +## Public API (consumed by CvPage) + +```ts +createCvExperienceStore(deps): { + // reactive snapshot + experiences: Experience[]; + feedStatus: FeedStatus; + editStatus: EditStatus; + draft: Experience | null; + editingId: string | null; + syncStatus: SyncStatus; + platformStatuses: Map; + lastSyncedAt: number | null; + feedError: string | null; + editError: string | null; + syncError: string | null; + // feed + load(): void; + // edit + newExperience(): void; + editExperience(id: string): void; + cancelEdit(): void; + saveExperience(draft: Experience): void; + deleteExperience(id: string): void; + // sync + startSync(): void; + cancelSync(): void; +} +``` + +`deps` (shell-injected, mockable in tests): + +```ts +interface CvExperienceDeps { + loadExperiences(): Promise; + saveExperience(draft: Experience, isNew: boolean): Promise; + deleteExperience(id: string): Promise; + copyToClipboard(text: string): Promise; + openUrl(url: string): Promise; + platforms: PlatformSyncTarget[]; + now(): number; + idPrefix: string; +} +``` + +## Pure helpers (core, unit-tested without mocks) + +- `buildPlatformPayloads(experiences, targets): Map` — formats the + experiences into a per-platform text block. +- `mergeExperiences(current, incoming): Experience[]` — dedup + supplement. +- `recomputePositionIndex(experiences): Experience[]` — gapless ordering. +- `normalizeExperience(draft): Experience` — enforces isCurrent↔endDate, trims. diff --git a/apps/extension/src/ui/molecules/ExperienceCard.svelte b/apps/extension/src/ui/molecules/ExperienceCard.svelte new file mode 100644 index 00000000..d305404c --- /dev/null +++ b/apps/extension/src/ui/molecules/ExperienceCard.svelte @@ -0,0 +1,152 @@ + + +
+ {#if isEditing && draft} +
+
+ + + {experience.title ? 'Modifier l’expérience' : 'Nouvelle expérience'} + +
+ +
+ {:else} +
+ + +
+ + +
+
+ + {#if expanded && hasDetails} +
+ {#if experience.description} +

+ {experience.description} +

+ {/if} + {#if experience.skills.length > 0} +
+ {#each experience.skills as skill (skill)} + + {/each} +
+ {/if} +
+ {/if} + +
+ {sourceLabel} + {#if isBusy} + + + Enregistrement… + + {/if} +
+ {/if} +
diff --git a/apps/extension/src/ui/molecules/ExperienceEditForm.svelte b/apps/extension/src/ui/molecules/ExperienceEditForm.svelte new file mode 100644 index 00000000..bd4c5c5c --- /dev/null +++ b/apps/extension/src/ui/molecules/ExperienceEditForm.svelte @@ -0,0 +1,195 @@ + + +
{ + event.preventDefault(); + handleSubmit(); + }} + class="space-y-3" +> +
+ + +
+ +
+ + + +
+ + + + + + + +
+ + +
+
diff --git a/apps/extension/src/ui/organisms/CvSyncPanel.svelte b/apps/extension/src/ui/organisms/CvSyncPanel.svelte new file mode 100644 index 00000000..6ef650c9 --- /dev/null +++ b/apps/extension/src/ui/organisms/CvSyncPanel.svelte @@ -0,0 +1,132 @@ + + +
+
+
+

Synchronisation du CV

+

+ Copie le bloc CV dans le presse-papiers puis ouvre chaque plateforme pour collage manuel. +

+
+ {#if isRunning} + + {:else} + + {/if} +
+ + {#if headline} +
+ + {headline} +
+ {/if} + + {#if isRunning || (store.platformStatuses.size > 0 && store.syncStatus !== 'idle')} +
    + {#each platforms as platform (platform.id)} + {@const meta = statusMeta(store.platformStatuses.get(platform.id))} +
  • + {platform.name} + + {#if meta.icon} + + {:else} + + {/if} + {meta.label} + +
  • + {/each} +
+ {/if} +
diff --git a/apps/extension/src/ui/organisms/ExperienceFeed.svelte b/apps/extension/src/ui/organisms/ExperienceFeed.svelte new file mode 100644 index 00000000..1dec85e0 --- /dev/null +++ b/apps/extension/src/ui/organisms/ExperienceFeed.svelte @@ -0,0 +1,151 @@ + + +
+
+
+

Expériences

+ + {store.experiences.length} + {store.experiences.length > 1 ? 'entrées' : 'entrée'} + +
+ +
+ + {#if isLoading} +
+ Chargement de vos expériences… + {#each Array(3) as _} +
+ + +
+ + +
+
+ {/each} +
+ {:else if hasError} + store.reload()} + /> + {:else if store.experiences.length === 0 && !isAdding} + store.newExperience()} + /> + {:else} + {#if showEditError} + + {/if} + + {#if isAdding} +
+ store.cancelEdit()} + /> +
+ {/if} + + {#each store.experiences as experience, i (experience.id)} +
+ store.editExperience(experience.id)} + onDelete={() => store.deleteExperience(experience.id)} + onSave={handleSave} + onCancelEdit={() => store.cancelEdit()} + /> +
+ {/each} + + {#if store.experiences.length > 0} +

Tri du plus récent au plus ancien

+ {/if} + {/if} +
diff --git a/apps/extension/src/ui/pages/CvPage.svelte b/apps/extension/src/ui/pages/CvPage.svelte index 6b6f267a..f9771504 100644 --- a/apps/extension/src/ui/pages/CvPage.svelte +++ b/apps/extension/src/ui/pages/CvPage.svelte @@ -1,1015 +1,107 @@ -
-
-
+
+ {#if isOffline} + + {/if} + +
+
-

Source canonique

-
-

Préparer le même profil partout

- - Local prêt - -
-

- MissionPulse prépare une version claire de votre profil pour LinkedIn et les plateformes - de mission connectées. +

CV & expériences

+

+ La source canonique de votre parcours. Chaque expérience renseignée ici est synchronisable + vers vos plateformes connectées pour garder le même profil partout.

-

- La préparation reste locale; le dashboard connecté prend le relais après connexion du - compte. -

-
-
-
-
- -
-
-
-
- {profileCompleteness}% -
- -
- { - if (cvStory.primaryActionIcon === 'upload') { - pushAll(); - return; - } - previewLinkedIn(); - }} - /> -
- -
- {#each cvWorkflowSteps as step} -
+
- {/each} -
- - {#if isOffline} -
- + + {isImporting ? 'Import…' : 'Importer LinkedIn'} + + {#if onNavigateToProfile} + + {/if}
- {/if} -
- - {#if isLoading} -
-
-
-
- - - -
- -
-
- - - -
-
-
-
-

- Chargement CV -

-

Préparation du workflow CV

-

- Pulse récupère le profil de référence, les plateformes disponibles et les dernières - vérifications avant d’afficher les actions. -

-
-
- {#each loadingProgressSteps as step} -
-
- -
-
-

{step.label}

-

{step.detail}

-
-
- {/each} -
-
- - - - -
-
- {:else} -
-
- {#if !profile} - - {/if} - -
-
-
-

Profil source

-

Champs utilisés pour l'homogénéisation.

-
- -
- -
- {#each syncFields as field} - - {/each} -
-
- -
-

Bloc à copier

-
{selectedPayload}
- -
- - {#if linkedInPreviewResult} -
-
-
- -
-
-

Preview LinkedIn

- {#if linkedInPreviewResult.extracted} -

- {linkedInPreviewResult.profile.title || 'Titre non renseigné'} · - {linkedInPreviewResult.profile.experiences.length} expérience(s), - {linkedInPreviewResult.profile.skills.length} compétence(s), - {linkedInPreviewResult.profile.education.length} formation(s). -

- {#if linkedInPreviewResult.profile.summary} -

- {linkedInPreviewResult.profile.summary} -

- {/if} -
- {#each linkedInPreviewResult.profile.skills.slice(0, 8) as skill} - - {skill.skill} - - {/each} -
-
- - -
- {:else} -

- {linkedInPreviewResult.errorCode}: {linkedInPreviewResult.errorMessage} -

-

- {getLinkedInRecoveryHint(linkedInPreviewResult.errorCode)} -

- {/if} -
-
-
- {/if} - - {#if linkedInImportResult} -
-
-
- -
-
-

Import LinkedIn

- {#if linkedInImportResult.imported} -

- Profil LinkedIn enregistré comme profil de référence. Le dashboard connecté - affichera l'import, les suggestions de champs et l'historique LinkedIn. -

- {:else} -

- {linkedInImportResult.errorCode}: {linkedInImportResult.errorMessage} -

-

- {getLinkedInRecoveryHint(linkedInImportResult.errorCode)} -

- {/if} -
-
-
- {/if} - - {#if selectedVerification} -
-
-
-

Vérification

-

- {selectedVerification.read.finalUrl} -

-
- - {getVerificationLabel(selectedVerification)} - -
- - {#if selectedVerification.read.status !== 'available'} -

- La page renvoie une connexion ou un contenu non exploitable depuis l'extension. - Ouvrez la plateforme puis relancez la vérification. -

- {:else} -
- {#each getComparisonRows(selectedVerification) as row} -
-
-

{row.label}

-

- {row.expected || 'A compléter'} -

-
- - {row.status === 'match' - ? 'OK' - : row.status === 'mismatch' - ? 'Écart' - : 'Manquant'} - -
- {/each} -
- {/if} -
- {/if} -
- -
-
-

Plateformes

- {platforms.length} -
+
-
- {#each platforms as platform} -
- + -
- - - - - -
-
- {/each} -
-
-
- {/if} + diff --git a/apps/extension/tests/e2e/linkedin-import.test.ts b/apps/extension/tests/e2e/linkedin-import.test.ts index ca28631d..4c5177ee 100644 --- a/apps/extension/tests/e2e/linkedin-import.test.ts +++ b/apps/extension/tests/e2e/linkedin-import.test.ts @@ -71,13 +71,13 @@ async function mockAuthenticatedLinkedInBridge(page: Page, mode: LinkedInBridgeM } chromeApi.runtime.sendMessage = async (message) => { - if (message.type === 'PREVIEW_LINKEDIN_PROFILE') { + if (message.type === 'IMPORT_LINKEDIN_PROFILE') { if (bridgeMode !== 'success') { const error = previewErrors[bridgeMode]; return { - type: 'LINKEDIN_PROFILE_PREVIEWED', + type: 'LINKEDIN_PROFILE_IMPORTED', payload: { - extracted: false, + imported: false, errorCode: error.errorCode, errorMessage: error.errorMessage, }, @@ -85,8 +85,8 @@ async function mockAuthenticatedLinkedInBridge(page: Page, mode: LinkedInBridgeM } return { - type: 'LINKEDIN_PROFILE_PREVIEWED', - payload: { extracted: true, profile }, + type: 'LINKEDIN_PROFILE_IMPORTED', + payload: { imported: true, profile }, }; } @@ -112,57 +112,39 @@ async function openCvPage(page: Page) { await expect(nav).toBeVisible(); await expect(nav.getByRole('button', { name: 'CV' })).toBeVisible(); await nav.getByRole('button', { name: 'CV' }).click(); - // The CV hero heading drifted to "Préparer le même profil partout". - await expect( - page.getByRole('heading', { name: 'Préparer le même profil partout' }) - ).toBeVisible(); + // The CV page was redesigned in PR #198 with a new heading. + await expect(page.getByRole('heading', { name: 'CV & expériences' })).toBeVisible(); } test.describe('LinkedIn profile import flow', () => { - test('previews a LinkedIn profile before saving it as the canonical source', async ({ page }) => { + test('imports LinkedIn profile and syncs experiences', async ({ page }) => { await mockAuthenticatedLinkedInBridge(page, 'success'); await openCvPage(page); - await page.getByRole('button', { name: 'Prévisualiser LinkedIn' }).click(); - - await expect(page.getByRole('heading', { name: 'Preview LinkedIn' })).toBeVisible(); - await expect(page.getByText('Consultant Svelte senior')).toBeVisible(); - await expect(page.getByText('Architecture Svelte, TypeScript')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Enregistrer comme source' })).toBeVisible(); + // The new CV page has a direct "Importer LinkedIn" button + await page.getByRole('button', { name: 'Importer LinkedIn' }).click(); - await page.getByRole('button', { name: 'Enregistrer comme source' }).click(); - - await expect(page.getByRole('heading', { name: 'Import LinkedIn' })).toBeVisible(); - await expect( - page.getByText('Profil LinkedIn enregistré comme profil de référence.') - ).toBeVisible(); + // After successful import, a success toast appears + await expect(page.getByText('Expériences LinkedIn importées avec succès.')).toBeVisible(); }); - test('shows typed LinkedIn preview errors without saving the source', async ({ page }) => { + test('shows typed LinkedIn import errors in toast', async ({ page }) => { await mockAuthenticatedLinkedInBridge(page, 'session-required'); await openCvPage(page); - await page.getByRole('button', { name: 'Prévisualiser LinkedIn' }).click(); + await page.getByRole('button', { name: 'Importer LinkedIn' }).click(); - await expect(page.getByRole('heading', { name: 'Preview LinkedIn' })).toBeVisible(); - await expect(page.getByText('session_required: Session LinkedIn requise.')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Enregistrer comme source' })).not.toBeVisible(); + // Error messages now appear as toast notifications + await expect(page.getByText('Session LinkedIn requise.')).toBeVisible(); }); - test('shows recovery guidance for missing LinkedIn permissions', async ({ page }) => { + test('shows recovery guidance for missing LinkedIn permissions in toast', async ({ page }) => { await mockAuthenticatedLinkedInBridge(page, 'permission-required'); await openCvPage(page); - await page.getByRole('button', { name: 'Prévisualiser LinkedIn' }).click(); - - await expect(page.getByRole('heading', { name: 'Preview LinkedIn' })).toBeVisible(); - await expect( - page.getByText('permission_required: Autorisation LinkedIn refusée.') - ).toBeVisible(); - await expect( - // The hint uses a typographic apostrophe (l'aperçu); match the unique apostrophe-free portion. - page.getByText(/Autorisez l.+accès LinkedIn dans Chrome, puis relancez/) - ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Enregistrer comme source' })).not.toBeVisible(); + await page.getByRole('button', { name: 'Importer LinkedIn' }).click(); + + // Permission errors appear as toast notifications + await expect(page.getByText('Autorisation LinkedIn refusée.')).toBeVisible(); }); }); diff --git a/apps/extension/tests/e2e/settings.test.ts b/apps/extension/tests/e2e/settings.test.ts index 106bbb6a..b4eb0ebb 100644 --- a/apps/extension/tests/e2e/settings.test.ts +++ b/apps/extension/tests/e2e/settings.test.ts @@ -20,7 +20,9 @@ test.describe('Settings Flow', () => { await expect( page.getByRole('heading', { name: /Votre profil MissionPulse|Bonjour/ }) ).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Profil' })).toBeVisible(); + // Use exact: true to match only the "Profil" heading in ProfileSection, + // not other headings that contain "profil" as a substring + await expect(page.getByRole('heading', { name: 'Profil', exact: true })).toBeVisible(); }); test('profile tab edit mode shows form fields', async ({ page }) => { diff --git a/apps/extension/tests/unit/cv/experience-helpers.test.ts b/apps/extension/tests/unit/cv/experience-helpers.test.ts new file mode 100644 index 00000000..577d33b4 --- /dev/null +++ b/apps/extension/tests/unit/cv/experience-helpers.test.ts @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Experience } from '../../../src/lib/core/types/profile'; +import type { CandidateExperienceDraft } from '../../../src/lib/core/profile-extractors/types'; +import { + buildPlatformPayloads, + formatExperienceDateRange, + formatExperiencePayload, + mergeExperiences, + normalizeDateToMonth, + normalizeExperience, + recomputePositionIndex, +} from '../../../src/lib/core/cv/experience-helpers'; + +const NOW = 1_700_000_000_000; +let idCounter = 0; +const generateId = () => `id-${NOW}-${idCounter++}`; + +beforeEach(() => { + idCounter = 0; +}); + +function baseExperience(overrides: Partial = {}): Experience { + return { + id: 'exp-1', + title: 'Lead Frontend', + company: 'Acme', + location: 'Paris', + startDate: '2023-01', + endDate: null, + isCurrent: true, + description: 'Plateforme Svelte.', + skills: ['Svelte', 'TypeScript'], + source: 'manual', + sourceExternalId: null, + positionIndex: 0, + updatedAt: NOW, + ...overrides, + }; +} + +function draft(overrides: Partial = {}): CandidateExperienceDraft { + return { + title: 'Lead Frontend', + company: 'Acme', + location: 'Paris', + startDate: '2023-01', + endDate: null, + isCurrent: true, + description: 'Plateforme Svelte.', + skills: ['Svelte'], + source: 'linkedin', + sourceExternalId: null, + positionIndex: 0, + ...overrides, + }; +} + +describe('normalizeExperience', () => { + it('trims text and enforces isCurrent ↔ endDate === null', () => { + const result = normalizeExperience( + { + title: ' Lead ', + company: ' Acme ', + location: ' Paris ', + startDate: ' 2023-01 ', + endDate: '2024-01', + isCurrent: true, + description: ' Text ', + skills: [' Svelte ', 'TypeScript'], + source: 'manual', + }, + NOW, + generateId + ); + + expect(result.title).toBe('Lead'); + expect(result.company).toBe('Acme'); + expect(result.location).toBe('Paris'); + expect(result.startDate).toBe('2023-01'); + expect(result.endDate).toBeNull(); + expect(result.isCurrent).toBe(true); + expect(result.description).toBe('Text'); + expect(result.skills).toEqual(['Svelte', 'TypeScript']); + }); + + it('keeps endDate when isCurrent is false', () => { + const result = normalizeExperience( + { title: 'Dev', company: 'Co', startDate: '2020-01', endDate: '2021-01', isCurrent: false }, + NOW, + generateId + ); + expect(result.endDate).toBe('2021-01'); + }); + + it('generates an id when none is provided', () => { + const result = normalizeExperience({ title: 'Dev', company: 'Co' }, NOW, generateId); + expect(result.id).toBe(`id-${NOW}-0`); + }); + + it('preserves an existing id and defaults source to manual', () => { + const result = normalizeExperience( + { id: 'keep-1', title: 'Dev', company: 'Co' }, + NOW, + generateId + ); + expect(result.id).toBe('keep-1'); + expect(result.source).toBe('manual'); + }); + + it('coerces empty strings to null for nullable fields', () => { + const result = normalizeExperience( + { title: 'Dev', company: ' ', location: '', startDate: '' }, + NOW, + generateId + ); + expect(result.company).toBeNull(); + expect(result.location).toBeNull(); + expect(result.startDate).toBeNull(); + }); +}); + +describe('recomputePositionIndex', () => { + it('assigns gapless 0-based indices sorted by descending start date', () => { + const experiences = [ + baseExperience({ id: 'a', startDate: '2020-01', positionIndex: 5 }), + baseExperience({ id: 'b', startDate: '2023-01', positionIndex: 2 }), + baseExperience({ id: 'c', startDate: '2022-01', positionIndex: 9 }), + ]; + + const result = recomputePositionIndex(experiences); + expect(result.map((e) => [e.id, e.positionIndex])).toEqual([ + ['b', 0], + ['c', 1], + ['a', 2], + ]); + }); + + it('stably orders entries with the same start date', () => { + const experiences = [ + baseExperience({ id: 'a', startDate: '2023-01' }), + baseExperience({ id: 'b', startDate: '2023-01' }), + ]; + const result = recomputePositionIndex(experiences); + expect(result.map((e) => e.id)).toEqual(['a', 'b']); + expect(result.map((e) => e.positionIndex)).toEqual([0, 1]); + }); + + it('places null start dates last', () => { + const experiences = [ + baseExperience({ id: 'a', startDate: null }), + baseExperience({ id: 'b', startDate: '2023-01' }), + ]; + const result = recomputePositionIndex(experiences); + expect(result.map((e) => e.id)).toEqual(['b', 'a']); + }); +}); + +describe('mergeExperiences', () => { + it('adds new draft experiences as linkedin-sourced entries', () => { + const result = mergeExperiences( + [], + [draft({ title: 'Dev', company: 'Co', startDate: '2020-01' })], + NOW + ); + expect(result).toHaveLength(1); + expect(result[0].source).toBe('linkedin'); + expect(result[0].title).toBe('Dev'); + expect(result[0].id).toBe(`exp-${NOW}-0`); + expect(result[0].updatedAt).toBe(NOW); + }); + + it('dedups by (company, title, startDate) case-insensitively', () => { + const current = [ + baseExperience({ + id: 'local-1', + title: 'Lead Frontend', + company: 'Acme', + startDate: '2023-01', + }), + ]; + const incoming = [draft({ title: 'LEAD frontend', company: 'acme', startDate: '2023-01' })]; + + const result = mergeExperiences(current, incoming, NOW); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('local-1'); + }); + + it('unions skills on match and keeps local description when manual', () => { + const current = [ + baseExperience({ + id: 'local-1', + title: 'Lead', + company: 'Acme', + startDate: '2023-01', + skills: ['Svelte'], + description: 'Local desc.', + source: 'manual', + }), + ]; + const incoming = [ + draft({ + title: 'Lead', + company: 'Acme', + startDate: '2023-01', + skills: ['Svelte', 'React'], + description: 'Draft desc.', + }), + ]; + + const result = mergeExperiences(current, incoming, NOW); + expect(result[0].skills).toEqual(['Svelte', 'React']); + expect(result[0].description).toBe('Local desc.'); + }); + + it('overwrites description with draft when local source is not manual', () => { + const current = [ + baseExperience({ + id: 'local-1', + title: 'Lead', + company: 'Acme', + startDate: '2023-01', + description: 'Local desc.', + source: 'linkedin', + }), + ]; + const incoming = [ + draft({ title: 'Lead', company: 'Acme', startDate: '2023-01', description: 'New desc.' }), + ]; + + const result = mergeExperiences(current, incoming, NOW); + expect(result[0].description).toBe('New desc.'); + }); + + it('recomputes gapless position indices after merge', () => { + const current = [baseExperience({ id: 'a', startDate: '2020-01' })]; + const incoming = [draft({ title: 'Dev', company: 'Co', startDate: '2023-01' })]; + + const result = mergeExperiences(current, incoming, NOW); + expect(result.map((e) => [e.id, e.positionIndex])).toEqual([ + [expect.stringContaining(`exp-${NOW}`), 0], + ['a', 1], + ]); + }); + + it('normalizes YYYY-MM-DD imported dates to YYYY-MM', () => { + const result = mergeExperiences( + [], + [ + draft({ + title: 'Dev', + company: 'Co', + startDate: '2023-01-15', + endDate: '2024-06-30', + isCurrent: false, + }), + ], + NOW + ); + expect(result[0].startDate).toBe('2023-01'); + expect(result[0].endDate).toBe('2024-06'); + }); + + it('clears endDate when a merged entry becomes current', () => { + const current = [ + baseExperience({ + id: 'local-1', + title: 'Lead', + company: 'Acme', + startDate: '2023-01', + endDate: '2024-01', + isCurrent: false, + }), + ]; + const incoming = [ + draft({ title: 'Lead', company: 'Acme', startDate: '2023-01', isCurrent: true }), + ]; + + const result = mergeExperiences(current, incoming, NOW); + expect(result[0].isCurrent).toBe(true); + expect(result[0].endDate).toBeNull(); + }); +}); + +describe('normalizeDateToMonth', () => { + it('normalizes YYYY-MM-DD to YYYY-MM', () => { + expect(normalizeDateToMonth('2023-01-15')).toBe('2023-01'); + }); + + it('pads single-digit months', () => { + expect(normalizeDateToMonth('2023-6-5')).toBe('2023-06'); + }); + + it('leaves already-canonical YYYY-MM unchanged', () => { + expect(normalizeDateToMonth('2023-01')).toBe('2023-01'); + }); + + it('returns null for empty or whitespace input', () => { + expect(normalizeDateToMonth('')).toBeNull(); + expect(normalizeDateToMonth(' ')).toBeNull(); + expect(normalizeDateToMonth(null)).toBeNull(); + expect(normalizeDateToMonth(undefined)).toBeNull(); + }); + + it('returns unknown formats trimmed rather than corrupting them', () => { + expect(normalizeDateToMonth('Jan 2023')).toBe('Jan 2023'); + }); +}); + +describe('formatExperienceDateRange', () => { + it('returns empty when no start date', () => { + expect(formatExperienceDateRange({ startDate: null, endDate: null, isCurrent: false })).toBe( + '' + ); + }); + + it('appends présent when isCurrent', () => { + expect( + formatExperienceDateRange({ startDate: '2023-01', endDate: null, isCurrent: true }) + ).toBe('2023-01 — présent'); + }); + + it('shows start — end when not current and end is present', () => { + expect( + formatExperienceDateRange({ startDate: '2023-01', endDate: '2025-01', isCurrent: false }) + ).toBe('2023-01 — 2025-01'); + }); + + it('shows only start when not current and no end', () => { + expect( + formatExperienceDateRange({ startDate: '2023-01', endDate: null, isCurrent: false }) + ).toBe('2023-01'); + }); +}); + +describe('formatExperiencePayload', () => { + it('returns empty string for no experiences', () => { + expect(formatExperiencePayload([])).toBe(''); + }); + + it('formats title, company, range, location, description and stack', () => { + const result = formatExperiencePayload([ + baseExperience({ + title: 'Lead Frontend', + company: 'Acme', + startDate: '2023-01', + isCurrent: true, + location: 'Paris', + description: 'Plateforme Svelte.', + skills: ['Svelte', 'TypeScript'], + }), + ]); + + expect(result).toContain('Lead Frontend — Acme · 2023-01 — présent'); + expect(result).toContain('Paris'); + expect(result).toContain('Plateforme Svelte.'); + expect(result).toContain('Stack: Svelte, TypeScript'); + }); + + it('separates multiple experiences with a blank line', () => { + const result = formatExperiencePayload([ + baseExperience({ id: 'a', title: 'A', company: 'Ca', startDate: '2023-01' }), + baseExperience({ id: 'b', title: 'B', company: 'Cb', startDate: '2020-01' }), + ]); + expect(result).toContain('\n\n'); + expect(result.split('\n\n')).toHaveLength(2); + }); +}); + +describe('buildPlatformPayloads', () => { + it('returns the same payload for every target', () => { + const targets = [ + { id: 'linkedin', name: 'LinkedIn', profileUrl: 'https://linkedin.com/in' }, + { id: 'freework', name: 'Free-Work', profileUrl: 'https://freework.com' }, + ]; + const map = buildPlatformPayloads([baseExperience()], targets); + expect(map.size).toBe(2); + const first = map.get('linkedin'); + expect(first).toBe(map.get('freework')); + expect(first).toContain('Lead Frontend — Acme'); + }); + + it('returns empty map for no targets', () => { + const map = buildPlatformPayloads([baseExperience()], []); + expect(map.size).toBe(0); + }); +}); diff --git a/apps/extension/tests/unit/messaging/schemas.test.ts b/apps/extension/tests/unit/messaging/schemas.test.ts index f5fd8e16..62a325d0 100644 --- a/apps/extension/tests/unit/messaging/schemas.test.ts +++ b/apps/extension/tests/unit/messaging/schemas.test.ts @@ -174,12 +174,12 @@ describe('validateMessage — SAVE_PROFILE', () => { expect(r.valid).toBe(true); }); - it('rejette un payload dépassant 10 Ko', () => { - const huge = { type: 'SAVE_PROFILE', payload: { bio: 'x'.repeat(11_000) } }; + it('rejette un payload dépassant 80 Ko', () => { + const huge = { type: 'SAVE_PROFILE', payload: { bio: 'x'.repeat(81_000) } }; const r = validateMessage(huge); expect(r.valid).toBe(false); if (!r.valid) { - expect(r.errors.some((e) => e.includes('10KB'))).toBe(true); + expect(r.errors.some((e) => e.includes('80KB'))).toBe(true); } }); }); 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 9befa44e..ad2e68c0 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 @@ -6,6 +6,8 @@ import type { } from '../../../src/lib/core/profile-extractors/types'; import { mergeCandidateProfileIntoUserProfile } from '../../../src/lib/core/profile-extractors/merge-candidate-profile'; +const NOW = 1_700_000_000_000; + function makeDraft( overrides: Partial = {} ): CanonicalCandidateProfileDraft { @@ -59,7 +61,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { it('overwrites jobTitle with the draft title', () => { const merged = mergeCandidateProfileIntoUserProfile( makeProfile({ jobTitle: 'Ancien titre' }), - makeDraft({ title: 'Nouveau titre LinkedIn' }) + makeDraft({ title: 'Nouveau titre LinkedIn' }), + NOW ); expect(merged.jobTitle).toBe('Nouveau titre LinkedIn'); @@ -74,7 +77,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { { skill: 'React', source: 'linkedin', confidence: 0.8 }, { skill: 'TYPESCRIPT', source: 'linkedin', confidence: 0.7 }, ], - }) + }), + NOW ); expect(merged.keywords).toEqual(['Svelte', 'TypeScript', 'React']); @@ -85,7 +89,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { makeProfile({ location: 'Lyon' }), makeDraft({ experiences: [experience({ location: 'Marseille' })], - }) + }), + NOW ); expect(merged.location).toBe('Lyon'); @@ -100,7 +105,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { experience({ title: 'Dev', location: 'Bordeaux', positionIndex: 1 }), experience({ title: 'Junior', location: 'Nantes', positionIndex: 2 }), ], - }) + }), + NOW ); expect(merged.location).toBe('Bordeaux'); @@ -111,14 +117,19 @@ describe('mergeCandidateProfileIntoUserProfile', () => { makeProfile({ location: ' ' }), makeDraft({ experiences: [experience({ location: null }), experience({ location: ' ' })], - }) + }), + NOW ); expect(merged.location).toBe(''); }); it('returns a complete UserProfile with defaults when current is null', () => { - const merged = mergeCandidateProfileIntoUserProfile(null, makeDraft({ title: 'Solo Title' })); + const merged = mergeCandidateProfileIntoUserProfile( + null, + makeDraft({ title: 'Solo Title' }), + NOW + ); expect(merged).toEqual({ firstName: '', @@ -130,6 +141,7 @@ describe('mergeCandidateProfileIntoUserProfile', () => { seniority: 'senior', jobTitle: 'Solo Title', scoringWeights: undefined, + experiences: [], }); }); @@ -142,7 +154,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { seniority: 'confirmed', keywords: ['react', 'remote'], }), - makeDraft({ title: 'Nouveau titre' }) + makeDraft({ title: 'Nouveau titre' }), + NOW ); expect(merged.tjmMin).toBe(700); @@ -157,7 +170,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { const weights = { stack: 50, location: 10, tjm: 30, remote: 10 }; const merged = mergeCandidateProfileIntoUserProfile( makeProfile({ scoringWeights: weights }), - makeDraft() + makeDraft(), + NOW ); expect(merged.scoringWeights).toEqual(weights); @@ -167,7 +181,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { const current = makeProfile({ keywords: ['Svelte'] }); const merged = mergeCandidateProfileIntoUserProfile( current, - makeDraft({ skills: [{ skill: 'React', source: 'linkedin', confidence: 0.9 }] }) + makeDraft({ skills: [{ skill: 'React', source: 'linkedin', confidence: 0.9 }] }), + NOW ); expect(current.keywords).toEqual(['Svelte']); diff --git a/apps/extension/tests/unit/profile/normalize-profile.test.ts b/apps/extension/tests/unit/profile/normalize-profile.test.ts index 5c4d4df7..a89305cd 100644 --- a/apps/extension/tests/unit/profile/normalize-profile.test.ts +++ b/apps/extension/tests/unit/profile/normalize-profile.test.ts @@ -42,6 +42,7 @@ describe('normalize profile helpers', () => { seniority: 'senior', jobTitle: '', scoringWeights: undefined, + experiences: [], }); }); @@ -76,4 +77,38 @@ describe('normalize profile helpers', () => { expect(result).toEqual({ ok: false, error: PROFILE_TJM_RANGE_ERROR }); }); + + it('preserves experiences passed through the draft input', () => { + const experiences = [ + { + id: 'exp-1', + title: 'Lead', + company: 'Acme', + location: 'Paris', + startDate: '2023-01', + endDate: null, + isCurrent: true, + description: 'Desc.', + skills: ['Svelte'], + source: 'manual' as const, + sourceExternalId: null, + positionIndex: 0, + updatedAt: 1_700_000_000_000, + }, + ]; + const result = normalizeProfileDraft({ + firstName: 'Guy', + experiences, + }); + + expect(result.ok).toBe(true); + expect(result.profile?.experiences).toEqual(experiences); + }); + + it('defaults experiences to an empty array when not provided', () => { + const result = normalizeProfileDraft({ firstName: 'Guy' }); + + expect(result.ok).toBe(true); + expect(result.profile?.experiences).toEqual([]); + }); }); 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 b74fb9e7..ab1f75c4 100644 --- a/apps/extension/tests/unit/storage/connected-profile-cache.test.ts +++ b/apps/extension/tests/unit/storage/connected-profile-cache.test.ts @@ -16,6 +16,7 @@ const profile: UserProfile = { seniority: 'senior', jobTitle: 'Architecte frontend', scoringWeights: { stack: 40, location: 20, tjm: 25, remote: 15 }, + experiences: [], }; describe('connected candidate profile cache', () => { diff --git a/apps/extension/tests/unit/storage/profile-db.test.ts b/apps/extension/tests/unit/storage/profile-db.test.ts index f4ccf67f..fd661c42 100644 --- a/apps/extension/tests/unit/storage/profile-db.test.ts +++ b/apps/extension/tests/unit/storage/profile-db.test.ts @@ -12,6 +12,7 @@ const profile: UserProfile = { remote: 'hybrid', seniority: 'senior', jobTitle: 'Architecte Svelte', + experiences: [], }; describe('profile IndexedDB store', () => { diff --git a/apps/extension/tests/unit/ui/CvPage.test.ts b/apps/extension/tests/unit/ui/CvPage.test.ts index f3e315e0..d361441e 100644 --- a/apps/extension/tests/unit/ui/CvPage.test.ts +++ b/apps/extension/tests/unit/ui/CvPage.test.ts @@ -4,18 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mount, tick } from 'svelte'; import { installChromeStubs } from '../../../src/dev/chrome-stubs'; -import { - initToastService, - getToastActor, - stopToastService, -} from '../../../src/lib/shell/notifications/toast-service'; import CvPage from '../../../src/ui/pages/CvPage.svelte'; /** * Regression for the CV clipboard bug: navigator.clipboard.writeText can reject - * (no permission, non-secure context, etc.). Previously the rejection was - * unhandled and no feedback was given. After the fix, an error toast is shown - * and pushedPlatformIds is not flipped. + * (no permission, non-secure context, etc.). The sync flow must surface the + * failure in the panel and mark every platform as errored — no platform should + * be marked as synced. */ describe('CvPage clipboard resilience', () => { let originalClipboardDescriptor: PropertyDescriptor | undefined; @@ -24,7 +19,6 @@ describe('CvPage clipboard resilience', () => { const globalRecord = globalThis as unknown as Record; delete globalRecord.chrome; installChromeStubs(); - initToastService(); originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); Object.defineProperty(navigator, 'clipboard', { @@ -38,7 +32,6 @@ describe('CvPage clipboard resilience', () => { }); afterEach(() => { - stopToastService(); if (originalClipboardDescriptor) { Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor); } @@ -48,42 +41,45 @@ describe('CvPage clipboard resilience', () => { const target = document.createElement('div'); document.body.appendChild(target); mount(CvPage, { target }); - // Resolve the on-mount getProfile() IIFE. + // Resolve the on-mount profile load. await tick(); await new Promise((resolve) => setTimeout(resolve, 0)); await tick(); return target; } - it('shows an error toast when copyPayload clipboard write fails', async () => { - const target = await mountAndLoad(); - - const copyBtn = [...target.querySelectorAll('button')].find((b) => - b.textContent?.includes('Copier') - ); - expect(copyBtn).toBeTruthy(); - copyBtn!.click(); + async function flush() { await tick(); await new Promise((resolve) => setTimeout(resolve, 0)); await tick(); + } + + it('surfaces a sync error in the panel when clipboard write fails', async () => { + const target = await mountAndLoad(); - const actor = getToastActor(); - const errorToasts = (actor?.toasts ?? []).filter((t) => t.toastType === 'error'); - expect(errorToasts.length).toBeGreaterThan(0); + const syncBtn = [...target.querySelectorAll('button')].find((b) => + b.textContent?.includes('Synchroniser') + ); + expect(syncBtn).toBeTruthy(); + syncBtn!.click(); + await flush(); + + // The global clipboard probe fails → every platform errored → error headline. + expect(target.textContent).toContain('refusé'); }); - it('does not mark a platform as pushed when clipboard fails', async () => { + it('does not mark any platform as synced when clipboard fails', async () => { const target = await mountAndLoad(); - const pushBtn = [...target.querySelectorAll('button')].find((b) => - b.textContent?.includes('Copier et ouvrir') + const syncBtn = [...target.querySelectorAll('button')].find((b) => + b.textContent?.includes('Synchroniser') ); - expect(pushBtn).toBeTruthy(); - pushBtn!.click(); - await tick(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await tick(); + expect(syncBtn).toBeTruthy(); + syncBtn!.click(); + await flush(); - expect(target.textContent).not.toContain('Prêt'); + expect(target.textContent).not.toContain('Synchronisé'); + // Each platform should surface the failure instead. + expect(target.textContent).toContain('Échec'); }); }); diff --git a/apps/extension/tests/unit/ui/CvPageSync.test.ts b/apps/extension/tests/unit/ui/CvPageSync.test.ts deleted file mode 100644 index fcfe42f6..00000000 --- a/apps/extension/tests/unit/ui/CvPageSync.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mount, tick } from 'svelte'; -import { - initToastService, - getToastActor, - stopToastService, -} from '../../../src/lib/shell/notifications/toast-service'; - -vi.mock('../../../src/lib/shell/messaging/bridge', () => { - const mockProfile = { - firstName: 'Guy', - keywords: ['Svelte', 'TypeScript', 'mission svelte'], - tjmMin: 650, - tjmMax: 900, - location: 'Paris', - remote: 'hybrid', - seniority: 'senior', - jobTitle: 'Lead Frontend', - }; - const mockDraft = { - title: 'Lead Frontend Svelte', - summary: 'Architecte front-end Svelte.', - experiences: [], - skills: [], - education: [], - links: [], - source: 'linkedin', - confidence: 0.92, - capturedAt: '2026-06-27T00:00:00.000Z', - profileUrl: 'https://www.linkedin.com/in/dev-preview', - }; - return { - sendMessage: vi.fn(async (message: { type: string }) => { - switch (message.type) { - case 'GET_PROFILE': - return { type: 'PROFILE_RESULT', payload: mockProfile }; - case 'PREVIEW_LINKEDIN_PROFILE': - return { - type: 'LINKEDIN_PROFILE_PREVIEWED', - payload: { extracted: true, profile: mockDraft }, - }; - case 'SYNC_LINKEDIN_PROFILE_IMPORT': - return { - type: 'LINKEDIN_PROFILE_IMPORTED', - payload: { - imported: false, - errorCode: 'sync_failed', - errorMessage: 'La synchronisation LinkedIn a échoué.', - }, - }; - case 'OPEN_EXTERNAL_URL': - return { type: 'EXTERNAL_URL_OPENED', payload: { opened: true } }; - default: - return { type: 'UNKNOWN' }; - } - }), - subscribeMessages: () => () => {}, - }; -}); - -import CvPage from '../../../src/ui/pages/CvPage.svelte'; - -/** - * Regression for CV-01: when the LinkedIn profile sync fails (e.g. the profile - * store is unavailable), CvPage must surface the service-worker errorMessage - * as an error toast plus a recovery hint as an info toast so the user knows - * the preview is still usable for a manual update. - */ -describe('CvPage LinkedIn sync failure recovery', () => { - beforeEach(() => { - document.body.innerHTML = ''; - initToastService(); - }); - - afterEach(() => { - stopToastService(); - }); - - async function mountAndLoad() { - const target = document.createElement('div'); - document.body.appendChild(target); - mount(CvPage, { target }); - await tick(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await tick(); - return target; - } - - async function flush() { - await tick(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await tick(); - } - - it('shows an error toast plus a recovery hint when sync fails', async () => { - const target = await mountAndLoad(); - - // Trigger the LinkedIn preview so "Enregistrer comme source" appears. - const previewBtn = [...target.querySelectorAll('button')].find((b) => - b.textContent?.includes('Prévisualiser') - ); - expect(previewBtn).toBeTruthy(); - previewBtn!.click(); - await flush(); - - // Click "Enregistrer comme source" to trigger the sync. - const syncBtn = [...target.querySelectorAll('button')].find((b) => - b.textContent?.includes('Enregistrer comme source') - ); - expect(syncBtn).toBeTruthy(); - syncBtn!.click(); - await flush(); - - const toasts = getToastActor()?.toasts ?? []; - const errorToasts = toasts.filter((t) => t.toastType === 'error'); - const infoToasts = toasts.filter((t) => t.toastType === 'info'); - expect(errorToasts.length).toBeGreaterThan(0); - expect( - errorToasts.some((t) => t.message.includes('La synchronisation LinkedIn a échoué.')) - ).toBe(true); - expect(infoToasts.length).toBeGreaterThan(0); - }); -}); 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 f09e2c7e..1a88cb1b 100644 --- a/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts +++ b/apps/extension/tests/unit/ui/operational-ui-constraints.test.ts @@ -73,15 +73,16 @@ describe('operational UI constraints', () => { it('keeps loading states tied to source and progression context', () => { const cvSource = readFileSync('src/ui/pages/CvPage.svelte', 'utf8'); + const feedSource = readFileSync('src/ui/organisms/ExperienceFeed.svelte', 'utf8'); const applicationsSource = readFileSync('src/ui/pages/ApplicationsPage.svelte', 'utf8'); - expect(cvSource).toContain('type LoadingProgressStep'); - expect(cvSource).toContain('Chargement CV'); - expect(cvSource).toContain('Progression du chargement CV'); - expect(cvSource).toContain('Profil canonique'); - expect(cvSource).toContain('Plateformes'); - expect(cvSource).toContain('Écarts'); - expect(cvSource).toContain('role="status"'); + // CvPage delegates loading to the experience feed, which ties its skeleton + // to the store's feedStatus and surfaces the entry count as progression. + expect(cvSource).toContain('store.load()'); + expect(cvSource).toContain('ExperienceFeed'); + expect(feedSource).toContain("feedStatus === 'loading'"); + expect(feedSource).toContain('Skeleton'); + expect(feedSource).toContain('store.experiences.length'); expect(applicationsSource).toContain('type LoadingProgressStep'); expect(applicationsSource).toContain('Chargement candidatures'); expect(applicationsSource).toContain('Progression du chargement candidatures'); @@ -352,26 +353,19 @@ describe('operational UI constraints', () => { expect(appSource).toContain("onNavigateToFeed={() => nav.navigate('feed')}"); }); - it('routes missing CV source states to import or profile completion', () => { + it('routes missing CV source states to add-experience or retry', () => { const cvSource = readFileSync('src/ui/pages/CvPage.svelte', 'utf8'); + const feedSource = readFileSync('src/ui/organisms/ExperienceFeed.svelte', 'utf8'); const appSource = readFileSync('src/sidepanel/App.svelte', 'utf8'); - expect(cvSource).toContain("primaryActionLabel: 'Importer LinkedIn'"); - expect(cvSource).toContain('secondaryActionLabel="Compléter le profil"'); - expect(cvSource).toContain('onSecondaryAction={completeProfileManually}'); - expect(cvSource).toContain("primaryActionLabel: 'Prévisualiser LinkedIn'"); - expect(cvSource).not.toContain('const linkedInPrimaryLabel'); - expect(cvSource).toContain( - "const sourceActionLabel = $derived(profile ? 'Tout préparer' : 'Compléter le profil')" - ); - expect(cvSource).toContain('type CvWorkflowStep'); - expect(cvSource).toContain('const cvWorkflowSteps = $derived.by'); - expect(cvSource).toContain('Source canonique'); - expect(cvSource).toContain('Plateformes à mettre à jour'); - expect(cvSource).toContain('Dashboard connecté'); - expect(cvSource).toContain('Enregistrer comme source'); - expect(cvSource).not.toContain('Profil CV synchronisé dans Supabase'); - expect(cvSource).toContain('function handleSourceAction()'); + // CvPage wires the sync panel + feed; the feed owns empty/error routing. + expect(cvSource).toContain('CvSyncPanel'); + expect(cvSource).toContain('ExperienceFeed'); + expect(cvSource).toContain('onNavigateToProfile'); + expect(feedSource).toContain('primaryActionLabel="Ajouter une expérience"'); + expect(feedSource).toContain('primaryActionLabel="Réessayer"'); + expect(feedSource).toContain('onPrimaryAction={() => store.reload()}'); + expect(feedSource).toContain('onPrimaryAction={() => store.newExperience()}'); expect(appSource).toContain("onNavigateToProfile={() => nav.navigate('profile')}"); });