SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
5.5 KB · 179 lines typescript
Raw Blame History
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