spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
1// -----------------------------------------------------------------------------2// Forma-Ka — Agrégateur de formations (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + client API robuste (timeout, erreurs typées)5// -----------------------------------------------------------------------------67export interface FormationDetails {8 uec?: number;9 credits?: number;10 level?: string;11 duration_hours?: number;12 price_from?: boolean;13 modes_offerts?: string[];14 [k: string]: unknown;15}1617export interface Formation {18 uid: string;19 source: string;20 external_id: string;21 url: string;22 title: string;23 training_type: string; // Cours universitaire, Séminaire, Atelier…24 category: string; // domaine : Informatique, Gestion, RH…25 mode: string; // en ligne | présentiel | hybride | asynchrone26 city: string;27 language: string; // fr | en | fr/en28 price: number | null; // null = non affiché (normal pour l'universitaire)29 price_label: string;30 is_free: boolean | null;31 duration: string;32 duration_hours: number | null;33 start_date: string | null; // ISO34 schedule_label: string;35 sessions: string[]; // toutes les dates offertes (ISO)36 level: string;37 credits: string; // « 3 crédits », « 1,4 UEC »38 credential: string;39 instructor: string;40 code: string; // sigle (ex. « GSF-1020 »)41 description: string;42 objectives: string[];43 prerequisites: string;44 audience: string;45 program: string[]; // plan / contenu46 tags: string[];47 details: FormationDetails;48 images: string[];49 price_history?: { ts: number; price: number | null }[];50 similar?: {51 uid: string; title: string; training_type: string; source: string;52 price: number | null; mode: string; duration: string;53 }[];54 first_seen?: number;55 last_seen: number;56 updated_at: number;57 active: number;58}5960export interface Facets {61 types: { t: string; n: number }[];62 categories: { category: string; n: number }[];63 modes: string[];64 cities: string[];65 languages: string[];66 levels: string[];67 sources: { source: string; n: number }[];68}6970export interface Source {71 id: string;72 name: string;73 url: string;74 listing_url: string;75 type_offre: string;76 connector: string | null;77 status: string;78 region: string;79 active_formations: number;80 last_sync: number | null;81}8283export interface Stats {84 total: number;85 gratuites: number;86 en_ligne: number;87 universitaires: number;88 sources: number;89 avg_price: number | null;90 avg_hours: number | null;91 par_type: { t: string; n: number }[];92 recent_syncs: {93 source: string; ts: number; found: number; added: number;94 updated: number; removed: number; ok: number; message: string;95 }[];96}9798const SOURCE_NAMES: Record<string, string> = {};99100export function registerSourceNames(sources: Source[]) {101 for (const s of sources) SOURCE_NAMES[s.id] = s.name;102}103export function sourceName(id: string): string {104 return SOURCE_NAMES[id] ?? id;105}106107async function get<T>(path: string): Promise<T> {108 const ctrl = new AbortController();109 const timer = setTimeout(() => ctrl.abort(), 20000);110 try {111 const res = await fetch(path, { signal: ctrl.signal });112 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);113 return (await res.json()) as T;114 } finally {115 clearTimeout(timer);116 }117}118119export interface FormationFilters {120 training_type?: string;121 category?: string;122 mode?: string;123 city?: string;124 language?: string;125 level?: string;126 source?: string;127 free?: string; // "1" = gratuites128 price_max?: string;129 credential?: string;130 starts_after?: string; // ISO131 q?: string;132 sort?: string; // recent | price | title | start133 limit?: string; // taille de page134 offset?: string; // décalage (pagination)135}136137export function fetchFormations(f: FormationFilters) {138 const params = new URLSearchParams();139 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);140 return get<{ total: number; formations: Formation[] }>(`/api/formations?${params}`);141}142143export const fetchFormation = (uid: string) =>144 get<Formation>(`/api/formations/${encodeURIComponent(uid)}`);145export const fetchFacets = (trainingType?: string) =>146 get<Facets>(`/api/facets${trainingType ? `?training_type=${encodeURIComponent(trainingType)}` : ""}`);147export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");148export const fetchStats = () => get<Stats>("/api/stats");149150/** null -> « Prix non affiché » (fréquent : cours universitaires) */151export const fmtPrice = (p: number | null, label?: string) => {152 if (p === 0) return "Gratuit";153 if (p != null)154 return p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";155 return label || "Prix non affiché";156};157158/** "2026-12-01" -> « 1ᵉʳ décembre 2026 » */159export function fmtDate(iso: string | null): string | null {160 if (!iso) return null;161 const [y, m, d] = iso.split("-").map(Number);162 if (!y || !m || !d) return null;163 const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", {164 day: "numeric", month: "long", year: "numeric",165 });166 return txt.replace(/^1 /, "1ᵉʳ ");167}168169/** 14 -> « 14 h », 3.5 -> « 3,5 h » */170export const fmtHours = (h: number | null): string | null =>171 h == null ? null : `${h.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} h`;172173export const MODE_ICONS: Record<string, string> = {174 "en ligne": "💻",175 "présentiel": "🏛",176 "hybride": "🔀",177 "asynchrone": "🕓",178};179