Imported from zid-web/planning-cardiomaine (
AGENTS.md). Install upstream withnpx skills add zid-web/planning-cardiomaine. Copyright stays with the author.
AGENTS.md
Cursor Cloud specific instructions
Next.js 16 (App Router, React 19) + Supabase app ("Cardiomaine Planning", a French
medical shift-scheduling tool). Package manager is bun (bun.lock). The single
required service is the Next.js dev server; the backend is Supabase.
Backend Render (IMPORTANT — ne pas confondre)
- Le vrai solveur / API est le dépôt séparé
github.com/zid-web/guard-api-cardiomaine(Renderguard-api-cardiomaine.onrender.com). - Le dossier
guard-api/dans ce repo (planning-cardiomaine) est un résidu : uniquement unREADME.mdde redirection. Ne pas y remettre un miroirsolver.py/ patches /rules_config.json— source de confusion. Toute évolution backend = PR / push surguard-api-cardiomaineuniquement. - Correctifs RYTHMO (créneaux précis + vendredi P/U selon
week_type) + allowlisthistorical_patterns+half_days_off: déjà fusionnés dansguard-api-cardiomaine(Claude + Cursor). Ne pas les retravailler ici.
Garde Nuit proposals / preferences
generateNightGuardProposals(lib/guard-scheduler.ts) : Mar–Dim (+ Lun si FV vacances). Préférences : Lun→U, Mar→M/W, Mer→S/U/P, Jeu→O/G, Ven→rotation B/G/A/P/Z/H/S (O/W/M exclus). Pas de garde la veille d’un NCT pour le médecin NCT (W/M, calendrierNCT_DATES_2026). Miroir soft/hard dansguard-api-cardiomaine(NIGHT_GARDE_*/ règles JSON).
Fixed clinical assignments & remplacant
- Règles fixes centralisées dans
lib/fixed-assignments.ts(applyFixedClinicalAssignments) : IRM = S (Lundi matin + Vendredi après-midi), FV Garde Nuit Lundi + Coro Jeudi apm, DAAS =Apm - EE2Lundi, Rythmo selon parité ISO (rythmoFixedSlotsForWeek) : impaire A Lun+Jeu apm, P Mar matin+apm, U Mer apm + Ven apm ; paire A Lun+Jeu apm, P Mar matin+apm, U Mer matin+apm, Ven matin alternance U/P. Visite = rotationU → A → B. DOC022 (ETT ped S mercrediApm - ETT salle 1, etc.). Cases vides seulement : une saisie manuelle différente du titulaire n’est jamais réécrasée (ex. remplacer S en ETT ped). Si le titulaire est en congés, la contrainte saute (case libre). Case vidée → le défaut peut revenir.getAllVacationsdoit échouer proprement (pas[]silencieux) pour ne pas écraser Congés/Rythmo. - Remplaçant texte libre : dans la modale d’affectation admin, champ « Remplaçant » → ajoute un libellé dans
cell.value(badge ambre). UtiliserisListedDoctor/normalizeRemplacantLabel(lib/doctor-code.ts) — hors équité / hors contrôle vacances. - Étiquettes variantes (
lib/special-activity-labels.ts) : affichage seulement (pas de nouvelle activité solveur).SmercrediApm - ETT salle 1→ badgeS (ped)/ « ETT pédiatrique » ;Amardi etPlundiApm - Cs PSS→A (PM)/P (PM)/ « Contrôle PM ». Compose avec doublon²viaformatDoctorWithDoublon. S mercredi : ETT ped compatible avec Garde Midi / ATL Midi (areCompatibleSamePeriod). - Stress + D (
lib/stress-rules.ts) : jamaisApm - Stressmercredi ni vendredi (cases grisées viaisCellBlocked/ refuscanAssignDoctorToSlot). D (externe echo PSS) : tous les jeudis Stress matin ; 1er jeudi du mois aussi Stress apm ; autres jeudis EE1 + EE2 apm. Soft si D en congés.
Choix de Gardes (WE & fériés)
- Un seul mécanisme : remplir des initiales, exactement comme le calendrier NCT — plus de demandes / validation / refus (ancienne table
guard_pickset ses server actions supprimées du code ; la table et les scripts SQLscripts/003/004restent en base, inutilisés). components/guard-calendar-dialog.tsx: vue Mois (grille lundi→dimanche ; seuls samedis, dimanches et jours fériés sont cliquables ; chaque case montre ☀ Matin et ☾ Nuit avec les initiales coloréesDOCTOR_COLORS) ⇄ vue Année (12 tuiles « n/N jours complets ») via le titre ; flèches ‹ › qui franchissent les années, ← → au clavier, « Aujourd'hui ». Férié = rose (prime sur le week-end), samedi bleu, dimanche gris, vacances scolaires zone B = ambre. Un jour sélectionné affiche les initiales des médecins pour Matin et Nuit ; un appui pose / retire l'initiale (bascule,toggleGuardDoctor), enregistrement immédiat.- Les initiales sont écrites directement dans les lignes
Garde Matin/Garde Nuitdu planning de la bonne semaine (setGuardAssignmentdansScheduleApp) : saisie admin protégée (manualAssignment, oumanuallyClearedsi vidée), ½ off de récupération après Garde Nuit recalculé comme dans la grille. Seuls les médecins quecanAssignDoctorautorise sont proposés (congés, repos après garde, CH exclu…) + ceux déjà posés ; infirmières exclues ; valeurs non listées (remplaçant texte libre) affichées sans être modifiables ici. - Admin uniquement pour modifier ; les autres rôles consultent (initiales en lecture seule). Logique pure testée :
lib/guard-calendar.ts(guardDayKind,guardDatesInMonth,toggleGuardDoctor,guardFill).
Vues Aujourd’hui / Semaine
- UI moderne dans
components/today-view.tsxetcomponents/week-view.tsx(montées parScheduleApp). - Notes du jour (onglet Aujourd’hui) : carte cliquable → modale texte →
saveScheduleToDbviaupdateSchedulesur la ligneNotes du jour. - Toolbar admin : Journal = audit
schedule_history(qui a changé quoi). Boutons outline : forcer!text-slate-900+ fond blanc (sinon icônes transparentes). - Pages admin (
/protected/admin/users|requests|feedback) : le layout racine estoverflow-hidden— chaque page doit scroller avech-full overflow-y-auto(sinon liste tronquée / emails invisibles). - Couleurs / modales : dans
app/globals.css, les tokens doivent être des couleurs CSS complètes (hsl(...)), pas des triplets HSL nus — sinonbg-card/bg-background→ transparent (Notes du jour, Cards). Dialogs/Cards/Textarea : préférerbg-white text-slate-900en secours.
Congés (ligne unique absences)
- Une seule ligne UI Congés (plus de ligne « Vacances »).
normalizeLeaveSchedule(lib/vacation-congés-mapper.ts) : fusion legacy Vacances→Congés, remplissage depuisdoctor_vacations, puis retrait des absents de toutes les autres lignes (y compris1/2 journée off Matin/Après-midi). resolveRowKeymappeVACANCESetCONGE→Congés.detectConflict/canAssignDoctorn’appliquent pas le rouge conflit sur la ligne Congés — badges =DOCTOR_COLORS.- Solveur Render : n’émettre que
CONGE(dansguard-api-cardiomaine, pas le dossier local). - Header admin Congé →
VacationsModal: liste complètedoctor_vacations(filtre médecin) + ajout / modification / suppression (vacation-actions.ts). Refresh viaonVacationsUpdated→loadVacationsdansScheduleApp. populateCongesRowFromVacationsreconstruit la ligne Congés depuisdoctor_vacations(ajout et retrait). Modifier/supprimer des dates se répercute immédiatement sur le planning affiché ; persistancesource: "constraints"sur les semaines déjà en mémoire (~150 ms).- Écriture congés :
addVacation/updateVacationn’écrivent quedoctor_id+ dates ; le motifreasonest patché en best-effort (projets Supabase sans colonne → erreur PostgREST « schema cache »). Migration20250726000000_doctor_vacations_reason_column.sqlpour l’ajouter côté SQL.
1/2 journée off après Garde Nuit
- Règle (
lib/half-day-off.ts) : après Garde Nuit → apm du lendemain (sauf vendredi et week-end). Si off habituel apm → matin. Vendredi nuit → pas de ½ off samedi (Sam Garde Matin = Ven Nuit). Dimanche → lundi semaine suivante. - ½ off habituels (alignés
rules_config.jsonde guard-api-cardiomaine) : Lundi matinR,K+ amK; Mardi amS; Mercredi amM,W,G,Z,H,B; Jeudi amU,P; Vendredi matinK+ amO,A,K,R,T.
Contraintes structurelles vs « Générer »
- Rotation LFB jeudi : H → S → G (modulo 3 sur le n° ISO), source unique
LFB_POOL/lfbDoctorForWeekNum(lib/week-generation-params.ts), importée parapply-structural-constraintsetschedule-utils. L'ordre était auparavant dupliqué dans trois fichiers avec deux valeurs différentes (G,S,HvsH,S,G) : lelfb_doctorenvoyé au solveur ne désignait pas le médecin réellement posé, deux semaines sur trois. - Consignes groupe DOC022 (PDF fonctionnement / répartition) : extraites dans
docs/CONSIGNES-GROUPE-DOC022.md+lib/group-clinical-rules.ts. Noms médecins dansDOCTOR_METADATA, créneaux fixes ETT/EE/Scinti viaapplyFixedClinicalAssignments(dont T toujours sur EE1 le mercredi après-midi, consigne 26/08/2026), éligibilités envoyées enrules_overrideà/generate-week. Addon JSON :patches/rules_config-doc022-addon.json(à merger dansguard-api-cardiomainesi besoin de persistance serveur). Durées cs 20/30 min = hors grille planning. - Toujours injectées (sans Générer) via
applyStructuralConstraints(lib/apply-structural-constraints.ts) : IRM/FV/DAAS/Rythmo/Visite, ½-off habituelles, récupération garde nuit, Congés + strip absents, NCT calendrier, LFB, CH, ATL↔Coro, couplages weekend Garde/ATL. Appliqué à l’affichage + persisté (source: "constraints", debounce). - Typecheck :
npm run typecheck(tsc --noEmit).tsconfig.jsonfixe une liste explicite detypes(node,react,react-dom) — sans elle TS inclut tous les@types/*du projet, dont le stub vide@types/minimatchtiré transitivement, échoue surTS2688et abandonne avant de vérifier le moindre fichier : la commande ne rapportait alors plus aucune erreur, y compris sur du code manifestement invalide. Le dépôt est à zéro erreur : le typecheck peut être branché en CI. - Blocages assignation (
lib/slot-blocking.ts) : jamais de médecin en congés hors ligne Congés ; ½-off Matin ⇒ pas d’activité matin ; ½-off Apm ⇒ pas d’activité apm ; 1 tâche / créneau matin|apm (sauf ATL+Coro, ETT salle1+salle2, EE1+EE2, et Garde Matin + I → Cs/ETT/EE matin autorisés, pas Coro/Rythmo/Rééducation) ; pas de cumul Cs PSS+Tessée ; LFB/CDL vs garde : le blocage du même jour ne porte plus que sur le créneau qui se chevauche (offSiteBlocksGardeSameDay) — hors site le matin ⇒ Garde Midi et Garde Nuit restent possibles ; hors site en journée entière ⇒ toutes les gardes bloquées, comme avant. Le lendemain d'une garde reste interdit quel que soit le créneau (repos post-garde) ; CDL mardi = matin seulement (O / V) → l’après-midi reste assignable (Astreinte ATL Midi + Coro apm), comme l’IRM lundi matin / vendredi apm. Doublon : Cs = 2× dans la même case →B²; ETT et EE = les deux salles du créneau →S²/G². Interne I : Garde Matin uniquement ; S+I peut aussi rester sur IRM. CH : astreintes ATL uniquement — jamais Garde Matin/Midi/Nuit. Garde week-end + remplaçant : toujours autoriser l’association avec un médecin listé (merge solveur préserve le remplacant). Val en ETT : toujours ETT salle 2, jamais salle 1 (canNurseTakeRow,NURSE_FORBIDDEN_ROWS) — ETT Tessé reste ouvert. IRM strictement réservée à S (IRM_DOCTOR) : refusée à tout autre médecin, et case grisée quand S est en congés (isIrmSlotClosed, la case n'a alors aucun candidat). Vacations non bloquantes (NON_BLOCKING_ROWS) : Entrées PSS, Pré-op et Matin - Visite (B/U/A — pas deApm - Visitedans la grille) n'occupent pas le créneau — le médecin qui y figure reste assignable à une autre tâche la même demi-journée ; congés et ½-off continuent de s'appliquer. Garde Nuit Lun-Ven : jamais deux nuits consécutives (adjacentWeekdayNightGuard) — le week-end est exempt (Ven Nuit → Sam Matin est un enchaînement voulu). UI :canAssignDoctor(..., { schedule, day })+applySlotBlockingStrips. - Infirmières (Val / Véro / Laura) (
lib/nurse-rules.ts, listeNURSESdanslib/constants.ts) : jamais préfixées « Dr. » à l’affichage — tout rendu d’un intervenant passe parformatPersonLabel(lib/doctor-code.ts), qui renvoieDr. Xpour un médecin et le nom seul pour une infirmière ou pour CH (structure externe, pas une personne). plannings fixes par parité de semaine, binôme médecin obligatoire sur Stress/EE (STRESS_PARTNER_POOL/EE_PARTNER_POOL), Val seule autorisée sur ETT2 / ETT Tessé. Véro est au Stress tous les mardis et mercredis matin (les deux parités — elle n’est plus sur EE1 le mardi matin en semaine impaire) ; Val est à l’ETT Tessé les mardis et mercredis matin dans les deux parités (elle n’a plus le tour de Stress du mardi impair ni EE1 le mercredi matin) et garde EE1 jeudi matin (seul EE1 matin ouvert). Laura en congés → repli systématique sur Val (NURSE_ABSENCE_FALLBACK, appliqué dansapplyNurseFixedAssignments) : Val est libérée de ses autres vacations fixes de la même demi-journée. - Cases fermées (
lib/closed-slots.ts, point d’entrée uniqueisSlotClosed) : Stress mercredi / vendredi apm (historique) et EE1 matin fermée lundi, mardi, mercredi, vendredi — seul le jeudi matin reste ouvert (consigne 26/08/2026 ; le matin, tout passe par EE2). Utilisé parcanAssignDoctorToSlot(refus + message),isCellBlocked(case grisée UI) etapplyClosedSlotsClear(vidage structurel). - Visite hebdomadaire (
lib/visite-rotation.ts) : la ligneMatin - Visiteest une vacation de semaine (rotation U/A/B). Un changement manuel d'initiales sur une case se reporte du lundi au vendredi (spreadVisiteAcrossWeek, branché danspatchSelectedCell). Le report est désactivable case par case : un interrupteur « Reporter sur toute la semaine » dans la modale (actif par défaut) permet à l'admin de ne modifier que le jour en cours — l'exception ainsi posée survit aux contraintes structurelles (la rotation Visite ne remplit que les cases vides). Deux garde-fous : un jour où le médecin a une contrainte le matin (congés, ½-off matin) est laissé vide au lieu de recevoir une affectation impossible, et une casemanuallyClearedn'est jamais re-remplie. La visite ne concerne que le matin et reste non bloquante : le médecin est assignable l'après-midi comme sur une autre tâche du matin. - Créneau des vacations hors site (
lib/off-site-slots.ts) : chaque case « Hors site - … » porte son créneau réel dansCellData.offSiteSlot(matin / apm / journée), modifiable case par case depuis la modale (sélecteur) et signalé dans la grille par un badge M / AM / J.periodOfRow(row, day, schedule)le lit pour décider de la disponibilité : hors site le matin ⇒ l’après-midi reste assignable, et inversement. Le sélecteur est actif : changer le créneau applique la conséquence immédiatement viaapplyOffSiteSlotRestriction— passer à « Journée » retire les vacations de la demi-journée nouvellement couverte, passer à « Matin » / « Après-midi » libère l’autre. Gardes et astreintes ne sont jamais retirées en silence : elles sont remontées dansconflictset signalées à l’admin. Sans choix explicite,DEFAULT_OFF_SITE_SLOTSs’applique — IRM S lundi matin + vendredi apm, CDL mardi matin, Scinti lundi/mardi/mercredi matin, LFB / PSSL / NCT journée entière (indisponible ce jour-là). Les plannings déjà enregistrés gardent donc leur comportement à l’identique. - Préférences de vacation souples (
lib/vacation-preferences.ts) — « souvent » / « rarement », distinctes des créneaux fixes et des blocages durs. Consignes 26/08/2026 : K souvent au Stress mardi matin (binôme Véro), K rarement au Stress mercredi matin, S souvent au Stress vendredi matin sauf lendemain de garde de nuit ou congés, H souvent en EE2 lundi matin sauf congés (remplace le créneau fixe DOC022 « EE2 matin réservé Dr Lefebvre », retiré), R souvent en EE2 mercredi matin sauf congés (sa Scinti du mardi matin est inchangée), O souvent en EE2 vendredi matin sauf congés. EE2 matin n’a plus aucun créneau fixe : les réservations DOC022 « Dr Lefebvre » (lundi) et « Dr Bros » (vendredi) sont devenues des préférences souples. Deux points d’application :applyPreferenceBiasbiaisehistorical_patternsavant/generate-week(souvent→ fréquence au-dessus du meilleur autre candidat ;rarement→ retiré deseligible_doctorssauf s’il est le seul candidat), etpreferredPartnerForNurseSlotoriente le médecin proposé en binôme dansensureNurseDoctorBinomeProposals. - Astreintes ATL :
- Gardes Lun-Ven : Garde Matin / Midi / Nuit reviennent au même médecin (
applyWeekdayGardeCoupling, ancré sur la Garde Nuit). Couplage souple : seules les cases vides sont remplies, toute exception saisie à la main est conservée. FV est exclu du couplage (GARDE_COUPLING_EXCLUDED) — externe, il ne fait que Garde Nuit lundi + Coro jeudi apm ; propager sa nuit occuperait la Garde Matin/Midi du lundi. - Pool général : M, O, W, CH. FV : uniquement ATL Midi jeudi (= miroir Apm Coro jeudi) — jamais Matin, Nuit, ni les autres AM. Front :
isAtlEligibleForCell+ sync Coro↔ATL. Payload solveurastreinte_allowed= M/O/W/CH (FV ATL jeudi via contraintes structurelles, pas de vars ASTREINTE globales FV). rules_config.jsonRender : ne jamais mettre FV dansastreinte_allowed(ça recrée le bug « Aucune solution »). Canonical :patches/rules_config-cardiomaine-canonical.json+ patchpatches/guard-api-rules-astreinte-without-fv.patch. Coro garde["W","M","O","FV"].- « Aucune solution trouvée » : souvent
astreinte_allowedavec FV global + couplage ATL=Coro comptant CORO+ASTREINTE ×2. Warning LFB indépendant. Patch exclusivité :patches/guard-api-atl-coro-slot-exclusivity.patch.
- Gardes Lun-Ven : Garde Matin / Midi / Nuit reviennent au même médecin (
- ATL Matin/Midi Lun–Ven = Coro pour M/O/W ; pour FV seulement jeudi Apm.
applyAtlFollowsCoroConstraintsunifie. Cumul autorisé dansslot-blocking.- Nuits + weekend (cycle 2 sem., source unique
lib/astreinte-cycle.ts—isWomAstreinteWeek/isChAstreinteWeek/chNightWeekdaysForWeek) : semaine CH = CH Lun/Mar/Ven nuit + weekend ATL entier ; W/O/M = Mer/Jeu nuit. semaine WOM = W/O/M Lun/Mar/Ven nuit + weekend ATL (mono/combo habituels) ; CH = Mer/Jeu nuit. Jusqu'à 2026 : semaine CH = n° ISO impair. À partir de 2027-W01 : roulement inversé (S1 2027 = semaine WOM, alternance comptée depuis la S1 2027 — pas par parité, pour survivre aux années à 53 semaines). Ne pas utiliserisOddIsoWeekpour ce roulement (il reste pour Rythmo / infirmières / Coro vendredi, inchangés). Le solveur reçoitastreinte_week_type(1 = CH, 2 = WOM) en plus deweek_type; patch backend à appliquer surguard-api-cardiomaine:patches/guard-api-astreinte-cycle-2027.patch(sans lui, « Générer » propose l'ancien roulement en 2027 — les contraintes structurelles corrigent CH mais les nuits W/O/M Lun/Mar/Ven restent à saisir). WeekendASTREINTEmappe vers Astreintes ATL Matin (pas Garde). - Roulement CH modifiable :
applyChAstreinteConstraintsne réécrit jamais une case ATL Nuit / ATL week-end saisie par l'admin (CellData.manualAssignment, posé parpatchSelectedCell) ni vidée (manuallyCleared) — échange ponctuel CH ↔ W/O/M possible (ex. W fait le mercredi de CH). Sans saisie admin : CH ajouté sur ses nuits, retiré des autres. CH reste toujours retiré des Gardes et des ATL Matin/Midi en semaine. - Ven ATL Nuit = Sam ATL Nuit (même médecin, soft) —
applyFriSatAtlNightCoupling. - Weekend ATL WOM (
lib/weekend-wom-rules.ts, semaines paires) :- Combo (exactement 5 / semestre, calendrier
WOM_COMBO_WEEK_KEYS_OVERRIDE/ indices) : rôle A = Ven ATL Nuit + Sat ATL + Garde Dim ; rôle B = Garde Sam + Sun ATL (A/B = rôles, pas le code médecin « A »). Presets :lib/weekend-wom-presets.ts(ex. W40/48 mono ; W42 O+M ; W44 W+O ; W52 special M Jeudi nuit + Ven matin/midi/nuit — force M même si CH structurel). Remplissage week-end : jamais un médecin en vacances ; saisie manuelle (médecin disponible) non écrasée ; cases vides seulement pour la consigne. Après strip congés (étape 10bis), pas de réinjection de l’absent (ex. W32 8–9/08/2026) — sinon M réapparaît et bloque l’édition manuelle. - Solveur « Générer » : sur semaines combo uniquement,
generateGuardsViaAPIenvoieweekend_astreinte_combo+ ancres (lib/weekend-combo-solver.ts/buildWeekendComboSolverFields).last_combo_garde_doctor/datelus/écrits danssettings(après Générer + validation Garde Sam) pour l’espacement 15 j. — lire le résultat réel, pas seulement l’ancre. - Mono (autres week-ends WOM) : Sat+Sun ATL Matin/Midi/Nuit = un seul M/O/W (équité 6 mois), vacances respectées.
- Soft fill only (cases vides / absents retirés) — override admin disponible conservé.
- Combo (exactement 5 / semestre, calendrier
- Weekend ATL jour : couplage souple Matin/Midi/Nuit —
applyWeekendGardeAtlCoupling. - Pas de nuits ATL consécutives Lun–Ven pour W/O/M (weekend et CH exempts) — solveur §5quinquies ; dérogation = saisie admin /
existing_schedule. generateGuardsViaAPIdériveweek_typedu n° de semaine ISO (ne plus laisser le défaut 1).
- Nuits + weekend (cycle 2 sem., source unique
- Weekend Garde (
applyWeekendGardeAtlCoupling+ WOM rules) — saisie manuelle possible ; soft suggestions :- Sam : un médecin Matin → Midi → Nuit si cases vides (priorité Matin).
- Dim Matin = Midi = Nuit (soft).
- Sam Matin dérivé de Ven Garde Nuit + Sam Midi si Matin vide.
- Combo WOM : Garde Sam/Dim complémentaires à l’ATL (voir ci-dessus).
- Ne jamais réécraser une case déjà pourvue.
- Patch solveur historique :
patches/guard-api-weekend-garde-atl-rythmo.patch; notes combo WOM :patches/guard-api-weekend-wom-combo.md
- « Générer » = propositions
pending(gardes/astreintes WOM/Coro/…) à valider admin. Lignes :GENERATOR_PROPOSAL_ROW_KEYS. UI : cases violet + badgeProp.(isSolverProposalCell) — distinct des fixes/validatedet des demandes de changement (orange).mergeSolverWeekIntoExisting: une case validated avec médecin listé n’est pas écrasée (saisie manuelle prime) ; remplaçant seul peut encore recevoir une proposition. - Temps réel sur l'app installée (Android / iOS PWA) :
ScheduleApps'abonne àschedulespour tous les rôles (médecins compris ; toast réservé aux admins), plusdoctor_vacationsetsettings(clénct_calendar) — ces deux tables sont ajoutées àsupabase_realtimepar la migration20261003000000_enable_realtime_vacations_settings.sql(à appliquer en prod ; sans elle ces deux abonnements restent muets). Les websockets sont coupés quand l'app passe en arrière-plan (surtout iOS) : au retour (visibilitychange≥ 5 s caché,pageshowbfcache,online) une resynchronisation complète recharge planning, congés, calendrier NCT et demandes (au plus 1 fois / 20 s). Nouvelle version :AppUpdateWatchervérifie/api/versionchaque minute, à l'ouverture (3 s), aufocus/pageshow/ retour visible, et forcereg.update()du service worker ; il propose « Recharger » (jamais de rechargement forcé : une modale peut contenir une saisie). Pas de notification push app fermée (Web Push non implémenté). - Calendrier NCT modifiable (
lib/nct-calendar.ts,app/actions/nct-calendar-actions.ts,components/nct-calendar-modal.tsx) : le calendrier par défaut (2025-12 + 2026, ex-NCT_DATES_*deguard-scheduler, ré-exportés) est surchargé parsettings.nct_calendar(JSON, admin uniquement, pas de migration). Tous les consommateurs lisentgetNctCalendar()/nctDoctorForDate(structurel, seedgenerateWeekSchedule, garde nuit « veille de NCT »,guard-generation) ;ScheduleAppcharge la liste au montage (loadNctCalendar) et bloque la persistance structurelle tant qu'elle n'est pas prête (nctReady), sinon une date supprimée serait réinjectée. Trois entrées : bouton NCT (modale : ajouter / supprimer / changer date ou W↔M, « Par défaut »), modification directe de la caseHors site - NCT(le calendrier suit viawithNctEntry— sans cela la contrainte structurelle remettrait l'ancien médecin), dictée/saisie de liste de dates. Une date supprimée vide la case dans les semaines déjà enregistrées (commitNctCalendar) ; ajout / réaffectation sont appliqués parapplyNctCalendarConstraints.generateGuardsViaAPIrecharge le calendrier côté serveur (setNctCalendar). Médecins proposés dans la modale : W / M. Le sélecteur d'affectation de la caseHors site - NCTn'accepte aussi que W / M (canAssignDoctorToSlot: les autres médecins sont grisés avec le motif « Le NCT est réservé à W ou M » ; remplaçant texte libre toujours possible ; les valeurs déjà présentes ne sont pas retirées d'office). Pour ouvrir le NCT à d'autres médecins : élargirNCT_DOCTORS(lib/nct-calendar.ts), la modale et la règle suivent. Modale : vue Mois (grille lundi→dimanche, clic sur un jour = W → M → supprimer ; flèches ‹ › qui passent d'une année à l'autre) ⇄ vue Année (12 tuiles avec les dates, clic = ouvre le mois) via le titre ; ← → au clavier, bouton « Aujourd'hui », ouverture sur le mois de la semaine affichée (buildMonthGrid/shiftMonth/cycleNctUser). Jours fériés (rose, calculés dont Pâques/Ascension/Pentecôte) et vacances scolaires zone B (ambre) danslib/french-calendar.ts— affichage seulement, mêmes codes couleur dans le planning global (en-tête de colonne + cases : férié = rose + bordures latérales, vacances zone B = ambre, légende en haut de la grille ; cases bloquées inchangées) ; données zone B 2025-26 et 2026-27 complètes, 2027-28 partiel (Toussaint + Noël) : compléterSCHOOL_HOLIDAYS_ZONE_Bpour hiver/printemps 2028 et les années suivantes. - Effacer les propositions (
lib/clear-solver-proposals.ts, bouton « Effacer propositions (n) » à côté de Générer/Retour dans « Outils », toujours présent pour l'admin — grisé avec le message « Aucune proposition à effacer » quand il n'y a aucune case violette, pour ne pas donner l'impression qu'il a disparu) : retire toutes les casesisSolverProposalCellde la semaine, sans toucher aux cases validées, aux demandes de changement (request), auxmanualAssignment, ni aux remplaçants/infirmières d'une case proposée. Les cases vidées ne sont pasmanuallyCleared:applyStructuralConstraintsles repeuple (LFB, CH, couplages…). Différent de Retour (restaure l'instantané d'avant Générer, donc perd aussi les saisies faites depuis) ; efface l'instantané Retour de la semaine. Sauvegardesource: "revert". Mobile : en vue Globale la barre d'outils est repliée derrière « Outils » ; le bouton (clearProposalsButton) est aussi rendu à côté d'« Outils » quand la barre est repliée et qu'il y a au moins une proposition, avec libellé court « Effacer (n) » sousmd. Le solveur n'a pas de graine aléatoire : un nouveau Générer peut redonner les mêmes propositions. - Paramètres semaine avant Générer (icône engrenage à côté du bouton) :
visite_doctor(U/A/B),lfb_doctor(H/S/G),pssl_b_active(B jeudi),pssl_z_active(Z mardi). Défauts = rotationweekNum % 3/ parité ; l’admin peut corriger. Envoyés dans/generate-week(optionnels, rétrocompatibles). Helpers :lib/week-generation-params.ts. LFB structurel aligné H/S/G (plus B/Z/A). PSSL éditable Mardi + Jeudi. - Congés CRUD : ne pas
revalidatePath('/protected/planning')pendant la modale (course / faux positif « message channel closed »). Refresh viaonVacationsUpdated+getAllVacations(noStore). - Do not reintroduce
generateWeekWithSolver/ second bouton solveur. - Equity / CellData:
{ value: string[], status, type? }+ row key. Uselib/equity-tracking.ts. Fenêtre glissante 6 mois pour toutes les catégories : astreinte/garde/nct/weekend (getCumulativeEquityFromTable+ repli JSON), CORO (getCoroEquity/points_coro), et Groupe 1 Cs/ETT/Stress (getGroupe1Equity→points_cs/points_ett/points_stress). Pas « depuis toujours », pas « mois calendaire ». Recalculée à chaque Générer ; pas de colonnes DB pour CORO/Cs/ETT/Stress (scanschedules). Lignes Cs = PSS+Tessée ; ETT = salles 1+2 ; Stress = Matin+Apm. Le solveur n’utilise Cs/ETT/Stress que pour B/Z/H/G/S.
Run / build / lint
- Dev server:
bun run dev→ http://localhost:3000 (this is the app; use dev, notbuild/start). - Un seul
next dev: si.next/dev/lockest pris (ou port 3000 déjà occupé), tuer l’ancien processus puis relancer — un secondbun run devéchoue ou bascule sur 3001. - Unit tests :
bunx tsx lib/__tests__/<name>.test.ts(pas de scripttestnpm ; pas de Jest). - Build:
bun run build(note:next.config.jssetstypescript.ignoreBuildErrors: true, so TS errors do not fail the build). - Lint:
bun run lintworks (flat config ineslint.config.mjs). It is configured to exit 0 with warnings only; several noisy/pre-existing rules (incl. React Compiler rules fromreact-hooksv6) are set towarnon purpose. - Standard commands are also in
README.mdandQUICK_REFERENCE.md. - E2E UI sans
TEST_LOGIN_*: se connecter via le Desktop pane (session cookie) ;SUPABASE_SERVICE_ROLE_KEYsuffit pour scripts admin. - Roadmap options G1–G7: see
docs/PLAN-OPTIONS-G1-G7.md. Prefer implementing throughScheduleApp+schedule-actions.ts; do not rebuild planning inpage.tsx. - Production go-live checklist (Vercel env vars, migrations, smoke tests):
docs/PRODUCTION_CHECKLIST.md. Preferconsole.error/warnonly — avoid reintroducing[v0]debugconsole.logs. - Performance / monitoring:
docs/PERFORMANCE.md(Render keep-alive cron/api/ping-solver, SWR on planning, solver cache, Speed Insights). User testing:docs/USER_TESTING_PLAN.md+ in-app Feedback →app_feedback//protected/admin/feedback. - Vercel / v0 URLs: Stable Production alias for keep-alive:
https://v0-recreate-attached-ui-zids-projects-22b662f4.vercel.app/api/ping-solver(public JSON). Do not use old hashed deploy URLs like…-p2ci9kbnq-…— they are immutable snapshots and may still redirect to/auth/login. Hobby-safe daily cron only invercel.json; use external 5–10 min ping for Render warmth. - G1–G3 shipped pattern: PDF export is client-side (
downloadPlanningPdf/lib/planning-pdf.ts) to avoid Vercel 413 onGET /api/export-planning-pdf(oversized Supabase auth cookies). The API route remains as fallback. PDF import via/api/upload-pdfis capped ~4 Mo (Vercel body limit). All week writes go throughsaveScheduleToDb(incrementsversion, writesschedule_history, syncsfull_scheduleblob). Realtime:schedulesis insupabase_realtime—ScheduleAppsubscribes for admins and ignores ownupdated_by+ thefull_scheduleblob key. Aftersupabase db reset, recreate local test users (admin/doctor). - G4: CSV/XLSX import in
VoiceAndUploadPanel(local parse vialib/planning-import.ts→mapped_existing_schedule). Sample:fixtures/sample-planning-import.csv. - G5:
/protected/admin/usersuses Server Actions +SUPABASE_SERVICE_ROLE_KEY(lib/supabase/admin.ts). Set that env var on Vercel; never expose it to the client.
Roles & API gotchas
/protected/planningis a thin loader that rendersScheduleApp(components/schedule-app.tsx). Do not reintroduce a full planning implementation inapp/protected/planning/page.tsx. Change requests, voice/PDF panel, solver persistence, and grid editing live inScheduleApp. Guard generation sendsprevious_sunday_guard_doctorviagetLastSundayGuardDoctorinapp/actions/guard-api-actions.ts.profiles.roledefaults to'admin'(init migration), so brand-new users are admins. The planning page treatsrole === 'admin'as admin (direct grid editing + change-request approval panel); any other role is a "doctor" who can only submit change requests. To test the doctor flow, set a user'sroleto something else (e.g.UPDATE profiles SET role='doctor' WHERE ...).- Admin hors planning (Lucie = L) :
luciecardiomaine@gmail.com→profiles.role=admin+doctor_code=L. Pas dansDOCTORS(aucune tâche médicale). Édition grille des autres médecins OK. Saisies auto-validatedviaadminEditsAreValidated(lib/staff-admin.ts). Créer/maj :bun scripts/ensure-staff-admins.ts(service role) ou/protected/admin/users. - Admin change-requests UI lives at
/protected/admin/requests(app/protected/admin/requests/page.tsx): filters (status / date range / requester / requested doctor), pagination (20/page,count: 'exact'+range), full history (all statuses), detail modal with reject comment, and Supabase Realtime (INSERTonchange_requests→ toast + badge + list refresh). Migration20250204000000_enable_realtime_change_requests.sqladds the table tosupabase_realtime(do not drop that publication). Approve/reject stays inapp/actions/change-request-actions.ts. The old path/protected/change-requestsis a thinredirect()only — do not reintroduce a full page there. Emails demandeurs : chargés via requêteprofilesséparée (pas d’embedprofiles(email)— 400 PostgREST si FK absente en prod). - Route protection lives in root
proxy.ts(Next.js 16; replacesmiddleware.ts). It uses Supabase SSRgetUser()(not a raw cookie name check). Public:/,/auth/login,/auth/sign-up,/auth/forgot-password. Unauthenticated/api/*redirects to login — use a session for voice/PDF proxies. /api/voice-commandand/api/upload-pdfare proxies to the Render guard API (GUARD_API_BASE_URLor fallbackGUARD_API_URL, defaulthttps://guard-api-cardiomaine.onrender.com), optionally withGUARD_API_KEY(x-api-keyheader +x_api_keyquery). PDF upload goes direct browser → Render (lib/pdf-upload-client.ts) to avoid Vercel 504 on multi-page Claude Vision (1–3 min);/api/upload-pdfis fallback only (maxDuration = 60). Voice still uses the Next proxy. Prefer the stable Production alias, not preview…-dpl_…/ hashed URLs.- Voice payload expected by Render (built in
VoiceAndUploadPanel/lib/guard-api-mapping.ts):{ text, reference_date, known_doctors, current_week_request }wherecurrent_week_requestmatches/generate-week(week_start_date,week_type,medecins, …). Response is applied viaparsed_command(surgical cell update), not the old local{ operations: [...] }shape. PDF response usesraw_extraction.rows/mapped_existing_schedule(keys likeGarde Nuit||LUNDI). SetGUARD_API_BASE_URL+GUARD_API_KEYon Vercel when needed. - Voice
doctor_in=null/weekend/ASTREINTE: Claude renvoyait parfoisdoctor_in: null→ ValidationError Pydantic, ouslot=weekend+ASTREINTE→ 422 « Combinaison créneau/activité non reconnue ». Correctif backend à porter surguard-api-cardiomaine(patches/guard-api-voice-weekend-astreinte.md+ patchdoctor_inOptional). Repli front :shouldUseLocalVoiceFallback+parseVoiceCommandLocallydansVoiceAndUploadPanel(weekend →Astreintes ATL MatinviaresolveRowKey). - Voice mic (Web Speech) : helpers in
lib/speech-recognition.ts. Requires Chrome/Edge + HTTPS (or localhost) + micro autorisé. Le panneau demandegetUserMediaavantSpeechRecognition.start(), affiche les erreurs (not-allowed, etc.), écoute encontinuous, et garde le textarea visible pendant la dictée.no-speech/aborted: non bloquants — auto-restart tant que l’utilisateur n’a pas arrêté le micro (wantListeningRef). Saisie manuelle + Appliquer reste le fallback. Après apply : siparsed_commandne modifie rien, fallbackmergeAssignmentsIntoSchedule(..., { proposalRowsOnly: false }); date hors semaine affichée → écriture sur la bonneweek_key. - NCT dictée / saisie :
resolveRowKeymappe toute combinaison*/NCT→Hors site - NCT(Claude renvoie souventmatin/NCT). Une liste multi-dates (2026-09-10 → M) est détectée localement (lib/nct-command.ts) et appliquée sur les semaines concernées sans passer par Render. Évolutions prompt voice =guard-api-cardiomaineuniquement. - Import historique multi-semaines : PDF avec
weeks[]→HistoryImportDialog(review admin) →commitHistoryImport(lib/history-import.ts+app/actions/import-history-actions.ts). Ne remplit que cellules vides, ignore OCRconfidence: low. - « Générer » (une seule passe) :
generateGuardsViaAPIcalculehistorical_patternsviabuildHistoricalPatternsPayload(lib/pattern-analysis.ts, allowlist Cs/ETT/EE/Stress —isSolverHistoricalRowKey). Le solveurguard-api-cardiomaineapplique la même allowlist + Rythmo précis (rythmo_slots, vendredi P/U) et émet aussi les hors site viaHORSSITE::*. Côté front,resolveRowKeymappe les activitésHIST/HORSSITE(suffixesCs PSS,CDL, …) vers les row keys ;GENERATOR_PROPOSAL_ROW_KEYSinclut Cs/ETT/EE/Stress + hors site (sauf NCT calendrier) → cellules pending / violet « Prop. » à valider. NCT / Rythmo fixe / ½-off restent structurelsvalidated. - Suspensions d’activité (
activity_maintenance) : même principe queroom_maintenance(Coro) — période + liste d’activités bloquées pour tout le monde. Front :lib/activity-maintenance.ts(calendrier 2026 : PSSL/LFB/CDL S28–S36, NCT S31–S36) → payload/generate-week+ clear structurel (applyActivityMaintenanceClear/ LFB & NCT skip). Pas de formulaire UI pour l’instant (calendrier codé) ; vocalactivity_maintenance= optionnel côté backend si besoin. - Doublon Cs solveur : le backend peut émettre 2
Assignmentidentiques (ex. Apm Cs PSS Lun Z / Mar H, 2ᵉ note"… (doublon)").mergeAssignmentsIntoSchedule+mergeCellDoctorsPreservingRemplacantsne dédupliquent pas les lignesisDoublonEligibleRow(lib/slot-blocking.ts) pour garder["Z","Z"]→ affichageZ². Les autres lignes restent dédupliquées. - PDF « JSON malformé » : root cause dans
guard-api-cardiomaine(pdf_upload.py/llm_json.py), pas le proxy Next. Corriger uniquement dans ce dépôt.
Configuration Supabase (code, pas Vercel)
- Source unique :
lib/supabase/config.ts(getSupabaseConfig). Le projet de production (rmrxsaiianffhpxpntws, clé anon publique) est fixé dans le code pour le navigateur (client.ts), le serveur (server.ts), le proxy (proxy.ts,lib/supabase/proxy.ts) et l'admin (admin.ts, URL). Les variables VercelNEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEYsont ignorées (un avertissement console le signale) — l'intégration Supabase de Vercel (17/07) injecte celles d'un autre projet (cdvz…) et cassait la connexion dès qu'elles atteignaient le build. - Exception : Supabase local (
NEXT_PUBLIC_SUPABASE_URLenlocalhost/127.0.0.1et clé fournie) → l'environnement est respecté, pour les tests de bout en bout ci-dessous. - Changer de projet = modifier
SUPABASE_PROJECT_URL/SUPABASE_PROJECT_ANON_KEYdansconfig.ts.SUPABASE_SERVICE_ROLE_KEY(Vercel, Secret) doit appartenir au même projet que l'URL. - Diagnostic :
/api/debug-env(connecté) afficheeffective(URL réellement utilisée, source, adresse d'environnement ignorée).
Supabase backend (important auth caveat)
- Committed
.envpoints at a hosted Supabase project. The client (lib/supabase/client.ts) hardcodes the same values as a fallback. - The hosted project has email confirmation ON (
mailer_autoconfirm=false) and no seeded/test accounts, so you cannot self-signup and log in without access to the confirmation email. The documented sample accounts (e.g.marie@cardiomaine.fr) do not exist in that project. - All protected pages require login (
proxy.tsgates everything except/,/auth/login,/auth/sign-up,/auth/forgot-password).
Running a local Supabase for end-to-end testing (no real account needed)
Requires Docker + the Supabase CLI installed in the VM (not part of the update script). To exercise login + the protected planning grid autonomously:
supabase start(a committedsupabase/config.tomlalready exists). Migrations insupabase/migrations/are now aligned with the app code —20240101000000_init_schema.sqlcreates theprofiles(withrole+must_change_password),week_key-basedschedules,doctor_vacations,planning_notes,doctors,settings,congres, and equity tables, with permissive RLS and the required role GRANTs. Usesupabase db resetto re-apply from scratch.- Create a confirmed user via the admin API (service_role key from
supabase startoutput):POST http://127.0.0.1:54321/auth/v1/admin/userswith{"email":"admin@cardiomaine.fr","password":"Admin123!","email_confirm":true,"user_metadata":{"first_name":"Marie","last_name":"Martin"}}. Theon_auth_user_createdtrigger auto-creates aprofilesrow (defaultrole='admin'). Note:supabase db resetwipes auth users, so recreate this user afterwards. - Point
.envNEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEYat the local stack (http://127.0.0.1:54321+ the printed anon key) and restartbun run dev. Restore.envto the hosted values when done.
- Non-obvious gotcha: Supabase REST needs table-level
GRANTs toanon/authenticatedin addition to RLS; without them PostgREST returns401 permission denied for table .... The init migration already includes these grants. - Note: the hosted Supabase project (the default
.envtarget) may still have the older/inconsistent schema; these migrations describe the schema the code expects.
Supabase service role key (local vs hébergé)
- En local, utilisez la clé
service_rolegénérée parsupabase status(copiez la clé dans.env.local). - En hébergé (Supabase en production), utilisez la clé
service_roledu projet hébergé (disponible dans Settings → API). - Ne pas confondre les deux clés : elles ne sont pas interchangeables.
