From 216296d4053f42be03ef4171e44b265c48c33d8d Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Thu, 9 Jul 2026 14:19:15 +0200 Subject: [PATCH 1/4] feat(cv): rewrite CV tab as experience feed with cross-platform sync Replace the old canonical-source/LinkedIn-preview CV page with a feed of experiences that supports inline add/edit/delete and a sync button that copies a CV block to the clipboard and opens each connected platform (LinkedIn + 6 freelance connectors) for manual paste. Follows the project Model -> Review -> Implement -> Verify workflow. The authoritative state machine lives in src/models/cv-experience-sync.model.md and defines three cooperating machines (Feed, Edit, Sync) with explicit invariants: one edit session at a time, no re-entrancy during save/delete, PROFILE_UPDATED dropped during in-flight sync/save/edit, per-platform independent status, and sync operates on committed data. Core (pure): - Add Experience type and experiences[] on UserProfile - Add pure helpers in core/cv/experience-helpers.ts (normalize, recompute position index, merge with dedup, format payload, build platform map) - Persist experiences through mergeCandidateProfileIntoUserProfile (was discarded before) and through backup/normalize/defaults/schemas Shell: - Add cv-experience.facade.ts wiring store deps to profile + clipboard + external URL opener + Date.now/crypto.randomUUID State: - Add cv-experience.svelte.ts runes store implementing the three machines UI: - ExperienceEditForm, ExperienceCard, ExperienceFeed, CvSyncPanel - Rewrite CvPage.svelte (1022 -> 65 lines) to header + sync panel + feed Tests: - 22 unit tests for pure helpers (no mocks) - Update existing fixtures to include experiences: [] - Rewrite CvPage.test.ts for the new sync flow; delete obsolete CvPageSync.test.ts (old LinkedIn preview/import flow) - Update operational-ui-constraints.test.ts for new CvPage wiring Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/dev/mocks.ts | 49 + apps/extension/src/dev/qa-seed.ts | 33 + apps/extension/src/lib/core/backup/backup.ts | 25 +- .../src/lib/core/cv/experience-helpers.ts | 204 ++++ .../merge-candidate-profile.ts | 12 +- .../src/lib/core/profile/defaults.ts | 3 + .../src/lib/core/profile/normalize-profile.ts | 1 + apps/extension/src/lib/core/types/profile.ts | 40 + apps/extension/src/lib/core/types/schemas.ts | 19 + .../lib/shell/facades/cv-experience.facade.ts | 90 ++ .../src/lib/state/cv-experience.svelte.ts | 398 +++++++ .../src/models/cv-experience-sync.model.md | 283 +++++ .../src/ui/molecules/ExperienceCard.svelte | 152 +++ .../ui/molecules/ExperienceEditForm.svelte | 202 ++++ .../src/ui/organisms/CvSyncPanel.svelte | 132 +++ .../src/ui/organisms/ExperienceFeed.svelte | 133 +++ apps/extension/src/ui/pages/CvPage.svelte | 1030 +---------------- .../tests/unit/cv/experience-helpers.test.ts | 322 ++++++ .../merge-candidate-profile.test.ts | 1 + .../unit/profile/normalize-profile.test.ts | 1 + .../storage/connected-profile-cache.test.ts | 1 + .../tests/unit/storage/profile-db.test.ts | 1 + apps/extension/tests/unit/ui/CvPage.test.ts | 58 +- .../tests/unit/ui/CvPageSync.test.ts | 127 -- .../ui/operational-ui-constraints.test.ts | 42 +- 25 files changed, 2182 insertions(+), 1177 deletions(-) create mode 100644 apps/extension/src/lib/core/cv/experience-helpers.ts create mode 100644 apps/extension/src/lib/shell/facades/cv-experience.facade.ts create mode 100644 apps/extension/src/lib/state/cv-experience.svelte.ts create mode 100644 apps/extension/src/models/cv-experience-sync.model.md create mode 100644 apps/extension/src/ui/molecules/ExperienceCard.svelte create mode 100644 apps/extension/src/ui/molecules/ExperienceEditForm.svelte create mode 100644 apps/extension/src/ui/organisms/CvSyncPanel.svelte create mode 100644 apps/extension/src/ui/organisms/ExperienceFeed.svelte create mode 100644 apps/extension/tests/unit/cv/experience-helpers.test.ts delete mode 100644 apps/extension/tests/unit/ui/CvPageSync.test.ts diff --git a/apps/extension/src/dev/mocks.ts b/apps/extension/src/dev/mocks.ts index 78172a1c..5dab7dfc 100644 --- a/apps/extension/src/dev/mocks.ts +++ b/apps/extension/src/dev/mocks.ts @@ -12,6 +12,55 @@ export const mockProfile: UserProfile = { seniority: 'senior', jobTitle: 'Développeur Fullstack', searchKeywords: [], + 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 1933d3a9..c2d73dbd 100644 --- a/apps/extension/src/dev/qa-seed.ts +++ b/apps/extension/src/dev/qa-seed.ts @@ -418,6 +418,39 @@ function buildCompleteProfile(): UserProfile { seniority: 'senior', jobTitle: 'Développeur Fullstack', searchKeywords: [], + 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, + }, + ], }; } diff --git a/apps/extension/src/lib/core/backup/backup.ts b/apps/extension/src/lib/core/backup/backup.ts index c4f89637..9cefe57f 100644 --- a/apps/extension/src/lib/core/backup/backup.ts +++ b/apps/extension/src/lib/core/backup/backup.ts @@ -31,6 +31,25 @@ export const BackupDataSchema = z.object({ remote: z.number(), }) .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), + }) + ) + .optional(), }), settings: z.object({ scanIntervalMinutes: z.number(), @@ -82,10 +101,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..74685ac8 --- /dev/null +++ b/apps/extension/src/lib/core/cv/experience-helpers.ts @@ -0,0 +1,204 @@ +/** + * 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) => { + const key = experienceKey(draft.company, draft.title, draft.startDate); + 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; + result[existingIdx] = { + ...existing, + skills: unionSkills(existing.skills, draft.skills), + description: keepDescription ? existing.description : draft.description, + location: existing.location ?? draft.location, + endDate: existing.isCurrent ? null : (existing.endDate ?? draft.endDate ?? null), + isCurrent: existing.isCurrent || draft.isCurrent, + sourceExternalId: existing.sourceExternalId ?? draft.sourceExternalId, + }; + return; + } + + result.push({ + id: `exp-${now}-${importIndex}`, + title: draft.title, + company: draft.company, + location: draft.location, + startDate: draft.startDate, + endDate: draft.isCurrent ? null : draft.endDate, + isCurrent: draft.isCurrent, + description: draft.description, + skills: [...draft.skills], + source: 'linkedin', + sourceExternalId: draft.sourceExternalId, + positionIndex: draft.positionIndex, + updatedAt: now, + }); + }); + + return recomputePositionIndex(result); +} + +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 58570f30..8e70494a 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, searchKeywords, * 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 = 0 ): 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, stack, location, + experiences, }; } diff --git a/apps/extension/src/lib/core/profile/defaults.ts b/apps/extension/src/lib/core/profile/defaults.ts index 61e8bed8..ad5e625a 100644 --- a/apps/extension/src/lib/core/profile/defaults.ts +++ b/apps/extension/src/lib/core/profile/defaults.ts @@ -20,6 +20,7 @@ const DEFAULT_PROFILE = { seniority: 'senior', jobTitle: '', searchKeywords: [], + experiences: [], scoringWeights: { stack: 0, location: 10, @@ -37,6 +38,7 @@ export function createDefaultProfile(): UserProfile { ...DEFAULT_PROFILE, stack: [...DEFAULT_PROFILE.stack], searchKeywords: [...DEFAULT_PROFILE.searchKeywords], + experiences: [], scoringWeights: { ...DEFAULT_PROFILE.scoringWeights }, }; } @@ -52,6 +54,7 @@ export function isDefaultProfile(profile: UserProfile): boolean { profile.seniority === DEFAULT_PROFILE.seniority && profile.jobTitle === DEFAULT_PROFILE.jobTitle && profile.searchKeywords.length === 0 && + (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 c5be7c08..ea92c2de 100644 --- a/apps/extension/src/lib/core/profile/normalize-profile.ts +++ b/apps/extension/src/lib/core/profile/normalize-profile.ts @@ -67,6 +67,7 @@ export const withProfileDefaults = (profile: Partial): UserProfile jobTitle: profile.jobTitle ?? '', scoringWeights: profile.scoringWeights, searchKeywords: [...(profile.searchKeywords ?? [])], + experiences: [...(profile.experiences ?? [])], }); export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfileResult { diff --git a/apps/extension/src/lib/core/types/profile.ts b/apps/extension/src/lib/core/types/profile.ts index 8cbd4665..a270989b 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. @@ -38,4 +72,10 @@ export interface UserProfile { scoringWeights?: ScoringWeights; /** User-defined search keywords sent to connector APIs for server-side filtering */ searchKeywords: string[]; + /** + * Professional experiences, edited in the CV tab and pushed to LinkedIn and + * the mission connectors. Optional for backward compatibility with stored + * profiles; treat as `[]` when absent. + */ + experiences?: Experience[]; } diff --git a/apps/extension/src/lib/core/types/schemas.ts b/apps/extension/src/lib/core/types/schemas.ts index 11177e36..be09f275 100644 --- a/apps/extension/src/lib/core/types/schemas.ts +++ b/apps/extension/src/lib/core/types/schemas.ts @@ -135,6 +135,25 @@ export const UserProfileSchema = z scoringWeights: ScoringWeightsSchema.optional(), /** User-defined search keywords sent to connector APIs for server-side filtering */ searchKeywords: z.array(z.string()).default([]), + 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, { message: 'Le TJM maximum doit être supérieur ou égal au TJM minimum', 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..b29b6f30 --- /dev/null +++ b/apps/extension/src/lib/shell/facades/cv-experience.facade.ts @@ -0,0 +1,90 @@ +/** + * 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: '', + stack: [] as string[], + tjmMin: 0, + tjmMax: 0, + location: '', + remote: 'any' as const, + seniority: 'senior' as const, + jobTitle: '', + searchKeywords: [] as string[], + experiences: [] as Experience[], + }; +} 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..bd1694d2 --- /dev/null +++ b/apps/extension/src/lib/state/cv-experience.svelte.ts @@ -0,0 +1,398 @@ +/** + * 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 error: 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 error = $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'; + error = null; + try { + const result = await deps.loadExperiences(); + experiences = recomputePositionIndex(result); + feedStatus = 'ready'; + } catch (err) { + error = 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'; + error = 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'; + error = null; + } + + function cancelEdit(): void { + if (editStatus !== 'adding' && editStatus !== 'editing' && editStatus !== 'error') { + return; + } + draft = null; + editingId = null; + editStatus = 'idle'; + error = 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'; + error = 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) { + error = 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'; + error = 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) { + error = 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'; + error = SYNC_EMPTY; + resetPlatformStatuses(); + return; + } + syncStatus = 'preparing'; + error = 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'; + error = SYNC_COPY_DENIED; + 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 { + const payload = payloads.get(target.id) ?? sample; + await deps.copyToClipboard(payload); + 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) { + error = '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'; + error = null; + editStatus = 'idle'; + editingId = null; + draft = null; + } + + 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 error() { + return error; + }, + 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/models/cv-experience-sync.model.md b/apps/extension/src/models/cv-experience-sync.model.md new file mode 100644 index 00000000..b3713bcd --- /dev/null +++ b/apps/extension/src/models/cv-experience-sync.model.md @@ -0,0 +1,283 @@ +# 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 +(runnes 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 + error: string | null; // feed/edit/sync 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. + +## 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; + error: 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..5f7fd61b --- /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..1d75ef34 --- /dev/null +++ b/apps/extension/src/ui/molecules/ExperienceEditForm.svelte @@ -0,0 +1,202 @@ + + +
{ + 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..ca140082 --- /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..69403035 --- /dev/null +++ b/apps/extension/src/ui/organisms/ExperienceFeed.svelte @@ -0,0 +1,133 @@ + + +
+
+
+

Expériences

+ + {store.experiences.length} + {store.experiences.length > 1 ? 'entrées' : 'entrée'} + +
+ +
+ + {#if isLoading} + {#each Array(3) as _} +
+ + +
+ + +
+
+ {/each} + {:else if hasError} + store.reload()} + /> + {:else if store.experiences.length === 0 && !isAdding} + store.newExperience()} + /> + {:else} + {#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 1fbccc63..887cd1bd 100644 --- a/apps/extension/src/ui/pages/CvPage.svelte +++ b/apps/extension/src/ui/pages/CvPage.svelte @@ -1,1021 +1,65 @@ -
-
-
+
+ {#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. -

-

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

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.

-
- -
+ {#if onNavigateToProfile} + + {/if}
- -
-
-
-
- {profileCompleteness}% -
- -
- { - if (cvStory.primaryActionIcon === 'upload') { - pushAll(); - return; - } - previewLinkedIn(); - }} - /> -
- -
- {#each cvWorkflowSteps as step} -
-
-
- -
-
-
-

- Étape {step.label} -

- - {step.statusLabel} - -
-

{step.title}

-

{step.detail}

-
-
-
- {/each} -
- - {#if isOffline} -
- -
- {/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/unit/cv/experience-helpers.test.ts b/apps/extension/tests/unit/cv/experience-helpers.test.ts new file mode 100644 index 00000000..c69a8d30 --- /dev/null +++ b/apps/extension/tests/unit/cv/experience-helpers.test.ts @@ -0,0 +1,322 @@ +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, + 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], + ]); + }); +}); + +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/profile-extractors/merge-candidate-profile.test.ts b/apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts index 6c8d4719..233d90f4 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 @@ -132,6 +132,7 @@ describe('mergeCandidateProfileIntoUserProfile', () => { jobTitle: 'Solo Title', scoringWeights: undefined, searchKeywords: [], + experiences: [], }); }); diff --git a/apps/extension/tests/unit/profile/normalize-profile.test.ts b/apps/extension/tests/unit/profile/normalize-profile.test.ts index 7b24cfce..5ab79dd8 100644 --- a/apps/extension/tests/unit/profile/normalize-profile.test.ts +++ b/apps/extension/tests/unit/profile/normalize-profile.test.ts @@ -43,6 +43,7 @@ describe('normalize profile helpers', () => { jobTitle: '', scoringWeights: undefined, searchKeywords: [], + experiences: [], }); }); 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..8ddfdfb9 100644 --- a/apps/extension/tests/unit/storage/connected-profile-cache.test.ts +++ b/apps/extension/tests/unit/storage/connected-profile-cache.test.ts @@ -17,6 +17,7 @@ const profile: UserProfile = { jobTitle: 'Architecte frontend', searchKeywords: ['svelte mission'], 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 cef40364..6618888e 100644 --- a/apps/extension/tests/unit/storage/profile-db.test.ts +++ b/apps/extension/tests/unit/storage/profile-db.test.ts @@ -13,6 +13,7 @@ const profile: UserProfile = { seniority: 'senior', jobTitle: 'Architecte Svelte', searchKeywords: ['mission 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 168976ab..00000000 --- a/apps/extension/tests/unit/ui/CvPageSync.test.ts +++ /dev/null @@ -1,127 +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', - stack: ['Svelte', 'TypeScript'], - tjmMin: 650, - tjmMax: 900, - location: 'Paris', - remote: 'hybrid', - seniority: 'senior', - jobTitle: 'Lead Frontend', - searchKeywords: ['mission svelte'], - }; - 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 e938b809..b2dea4ff 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'); @@ -351,26 +352,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')}"); }); From dfd55d59cfd8b94a3ac57777d83bdfad751ae4d6 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Thu, 9 Jul 2026 14:39:47 +0200 Subject: [PATCH 2/4] fix(cv): address review feedback on CV experience feed sync - Split shared store `error` into `feedError`/`editError`/`syncError` so a failure in one machine can no longer overwrite another's terminal error copy - Honor `cancelSync()` during the `preparing` clipboard probe in `startSync()` by re-checking `readSyncStatus()` before entering the platform loop - Make `now` required in `mergeCandidateProfileIntoUserProfile` (was defaulting to 0, causing colliding IDs and `updatedAt: 0`); inject `Date.now()` at both shell callers - Tighten `onSave` prop type from `ExperienceFormData` to `Experience` and drop the unsafe `as Experience` cast in `ExperienceFeed.handleSave` - Add `aria-busy`/`role="status"`/`aria-live="polite"` to the experience feed loading skeleton - Fix typo `runnes` -> `runes` in the cv-experience-sync model Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/background/index.ts | 2 +- apps/extension/src/dev/chrome-stubs.ts | 2 +- .../merge-candidate-profile.ts | 2 +- .../src/lib/state/cv-experience.svelte.ts | 55 +++++++++++++------ .../src/models/cv-experience-sync.model.md | 12 +++- .../src/ui/molecules/ExperienceCard.svelte | 2 +- .../src/ui/organisms/CvSyncPanel.svelte | 2 +- .../src/ui/organisms/ExperienceFeed.svelte | 28 +++++----- .../merge-candidate-profile.test.ts | 32 ++++++++--- 9 files changed, 89 insertions(+), 48 deletions(-) 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/lib/core/profile-extractors/merge-candidate-profile.ts b/apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts index 8e70494a..3428d5ed 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 @@ -27,7 +27,7 @@ import type { CanonicalCandidateProfileDraft } from './types'; export function mergeCandidateProfileIntoUserProfile( current: UserProfile | null, draft: CanonicalCandidateProfileDraft, - now: number = 0 + now: number ): UserProfile { const base = withProfileDefaults({ ...current }); diff --git a/apps/extension/src/lib/state/cv-experience.svelte.ts b/apps/extension/src/lib/state/cv-experience.svelte.ts index bd1694d2..af1737ce 100644 --- a/apps/extension/src/lib/state/cv-experience.svelte.ts +++ b/apps/extension/src/lib/state/cv-experience.svelte.ts @@ -62,7 +62,9 @@ export interface CvExperienceStore { readonly syncStatus: SyncStatus; readonly platformStatuses: Map; readonly lastSyncedAt: number | null; - readonly error: string | null; + readonly feedError: string | null; + readonly editError: string | null; + readonly syncError: string | null; readonly canSync: boolean; readonly isSyncing: boolean; // feed @@ -92,7 +94,9 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto let syncStatus = $state('idle'); let platformStatuses = $state>(new Map()); let lastSyncedAt = $state(null); - let error = $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'); @@ -120,13 +124,13 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto // ── Feed machine ──────────────────────────────────────────────────────── async function load(): Promise { feedStatus = 'loading'; - error = null; + feedError = null; try { const result = await deps.loadExperiences(); experiences = recomputePositionIndex(result); feedStatus = 'ready'; } catch (err) { - error = errorMessage(err, 'Impossible de charger vos expériences.'); + feedError = errorMessage(err, 'Impossible de charger vos expériences.'); feedStatus = 'error'; } } @@ -160,7 +164,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto ); editingId = null; editStatus = 'adding'; - error = null; + editError = null; } function editExperience(id: string): void { @@ -174,7 +178,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto draft = { ...target, skills: [...target.skills] }; editingId = id; editStatus = 'editing'; - error = null; + editError = null; } function cancelEdit(): void { @@ -184,7 +188,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto draft = null; editingId = null; editStatus = 'idle'; - error = null; + editError = null; } async function saveExperience(draftInput: Experience): Promise { @@ -199,7 +203,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto ); draft = normalized; editStatus = 'saving'; - error = null; + editError = null; try { const next = isNew ? [...experiences, normalized] @@ -212,7 +216,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto editStatus = 'idle'; feedStatus = 'ready'; } catch (err) { - error = errorMessage(err, 'Impossible d’enregistrer l’expérience.'); + editError = errorMessage(err, 'Impossible d’enregistrer l’expérience.'); editStatus = 'error'; } } @@ -223,7 +227,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto } editingId = id; editStatus = 'deleting'; - error = null; + editError = null; try { const next = recomputePositionIndex(experiences.filter((exp) => exp.id !== id)); await deps.saveExperiences(next); @@ -232,7 +236,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto editStatus = 'idle'; feedStatus = 'ready'; } catch (err) { - error = errorMessage(err, 'Impossible de supprimer l’expérience.'); + editError = errorMessage(err, 'Impossible de supprimer l’expérience.'); editStatus = 'error'; } } @@ -244,12 +248,12 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto } if (experiences.length === 0) { syncStatus = 'error'; - error = SYNC_EMPTY; + syncError = SYNC_EMPTY; resetPlatformStatuses(); return; } syncStatus = 'preparing'; - error = null; + syncError = null; resetPlatformStatuses(); // Pure prepare in core. @@ -264,7 +268,15 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto setPlatformStatus(target.id, 'error'); } syncStatus = 'error'; - error = SYNC_COPY_DENIED; + 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; } @@ -306,7 +318,7 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto } if (failed > 0 && done === 0) { - error = 'La synchronisation a échoué sur toutes les plateformes.'; + syncError = 'La synchronisation a échoué sur toutes les plateformes.'; } } @@ -333,7 +345,8 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto } experiences = recomputePositionIndex(incoming); feedStatus = 'ready'; - error = null; + feedError = null; + editError = null; editStatus = 'idle'; editingId = null; draft = null; @@ -364,8 +377,14 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto get lastSyncedAt() { return lastSyncedAt; }, - get error() { - return error; + get feedError() { + return feedError; + }, + get editError() { + return editError; + }, + get syncError() { + return syncError; }, get canSync() { return canSync; diff --git a/apps/extension/src/models/cv-experience-sync.model.md b/apps/extension/src/models/cv-experience-sync.model.md index b3713bcd..0206270c 100644 --- a/apps/extension/src/models/cv-experience-sync.model.md +++ b/apps/extension/src/models/cv-experience-sync.model.md @@ -4,7 +4,7 @@ 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 -(runnes over XState — see `profile-state.model.md`). +(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 @@ -125,7 +125,9 @@ interface CvExperienceContext { syncStatus: SyncStatus; platformStatuses: Map; lastSyncedAt: number | null; // epoch ms - error: string | null; // feed/edit/sync error copy + feedError: string | null; // feed machine error copy + editError: string | null; // edit machine error copy + syncError: string | null; // sync machine error copy } ``` @@ -230,6 +232,8 @@ targets)` in core. If `experiences.length === 0` → `PREPARE_ERROR`. Else → 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) @@ -244,7 +248,9 @@ createCvExperienceStore(deps): { syncStatus: SyncStatus; platformStatuses: Map; lastSyncedAt: number | null; - error: string | null; + feedError: string | null; + editError: string | null; + syncError: string | null; // feed load(): void; // edit diff --git a/apps/extension/src/ui/molecules/ExperienceCard.svelte b/apps/extension/src/ui/molecules/ExperienceCard.svelte index 5f7fd61b..d305404c 100644 --- a/apps/extension/src/ui/molecules/ExperienceCard.svelte +++ b/apps/extension/src/ui/molecules/ExperienceCard.svelte @@ -25,7 +25,7 @@ draft?: Experience | null; onEdit?: () => void; onDelete?: () => void; - onSave?: (data: ExperienceFormData) => void; + onSave?: (experience: Experience) => void; onCancelEdit?: () => void; } = $props(); diff --git a/apps/extension/src/ui/organisms/CvSyncPanel.svelte b/apps/extension/src/ui/organisms/CvSyncPanel.svelte index ca140082..6ef650c9 100644 --- a/apps/extension/src/ui/organisms/CvSyncPanel.svelte +++ b/apps/extension/src/ui/organisms/CvSyncPanel.svelte @@ -52,7 +52,7 @@ case 'partial': return `Synchronisé sur ${doneCount}/${platforms.length} plateformes.`; case 'error': - return store.error ?? 'La synchronisation a échoué.'; + return store.syncError ?? 'La synchronisation a échoué.'; case 'cancelled': return 'Synchronisation annulée.'; default: diff --git a/apps/extension/src/ui/organisms/ExperienceFeed.svelte b/apps/extension/src/ui/organisms/ExperienceFeed.svelte index 69403035..a1917f29 100644 --- a/apps/extension/src/ui/organisms/ExperienceFeed.svelte +++ b/apps/extension/src/ui/organisms/ExperienceFeed.svelte @@ -5,7 +5,6 @@ import type { CvExperienceStore } from '$lib/state/cv-experience.svelte'; import type { Experience } from '$lib/core/types/profile'; import ExperienceCard from '../molecules/ExperienceCard.svelte'; - import type { ExperienceFormData } from '../molecules/ExperienceEditForm.svelte'; import OperationalEmptyState from '../molecules/OperationalEmptyState.svelte'; const { store }: { store: CvExperienceStore } = $props(); @@ -36,8 +35,8 @@ store.editStatus === 'saving' || store.editStatus === 'deleting' ? store.editingId : null ); - function handleSave(data: ExperienceFormData) { - store.saveExperience(data as Experience); + function handleSave(experience: Experience) { + store.saveExperience(experience); } @@ -62,20 +61,23 @@
{#if isLoading} - {#each Array(3) as _} -
- - -
- - +
+ Chargement de vos expériences… + {#each Array(3) as _} +
+ + +
+ + +
-
- {/each} + {/each} +
{:else if hasError} = {} ): CanonicalCandidateProfileDraft { @@ -60,7 +62,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'); @@ -75,7 +78,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { { skill: 'React', source: 'linkedin', confidence: 0.8 }, { skill: 'TYPESCRIPT', source: 'linkedin', confidence: 0.7 }, ], - }) + }), + NOW ); expect(merged.stack).toEqual(['Svelte', 'TypeScript', 'React']); @@ -86,7 +90,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { makeProfile({ location: 'Lyon' }), makeDraft({ experiences: [experience({ location: 'Marseille' })], - }) + }), + NOW ); expect(merged.location).toBe('Lyon'); @@ -101,7 +106,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { experience({ title: 'Dev', location: 'Bordeaux', positionIndex: 1 }), experience({ title: 'Junior', location: 'Nantes', positionIndex: 2 }), ], - }) + }), + NOW ); expect(merged.location).toBe('Bordeaux'); @@ -112,14 +118,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: '', @@ -145,7 +156,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { seniority: 'confirmed', searchKeywords: ['react', 'remote'], }), - makeDraft({ title: 'Nouveau titre' }) + makeDraft({ title: 'Nouveau titre' }), + NOW ); expect(merged.tjmMin).toBe(700); @@ -160,7 +172,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); @@ -170,7 +183,8 @@ describe('mergeCandidateProfileIntoUserProfile', () => { const current = makeProfile({ stack: ['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.stack).toEqual(['Svelte']); From f0cb6c45a6c0b915807e8838338f9672184925e6 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Thu, 9 Jul 2026 15:10:23 +0200 Subject: [PATCH 3/4] fix(cv): address review feedback on experience feed and sync - Normalize imported LinkedIn dates (YYYY-MM-DD) to canonical YYYY-MM in mergeExperiences via new normalizeDateToMonth pure helper. - Clear endDate when a merged entry becomes current (checks draft.isCurrent, not just existing.isCurrent). - Preserve experiences through normalizeProfileDraft (ProfileDraftInput now accepts experiences; settings-page passes current?.experiences). - Raise SAVE_PROFILE payload cap from 10KB to 80KB so full experiences array fits (matches PlatformProfileDraftSchema cap). - Reset sync state (syncStatus, platformStatuses, lastSyncedAt, syncError) in applyProfileUpdate so external merges don't leave stale sync state. - Remove redundant per-platform clipboard copy in sync loop (payload already on clipboard from the probe; re-copy fails once a tab steals focus). - Keep failed add/edit drafts visible (isAdding/isEditing include error state with matching editingId). - Surface edit errors in non-empty feed via alert banner. - Restore LinkedIn import button on CvPage (preview + sync facades). - Allow null endDate for past roles in ExperienceEditForm (end date optional). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/lib/core/cv/experience-helpers.ts | 37 +++++++++-- .../src/lib/core/profile/normalize-profile.ts | 4 +- .../src/lib/shell/messaging/schemas.ts | 2 +- .../src/lib/state/cv-experience.svelte.ts | 12 +++- .../src/lib/state/settings-page.svelte.ts | 1 + .../ui/molecules/ExperienceEditForm.svelte | 13 +--- .../src/ui/organisms/ExperienceFeed.svelte | 20 +++++- apps/extension/src/ui/pages/CvPage.svelte | 52 +++++++++++++-- .../tests/unit/cv/experience-helpers.test.ts | 64 +++++++++++++++++++ .../tests/unit/messaging/schemas.test.ts | 6 +- .../unit/profile/normalize-profile.test.ts | 34 ++++++++++ 11 files changed, 216 insertions(+), 29 deletions(-) diff --git a/apps/extension/src/lib/core/cv/experience-helpers.ts b/apps/extension/src/lib/core/cv/experience-helpers.ts index 74685ac8..112f370f 100644 --- a/apps/extension/src/lib/core/cv/experience-helpers.ts +++ b/apps/extension/src/lib/core/cv/experience-helpers.ts @@ -137,7 +137,12 @@ export function mergeExperiences( const result: Experience[] = current.map((exp) => ({ ...exp, skills: [...exp.skills] })); incoming.forEach((draft, importIndex) => { - const key = experienceKey(draft.company, draft.title, draft.startDate); + // 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 ); @@ -145,13 +150,14 @@ export function mergeExperiences( 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: existing.isCurrent ? null : (existing.endDate ?? draft.endDate ?? null), - isCurrent: existing.isCurrent || draft.isCurrent, + endDate: mergedIsCurrent ? null : (existing.endDate ?? draftEnd ?? null), + isCurrent: mergedIsCurrent, sourceExternalId: existing.sourceExternalId ?? draft.sourceExternalId, }; return; @@ -162,8 +168,8 @@ export function mergeExperiences( title: draft.title, company: draft.company, location: draft.location, - startDate: draft.startDate, - endDate: draft.isCurrent ? null : draft.endDate, + startDate: draftStart, + endDate: draft.isCurrent ? null : draftEnd, isCurrent: draft.isCurrent, description: draft.description, skills: [...draft.skills], @@ -177,6 +183,27 @@ export function mergeExperiences( 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 ?? ''}`; } diff --git a/apps/extension/src/lib/core/profile/normalize-profile.ts b/apps/extension/src/lib/core/profile/normalize-profile.ts index 8cb54ef0..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 { @@ -87,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/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 index af1737ce..0d175d26 100644 --- a/apps/extension/src/lib/state/cv-experience.svelte.ts +++ b/apps/extension/src/lib/state/cv-experience.svelte.ts @@ -291,8 +291,10 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto } setPlatformStatus(target.id, 'copying'); try { - const payload = payloads.get(target.id) ?? sample; - await deps.copyToClipboard(payload); + // 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; @@ -350,6 +352,12 @@ export function createCvExperienceStore(deps: CvExperienceDeps): CvExperienceSto 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 { 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/ui/molecules/ExperienceEditForm.svelte b/apps/extension/src/ui/molecules/ExperienceEditForm.svelte index 1d75ef34..bd4c5c5c 100644 --- a/apps/extension/src/ui/molecules/ExperienceEditForm.svelte +++ b/apps/extension/src/ui/molecules/ExperienceEditForm.svelte @@ -48,12 +48,9 @@ const startDateError = $derived( touched && startDate.trim().length === 0 ? 'La date de début est requise.' : '' ); - const endDateError = $derived( - !isCurrent && touched && endDate.trim().length === 0 - ? 'La date de fin est requise (ou cochez « poste actuel »).' - : '' - ); - const hasErrors = $derived(Boolean(titleError || companyError || startDateError || endDateError)); + // End date is optional for past roles — the user may not remember the exact + // month. Only validate format when a value is provided. + const hasErrors = $derived(Boolean(titleError || companyError || startDateError)); function handleSubmit() { touched = true; @@ -143,11 +140,7 @@ type="month" disabled={isCurrent} class="rounded-lg border border-border-light bg-surface-white px-3 py-2 text-sm text-text-primary focus:border-blueprint-blue focus:outline-none focus:ring-2 focus:ring-blueprint-blue/20 disabled:opacity-40" - aria-invalid={Boolean(endDateError)} /> - {#if endDateError} - {endDateError} - {/if}
diff --git a/apps/extension/tests/unit/cv/experience-helpers.test.ts b/apps/extension/tests/unit/cv/experience-helpers.test.ts index c69a8d30..577d33b4 100644 --- a/apps/extension/tests/unit/cv/experience-helpers.test.ts +++ b/apps/extension/tests/unit/cv/experience-helpers.test.ts @@ -6,6 +6,7 @@ import { formatExperienceDateRange, formatExperiencePayload, mergeExperiences, + normalizeDateToMonth, normalizeExperience, recomputePositionIndex, } from '../../../src/lib/core/cv/experience-helpers'; @@ -240,6 +241,69 @@ describe('mergeExperiences', () => { ['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', () => { 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/normalize-profile.test.ts b/apps/extension/tests/unit/profile/normalize-profile.test.ts index 1e3aff95..a89305cd 100644 --- a/apps/extension/tests/unit/profile/normalize-profile.test.ts +++ b/apps/extension/tests/unit/profile/normalize-profile.test.ts @@ -77,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([]); + }); }); From 9d344fd38b305383bab5658c0fd62f38a26214fc Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Thu, 9 Jul 2026 15:52:27 +0200 Subject: [PATCH 4/4] fix(e2e): update tests for redesigned CV page and LinkedIn import flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CV page was completely redesigned in PR #198: - Changed heading from 'Préparer le même profil partout' to 'CV & expériences' - Replaced preview-based LinkedIn flow with direct import + toast notifications - Simplified UI: 'Prévisualiser LinkedIn' → 'Importer LinkedIn' button Test changes: 1. Updated openCvPage() helper to expect new heading 2. Rewrote LinkedIn import tests to match new direct import flow 3. Fixed Settings profile tab test to use exact: true for 'Profil' heading (avoids matching other headings containing 'profil' substring) Updated bridge mock to use IMPORT_LINKEDIN_PROFILE message type. All 4 previously failing tests now pass locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/e2e/linkedin-import.test.ts | 60 +++++++------------ apps/extension/tests/e2e/settings.test.ts | 4 +- 2 files changed, 24 insertions(+), 40 deletions(-) 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 }) => {