/** * ============================================================================= * Job·Ka — Groupe KA * Auteur : Simon-Pierre Boucher * Contact : contact@spboucher.ai * Fichier : frontend/src/api.ts * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py * Créé : 2026-08-17 Modifié : 2026-08-25 * ============================================================================= */ export interface Job { uid: string; source: string; external_id: string; url: string; employer: string; title: string; description?: string; address: string; city: string; region: string; postal_code: string; location_label: string; work_mode: string | null; employment_type: string | null; salary_min: number | null; salary_max: number | null; salary_unit: string | null; salary_label: string; salary_year_min: number | null; salary_year_max: number | null; salary_hour_min: number | null; salary_hour_max: number | null; benefits: string[]; requirements: JobRequirements; date_posted: string | null; date_deadline: string | null; category: string; ats: string; details: Record; lat: number | null; lng: number | null; active: number; dup_sources?: string[]; is_direct?: boolean; company_logo?: string | null; language?: string | null; // fr | en | bilingue apply_url?: string | null; // candidature directe (≠ url de la fiche) title_clean?: string | null; // titre d'affichage normalisé seniority?: string | null; // stage | junior | intermediaire | senior | direction first_seen?: number | null; // epoch : première collecte par Job·Ka // fiche seulement (GET /api/jobs/{uid}) : salary_context?: SalaryContext | null; employer_jobs?: number; // offres actives du même employeur salary_history?: SalaryPoint[]; // personnalisation KA ID : posé par le serveur quand le score personnel // est net (voir jobka/kaid.py) — affiche « Recommandé pour vous » ka_reco?: { score: number; reasons: string[] } | null; } /** Exigences structurées extraites de l'offre (clés présentes si explicites). */ export interface JobRequirements { experience_years?: number; // années d'expérience minimales exigées education?: string; // plus bas diplôme exigé (DEC, baccalauréat…) languages?: string[]; // langues exigées [k: string]: unknown; // champs bruts hérités des ATS } /** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */ export interface SalaryContext { category: string; n: number; // offres avec salaire dans la catégorie p25: number; median: number; p75: number; job_mid?: number; // point médian de la fourchette de l'offre delta_pct?: number; // écart % vs médiane de la catégorie } export interface SalaryPoint { ts: number; s_min: number | null; s_max: number | null; unit: string | null; } export interface JobList { total: number; count: number; jobs: Job[]; } export interface Facets { cities: string[]; regions: { region: string; n: number }[]; languages: { language: string; n: number }[]; categories: { category: string; n: number }[]; employers: { employer: string; n: number }[]; work_modes: string[]; employment_types: string[]; seniorities: { seniority: string; n: number }[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; careers_url: string; sectors: string[]; connector: string; status: string; region: string; active_jobs: number; last_sync: number | null; } export interface Stats { total: number; employers: number; sources: number; with_salary: number; avg_salary_year: number | null; remote: number; geocoded: number; direct_jobs?: number; direct_employers?: number; direct_sources?: number; portal_jobs?: number; duplicates_hidden?: number; top_cities: { city: string; n: number }[]; categories: { category: string; n: number }[]; recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string; }[]; } async function get(path: string, params?: Record): Promise { const url = new URL(path, window.location.origin); if (params) { for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); } } const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 20000); try { const resp = await fetch(url.toString(), { signal: ctl.signal }); if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`); return (await resp.json()) as T; } finally { clearTimeout(timer); } } export interface JobFilters { q?: string; city?: string; region?: string; category?: string; work_mode?: string; employment_type?: string; seniority?: string; language?: string; salary_min?: number; with_salary?: number; sort?: string; } export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) => get("/api/jobs", { ...filters, limit, offset }); export const fetchJob = (uid: string) => get(`/api/jobs/${uid}`); export const fetchFacets = () => get("/api/facets"); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); export const geojsonUrl = (filters: JobFilters) => { const url = new URL("/api/jobs.geojson", window.location.origin); for (const [k, v] of Object.entries(filters)) { if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); } return url.toString(); }; // --- présentation ------------------------------------------------------------ const UNIT_FR: Record = { hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an", }; export function formatSalary(job: Pick): string | null { if (job.salary_min == null || !job.salary_unit) return null; const fmt = (n: number) => n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit; if (job.salary_max != null && job.salary_max > job.salary_min) { return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`; } return `${fmt(job.salary_min)} $ / ${unit}`; } export const MODE_FR: Record = { presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail", }; export const TYPE_FR: Record = { temps_plein: "Temps plein", temps_partiel: "Temps partiel", contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier", }; export const LANG_FR: Record = { fr: "Français", en: "Anglais", bilingue: "Bilingue", }; export const SENIORITY_FR: Record = { stage: "Stage", junior: "Junior", intermediaire: "Intermédiaire", senior: "Senior", direction: "Direction", }; /** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */ export function compactMoney(n: number): string { if (n >= 1000) { const k = n / 1000; return `${k.toLocaleString("fr-CA", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`; } return `${n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`; } /** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */ export function yearlyEquiv(job: Pick): string | null { if (job.salary_year_min == null || job.salary_unit === "year") return null; const lo = compactMoney(job.salary_year_min); const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min ? ` – ${compactMoney(job.salary_year_max)}` : ""; return `≈ ${lo}${hi}/an`; } /** Jours écoulés depuis une date ISO (null si absente ou invalide). */ export function daysSince(iso: string | null | undefined): number | null { if (!iso) return null; const d = new Date(`${iso}T12:00:00`).getTime(); if (Number.isNaN(d)) return null; return Math.max(0, Math.round((Date.now() - d) / 86400000)); } /** « aujourd'hui », « hier », « il y a N jours ». */ export function agoLabel(days: number): string { if (days === 0) return "aujourd'hui"; if (days === 1) return "hier"; return `il y a ${days} jours`; } /** Titre d'affichage : version normalisée si disponible, sinon le titre source. */ export const displayTitle = (job: Pick) => job.title_clean || job.title; export function formatDate(iso: string | null): string { if (!iso) return ""; const d = new Date(`${iso}T12:00:00`); return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }); }