Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/src/dev/chrome-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ function createChromeStubs() {
case 'SYNC_LINKEDIN_PROFILE_IMPORT': {
const draft = (message.payload as { profile: CanonicalCandidateProfileDraft }).profile;
const current = readDevStorage<UserProfile>(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 });
Expand Down
49 changes: 49 additions & 0 deletions apps/extension/src/dev/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,55 @@ export const mockProfile: UserProfile = {
remote: 'hybrid',
seniority: 'senior',
jobTitle: 'Développeur Fullstack',
experiences: [
{
id: 'exp-mock-1',
title: 'Lead Frontend',
company: 'Fintech Scale-up',
location: 'Paris',
startDate: '2023-03',
endDate: null,
isCurrent: true,
description:
'Refonte de la plateforme client en Svelte 5, mise en place du design system et accompagnement d’une équipe de 4 développeurs.',
skills: ['Svelte', 'TypeScript', 'Vite', 'TailwindCSS'],
source: 'manual',
sourceExternalId: null,
positionIndex: 0,
updatedAt: 1710000000000,
},
{
id: 'exp-mock-2',
title: 'Développeur Fullstack',
company: 'Agence Web Lyon',
location: 'Lyon',
startDate: '2020-09',
endDate: '2023-02',
isCurrent: false,
description:
'Conception et développement de dashboards SaaS pour des clients B2B, API Node.js + React.',
skills: ['React', 'Node.js', 'PostgreSQL', 'Docker'],
source: 'linkedin',
sourceExternalId: 'li-123',
positionIndex: 1,
updatedAt: 1710000000000,
},
{
id: 'exp-mock-3',
title: 'Développeur Frontend',
company: 'Startup SaaS',
location: 'Remote',
startDate: '2018-01',
endDate: '2020-08',
isCurrent: false,
description: 'Intégration d’une bibliothèque de composants et migration Angular vers React.',
skills: ['Angular', 'React', 'TypeScript'],
source: 'linkedin',
sourceExternalId: 'li-456',
positionIndex: 2,
updatedAt: 1710000000000,
},
],
};

const stacks = [
Expand Down
34 changes: 34 additions & 0 deletions apps/extension/src/dev/qa-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,39 @@ function buildCompleteProfile(): UserProfile {
remote: 'hybrid',
seniority: 'senior',
jobTitle: 'Développeur Fullstack',
experiences: [
{
id: 'exp-seed-1',
title: 'Lead Frontend',
company: 'Fintech Scale-up',
location: 'Paris',
startDate: '2023-03',
endDate: null,
isCurrent: true,
description:
'Refonte de la plateforme client en Svelte 5 et mise en place du design system.',
skills: ['Svelte', 'TypeScript', 'Vite', 'TailwindCSS'],
source: 'manual',
sourceExternalId: null,
positionIndex: 0,
updatedAt: 1710000000000,
},
{
id: 'exp-seed-2',
title: 'Développeur Fullstack',
company: 'Agence Web Lyon',
location: 'Lyon',
startDate: '2020-09',
endDate: '2023-02',
isCurrent: false,
description: 'Dashboards SaaS pour des clients B2B, API Node.js + React.',
skills: ['React', 'Node.js', 'PostgreSQL', 'Docker'],
source: 'linkedin',
sourceExternalId: 'li-123',
positionIndex: 1,
updatedAt: 1710000000000,
},
],
};
}

Expand All @@ -430,6 +463,7 @@ function buildIncompleteProfile(): UserProfile {
remote: 'hybrid',
seniority: 'senior',
jobTitle: '',
experiences: [],
};
}

Expand Down
6 changes: 5 additions & 1 deletion apps/extension/src/lib/core/backup/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ export function createBackup(
hidden: Record<string, number>,
timestamp: number
): BackupData {
const normalizedProfile: UserProfile = {
...profile,
experiences: profile.experiences ?? [],
};
return {
version: CURRENT_BACKUP_VERSION,
timestamp,
profile,
profile: normalizedProfile,
settings,
favorites,
hidden,
Expand Down
231 changes: 231 additions & 0 deletions apps/extension/src/lib/core/cv/experience-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/**
* Pure helpers for the CV experience feed and cross-platform sync.
*
* STRICTLY PURE: no Date, no async, no I/O, no side effects. Non-deterministic
* values (`now`, id generation) are injected by the shell caller.
*
* See `apps/extension/src/models/cv-experience-sync.model.md` for the state
* machine that consumes these helpers.
*/
import type { Experience, ExperienceSource } from '../types/profile';
import type { CandidateExperienceDraft } from '../profile-extractors/types';

/** A platform target for the sync push (LinkedIn + mission connectors). */
export interface PlatformSyncTarget {
id: string;
name: string;
profileUrl: string;
}

/** Build the per-platform text payload (same CV block for every target). */
export function buildPlatformPayloads(
experiences: readonly Experience[],
targets: readonly PlatformSyncTarget[]
): Map<string, string> {
const payload = formatExperiencePayload(experiences);
const map = new Map<string, string>();
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<Experience, 'startDate' | 'endDate' | 'isCurrent'>
): 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<Experience> & { title: string },
now: number,
generateId: () => string
): Experience {
const title = draft.title.trim();
const isCurrent = draft.isCurrent ?? false;
const endDate = isCurrent ? null : (trimToNull(draft.endDate) ?? null);
const source: ExperienceSource = draft.source ?? 'manual';

return {
id: draft.id ?? generateId(),
title,
company: trimToNull(draft.company) ?? null,
location: trimToNull(draft.location) ?? null,
startDate: trimToNull(draft.startDate) ?? null,
endDate,
isCurrent,
description: (draft.description ?? '').trim(),
skills: (draft.skills ?? []).map((s) => s.trim()).filter((s) => s.length > 0),
source,
sourceExternalId: draft.sourceExternalId ?? null,
positionIndex: draft.positionIndex ?? 0,
updatedAt: now,
};
}

/**
* Recompute gapless `positionIndex` (0 = most recent). Sorts by `startDate`
* descending; entries without a start date keep their relative order at the
* end. Stable: ties preserve the input order.
*/
export function recomputePositionIndex(experiences: readonly Experience[]): Experience[] {
const indexed = experiences.map((exp, originalIndex) => ({ exp, originalIndex }));
indexed.sort((a, b) => {
const sa = a.exp.startDate ?? '';
const sb = b.exp.startDate ?? '';
if (sa !== sb) {
return sb < sa ? -1 : 1; // descending start date
}
return a.originalIndex - b.originalIndex; // stable tiebreak
});
return indexed.map(({ exp }, i) => ({ ...exp, positionIndex: i }));
}

/**
* Merge imported draft experiences into the current persisted list.
*
* Dedup key: `(company, title, startDate)` case-insensitively. On match, the
* local entry is kept (id, positionIndex, source, description when manual) and
* its skills are unioned with the draft's. New drafts become `source: 'linkedin'`
* entries with a `now`-seeded id. The result is position-indexed via
* {@link recomputePositionIndex}.
*/
export function mergeExperiences(
current: readonly Experience[],
incoming: readonly CandidateExperienceDraft[],
now: number
): Experience[] {
const result: Experience[] = current.map((exp) => ({ ...exp, skills: [...exp.skills] }));

incoming.forEach((draft, importIndex) => {
// Normalize imported dates (LinkedIn yields YYYY-MM-DD) to the canonical
// YYYY-MM month format so they dedupe against manual entries and are valid
// when edited through the month input.
const draftStart = normalizeDateToMonth(draft.startDate);
const draftEnd = normalizeDateToMonth(draft.endDate);
const key = experienceKey(draft.company, draft.title, draftStart);
const existingIdx = result.findIndex(
(exp) => experienceKey(exp.company, exp.title, exp.startDate) === key
);

if (existingIdx >= 0) {
const existing = result[existingIdx];
const keepDescription = existing.source === 'manual' || draft.description.length === 0;
const mergedIsCurrent = existing.isCurrent || draft.isCurrent;
result[existingIdx] = {
...existing,
skills: unionSkills(existing.skills, draft.skills),
description: keepDescription ? existing.description : draft.description,
location: existing.location ?? draft.location,
endDate: mergedIsCurrent ? null : (existing.endDate ?? draftEnd ?? null),
isCurrent: mergedIsCurrent,
sourceExternalId: existing.sourceExternalId ?? draft.sourceExternalId,
};
return;
}

result.push({
id: `exp-${now}-${importIndex}`,
title: draft.title,
company: draft.company,
location: draft.location,
startDate: draftStart,
endDate: draft.isCurrent ? null : draftEnd,
isCurrent: draft.isCurrent,
description: draft.description,
skills: [...draft.skills],
source: 'linkedin',
sourceExternalId: draft.sourceExternalId,
positionIndex: draft.positionIndex,
updatedAt: now,
});
});

return recomputePositionIndex(result);
}

/**
* Normalize a date string to the canonical `YYYY-MM` month format used by
* {@link Experience}. Accepts `YYYY-MM`, `YYYY-M`, `YYYY-MM-DD`, and `YYYY-M-D`.
* Unknown formats are returned trimmed (unchanged) so manual entries are not
* corrupted. Returns null for empty/whitespace input.
*/
export function normalizeDateToMonth(date: string | null | undefined): string | null {
if (!date) {
return null;
}
const trimmed = date.trim();
if (trimmed.length === 0) {
return null;
}
const match = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?$/.exec(trimmed);
if (!match) {
return trimmed;
}
return `${match[1]}-${match[2].padStart(2, '0')}`;
}

function experienceKey(company: string | null, title: string, startDate: string | null): string {
return `${(company ?? '').toLowerCase()}|${title.toLowerCase()}|${startDate ?? ''}`;
}

function unionSkills(current: readonly string[], incoming: readonly string[]): string[] {
const result = [...current];
for (const skill of incoming) {
const trimmed = skill.trim();
if (trimmed.length === 0) {
continue;
}
if (!result.some((existing) => existing.toLowerCase() === trimmed.toLowerCase())) {
result.push(trimmed);
}
}
return result;
}

function trimToNull(value: string | null | undefined): string | null {
if (value === null || value === undefined) {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
Loading
Loading