- Couleurs restreintes à la charte TARGO (noir/vert/blanc/gris + bleu en haut) sur le
courriel ET la page RSVP : bandeau supérieur bleu #1a86c7, accents/CTA verts, texte
noir/gris, fond blanc, programme en pastilles vert/bleu/gris. Retrait orange/jaune/rose/rouge.
- Admin : le téléphone saisi s'affiche sous le courriel (n'apparaissait pas avant).
- Hub : capture de l'adresse IP du visiteur à l'inscription (x-forwarded-for → socket).
- CSV enrichi = TOUTES les infos : client, n° client, ID compte, nom au dossier, téléphone,
personnes, courriel, allergies, confiance, signaux, langue, IP, créé le, répondu le.
Déployé hub + SPA 2026-07-06. Vérifié prod : palette conforme, téléphone + IP stockés
(819 555-9999 / 96.125.192.22), test envoyé OK. 21/21 tests unitaires.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Titre corrigé partout (courriel, page RSVP, admin) : « Fête Targo 20 ans » →
« Targo fête ses 20 ans » (FR) / « Targo turns 20! » (EN), tagline étoffée.
- Courriel refait façon flyer : bandeau confetti + ballons 🎈 + pastille « 20 ANS »,
programme en cercles colorés (vert/bleu/violet/rose/orange), encart « places limitées »,
ligne « Merci de votre confiance au fil des années 💚 », pied targo.ca.
Déployé hub 2026-07-06 (page RSVP live = « Targo fête ses 20 ans » ; test envoyé OK).
La page RSVP et le nom en admin viennent du hub — pas de rebuild SPA requis (le repli
cosmétique dans EventsPage suivra au prochain déploiement SPA). 21/21 tests unitaires.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Suite aux demandes : bouton d'envoi test (courriel par défaut = utilisateur), auto-
rattachement par courriel/nom/téléphone, et confirmation « êtes-vous un client ? » par
recoupement — SANS autosuggest (aucune fuite de PII).
Hub lib/events.js :
- matchAndValidate({token,number,email,phone,name}) : recherche AVEUGLE de candidats par
courriel/téléphone/numéro (en parallèle), le nom corrobore. « confirmed » = ≥2 signaux
concordent sur le MÊME compte ; « probable » = 1 ; « none » = aucun. Jamais bloquant, ne
renvoie jamais de donnée client. Empêche un courriel deviné de mal rattacher.
- Formulaire RSVP : ajoute Nom (requis) + Téléphone (optionnel), n° client devient optionnel.
Message de remerciement adaptatif (« ✓ on vous a reconnu » si confirmed).
- POST /events/:id/send {test:true, test_emails[]} : envoi TEST via Mailjet/Gmail (réutilise
email.sendEmail/gmail.sendMessage + inviteEmail + rsvpLink). Envoi de masse reste gaté (400).
Ops EventsPage : badge de confiance (Confirmé/Probable/À vérifier + signaux en infobulle),
dialogue d'envoi test (canal + courriel défaut = usePermissions().userEmail), CSV enrichi
(téléphone + confiance). Filtres par niveau de confiance.
Vérifié : 21/21 tests unitaires hub (cross-validation + envoi test mailjet/gmail + masse gatée) ;
page Ops rend sans erreur, dialogue s'ouvre avec courriel pré-rempli.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
À la publication (vue Tournées), bouton « Notifier » → aperçu par tech
PUIS envoi. Chaque tech reçoit :
- UN lien token personnel (techFieldLink = app terrain, ses billets du
jour à répondre),
- la liste de ses interventions avec ADRESSE + flags cruciaux (AM/PM ·
URGENT · Appeler avant) détectés du titre/notes/priorité.
Hub (lib/roster.js) : composeDispatchNotifs(dates, techIds) + route
POST /roster/notify-dispatch (preview=compose sans envoi | envoi SMS via
twilio + Email via lib/email.js, best-effort par tech, retourne compte +
erreurs). jobFlags() détecte AM/PM/urgent/appeler-avant.
Client : api roster.notifyDispatch ; dialogue APERÇU (toggles SMS/Courriel
+ liste par tech + corps du message, alerte si pas de tel/courriel) →
« Envoyer ». Aperçu d'abord = pas d'envoi accidentel.
Vérifié : hub node --check OK ; client rend le bouton + dialogue + toggles,
0 erreur. ⚠️ Le PREVIEW/envoi nécessite le déploiement HUB (nouvelle
route) — GATÉ + sortant. Faire un test 1 tech avant usage réel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch 2 de l'audit (perf backend), items sûrs :
- roster.js : fetchTechnicians caché 30 s (appelé par endpoint + generate + materialize +
tech-positions polling 25 s pour des données quasi statiques). invalidateTechCache() sur
les 6 écritures Dispatch Technician (coût/effic/skills/pause/traccar/weekly) → pas de stale
après édition. Vérifié : call1 71 ms → call2 5 ms (caché).
- legacy-dispatch-sync.js : garde _inFlight sur le tick d'import ET la veille → un passage
long ne se relance plus par-dessus (évitait doublons Dispatch Job + contention ERPNext).
- conversation.js : jeton d'ingestion webhook mémoïsé (mailIngestToken) au lieu d'un
fs.readFileSync SYNCHRONE à chaque courriel/canal entrant (bloquait la boucle d'événements).
NON fait (par prudence) : paralléliser les erp.list séquentiels de generate() (séquencement
DÉLIBÉRÉ anti-ECONNRESET Frappe, cf. feedback_hub_crash_502) ; batch N+1 Customer du triage
(refactor risqué d'une chaîne de fallback stateful — reporté) ; jitter des pollers (502 boot
déjà mitigé).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour →
détecte les transitions et les journalise par job. Le statut « live » du job est
DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en
cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé
(HH:MM) → Reparti (HH:MM).
- lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min),
store data/geofence.json par job, machine à états ; startScan() scheduler 90 s
(GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK.
- server.js : démarre le scan au boot.
- roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test).
- api/roster.js : jobGeofence(), geofenceStates().
- PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped).
Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une
valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Onglet Tournées : marqueurs « live » de chaque tech (appareil Traccar associé),
rafraîchis ~25 s tant que l'onglet est ouvert. Marqueur = pastille ronde à initiales,
couleur = celle de la tournée du tech, halo pulsé si position fraîche (grisé sans halo
si > 10 min). Tooltip : nom · « il y a X min » · vitesse (km/h) ou « arrêté ».
Bouton « GPS live (N) » pour afficher/masquer. Respecte les chips techs masqués.
- hub roster.js : GET /roster/tech-positions → {positions:[{techId,techName,lat,lon,time,speed}]}
(batch getPositions par appareil). Vérifié prod : 14 positions.
- api/roster.js : techPositions().
- RouteMap.vue : prop `live` + marqueurs .rm-live (halo pulsé, staleness), watch dédié.
- PlanificationPage : polling 25 s (démarré sur l'onglet Tournées, arrêté sinon +
onUnmounted), couleur mappée depuis dayRoutes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the recurring "blank menu": the hub keys OPS permissions to the
exact Authentik email (louis@targo.ca). If whoami passes a different email
(duplicate account e.g. louism@targointernet.com) or an empty email, the hub
404s → can()=false → menu/search/avis silently blank, mistaken for a token bug.
The ERP token and Authentik provider config were verified correct throughout.
- REVERT the misleading session-fix (96da97b): drop verifySessionAlive /
sessionExpired / auto-reload churn and the "Session expirée" overlay; restore
the original keep-alive ping. (Re-login couldn't fix a perms/identity issue,
so that overlay only added noise.)
- FAIL-LOUD (App.vue): when permissions don't load in prod, show a clear overlay
naming the exact account OPS received ("OPS a reçu <email>, pas de permissions")
+ Changer de compte / Recharger — instead of a silent blank shell.
- USERNAME FALLBACK: whoami username is captured (api/auth getAuthUsername),
passed to loadPermissions, and the hub (/auth/permissions) retries by username
when the email matches no provisioned OPS account. Verified on prod: username=Louis
alone resolves; bad-email+username falls back; unknown → 404.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le dispatch auto utilisait tout le pool avec son propre sélecteur de jour,
en ignorant les chips de date du pool (et même la sélection lasso, sur le
chemin solveur hub). Désormais les chips pilotent le dispatch :
- FENÊTRE = les chips de date cochés (⏰ en retard / Sans date → placés
aujourd'hui) ; aucun chip → repli sur le sélecteur Aujourd'hui/Demain/date.
- JEU DE JOBS restreint aux jours cochés (ou à la sélection lasso) sur les
3 chemins : heuristique locale, solveur local, ET solveur hub (nouveau
param `job_names` dans optimize-plan, additif/rétrocompatible).
- UI : bouton « Suggérer (N) » + dialogue « N job(s) des jours cochés → jours ».
- Helper partagé jobMatchesDateChips (dé-dup avec le filtre d'affichage).
Vérifié en preview : chip 07-06 → 19 arrêts tous le 07-06 (local) ;
chips 07-06+07-07 → 34 arrêts, uniquement ces 2 jours (solveur hub déployé).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
suggestSlots was skill-blind and assumed a default 8-17 shift for every
tech. Now:
- SKILL FILTER: only techs whose skills (or _user_tags) include the
requested skill are considered. Dialog passes the picked chip's skill.
- REAL SHIFTS: slot windows come from published Shift Assignment ×
Shift Template (garde/on_call excluded). A tech with no regular shift
that day yields no slot — so it reflects reality (needs shifts created).
- WEEKENDS reserved for repairs: Sat/Sun skipped unless the skill is a
repair/dépannage. Removed SLOT_DEFAULT_SHIFT.
- Empty-state message hints to publish a shift / weekend rule.
Verified: with only garde shifts in the horizon, correctly returns 0
(regular_count=0). Skill/weekend/garde-exclusion logic confirmed via
data diagnostic. Produces slots once a regular day-shift is published for
a qualified tech.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ingest geocode fallback called Mapbox with only a broad territory
bbox guard — so a wrong-city guess (e.g. "Rue Elsie" → Valleyfield, both
in-territory) was accepted. Rebuilt the chain to be bounded:
infra (fibre/placemarks) → RQA exact → OSM (validated) → postal-code
centroid → phone-match / town-centre (existing last resorts)
- geocodeNominatim(): free OpenStreetMap geocoder (User-Agent per OSM
policy, cached, in-territory) — replaces Mapbox in the ingest path.
- OSM result is ACCEPTED only if within 10 km of the postal-code centroid
(geocodeCentroid, RQA average for the full postal code); otherwise
rejected → the centroid is used. So a J0S1H0 address can never land in
Valleyfield again.
- postal_centroid is the guaranteed bounded fallback.
Note on connectors: the fibre table is a PERMANENT direct MySQL link
(cfg.LEGACY_DB); placemarks come via the permanent PHP bridge
ops_placemarks.php — both already wired into resolveDevCoords.
Verified: J0S1H0 centroid = 45.0857,-74.1795 (Huntingdon, 3471 addrs);
OSM reachable and returns in-town coords for known streets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug: "102-4 Rue Elsie, Huntingdon J0S1H0" (a NEW street, absent from RQA)
showed in Valleyfield. The F `fibre` table had the correct placemark
(45.091152,-74.180855) but the job was stuck at 45.264,-74.144.
Root causes:
1. _parseAddr extracted civic "102" from "102-4" → fibreMatch queried
`terrain LIKE '102%'`, matching MANY other streets (Lost Nation,
Dalhousie, Ridge…) and burying/missing the Elsie `terrain '102-4'`.
Now captures the full "NNN-N" civic → `terrain LIKE '102-4%'` (precise)
and cleans the street ("Rue Elsie", no "-4" leftover).
2. resolveDevCoords let the (stale/wrong, account-fallback) delivery
coords win before fibreMatch. Now a STREET-CONFIRMED fibre match is
the top authority (_streetConfirmed) — "se fier à la base infra".
3. Existing wrong jobs never self-corrected (sync only fills missing
coords). Added geolocateJobs refreshFibre mode + ?refreshFibre=1:
re-checks already-coordinated jobs, overwrites ONLY when fibre
confirms the street AND it moved >100m.
Ran the correction: 11 jobs fixed (all fibre placemarks), incl.
LEG-253136 moved 19 km to Huntingdon. Verified read-back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routing: the last Mapbox routing calls now go through our OSRM —
useMap.computeDayRoute (Dispatch board route lines) → /roster/osrm-route,
useAutoDispatch optimizeRoute → new /roster/osrm-trip (OSRM /trip TSP,
same response shape as Mapbox optimized-trips). 0 Mapbox directions/
matrix/optimized-trips fetches left in the SPA. (Remaining Mapbox =
gl-js renderer + base tiles + geocoding, which OSRM can't provide.)
Anthony route bug: /roster/osrm-route now sends continue_straight=false.
Without it OSRM forbids U-turns at intermediate waypoints, so on a
home→stop→home loop it overshoots the address and turns around in a side
street ("s'éloigne et tourne"). Now it goes straight to the door.
Benefits every map (shared osrm-route).
Job detail dialog:
- Highlighted assigned-tech chip (indigo, avatar+name) in the header —
"who does this job" is now obvious; grey "Non assigné" otherwise.
- Mini-map shows nearby jobs (pool + occupancy within ~3km) as pale
grey dots for context, under the job's own pin; centered on the job.
- Normalize displayed strings: decode HTML entities (subject
"d'équipement" → "d'équipement") via deEnt().
Verified: osrm-trip returns Ok order; detail shows Anthony Dion chip +
decoded subject; routes render (43 pins), Anthony 31.7km/35min round trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Tournées tab: removable/addable TECH CHIPS above the map — each chip
in the tech's color with a job-count badge; click toggles the route
on/off (colors stay stable). Route colors switched from a 12-color
fixed palette to golden-angle HSL (137.5°) — consecutive techs always
get highly distinguishable hues, unlimited count.
- 🖥 SANS DÉPLACEMENT: per-job persistent flag (hub store data/
job-remote.json + POST /roster/job-remote; buildUnassigned sets
j.remote). optimizePlan EXCLUDES remote jobs from routing and address
pinning (e.g. "Configuration de boîtier tel" doable via ACS — nobody
travels) → returned in a remote[] bucket, shown as a "🖥 Sans
déplacement" group in the review, assignable to an agent. Toggle
button (computer icon) on every review entry. Verified: "Boitier
ouvert | Lac des pins" flagged → excluded from routes, then reverted.
- 📦 GROUPER EN PROJET: same-address entries (🔗) now open a menu →
"Grouper en projet" sets ERPNext parent_job on the shorter tasks
(parent = longest), POST /roster/job-group. Plugs into the existing
parent-child pool sort ("Groupe (parent-enfant)").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.
Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
or normalized address) are merged into one solver node (service times
summed) → same tech, single stop. Verified: install + TV boîtier of
the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
that day is pinned onto THAT tech (they're going anyway) — verified:
"Réinstallation #253028" pinned to Frédérique who already has the
install there; capable flag kept honest (⚠ if skill missing).
SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 — self-hosted OSRM replaces ALL paid Mapbox routing in the SPA:
- hub: POST /roster/osrm-route (geometry + total + per-leg km/min) and
/roster/osrm-table (durations/distances matrix, Mapbox-shaped) proxying
the local OSRM; osrmGet helper.
- SPA: loadRealRoutes (review map), kanban travel matrix, day-editor
matrix, day-map route geometry → all via hub OSRM. 0 paid
directions/matrix calls left (tiles + geocoding only).
Review usability (evaluate whether times fit):
- Click a job → the SAME job-detail dialog as the grid (address, ticket
thread, duration, team). openEntryDetail maps the pool job.
- Per-task time is editable (q-popup-edit on the "X.Xh"): updates the
plan live, survives Ré-optimiser (durOverride), persists duration_h
via patchJob (hub whitelist extended, 0-24h).
- Real travel time BETWEEN stops: one OSRM route per tech×day (cached)
→ "🚗 X min" rows between entries + "domicile → 1er arrêt";
haversine ≈ fallback when coords missing.
Verified: OSRM proxies live (route 7.8km/12min with legs 7+6min, table
3x3); review shows 10 real leg rows; detail dialog shows address;
duration popup opens/cancels cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- shared priorityMeta/PRIORITY_LEVELS → 3 levels: Haute (RED) · Moyenne
(neutral grey) · Basse. Default/medium = grey outlined_flag. Aliases
urgent→high, normal→medium for old conversation data.
- conversations: priority endpoint accepts 'medium' (keeps old values for
back-compat); the flag menu now shows the 3 levels.
- jobs: dropped the parallel job-flags override store — the job flag now
writes the REAL ERPNext priority (low/medium/high) via patchJob/
updateJob, same source as the pool sheet (no more two mechanisms).
setEntryPriority reuses patchJob. Solver: high never dropped + served
early, low dropped first.
Verified: job flag + conversation flag both grey-by-default, menu Haute/
Moyenne/Basse, high=red.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The urgent flag was a red emoji shown red before being set — wrong.
Now reuses the exact Communications component: priorityMeta() + a
priority list (Urgent/Haute/Normale/Basse/Aucune) — grey outlined_flag
by default, colored only when set.
- SPA: per-job flag = q-btn(icon=priorityMeta(...).icon) + q-menu list,
same as ConversationPanel. setEntryPriority(name,lvl) (renamed to avoid
clashing with the existing pool setJobPriority(j,p)). medium (ERPNext
default) shows as grey/none.
- hub: job-flags store now holds a dispatch PRIORITY (urgent|high|normal|
low) overriding j.priority in buildUnassigned — needed because the
ERPNext job-priority field is whitelisted to low/medium/high (can't do
urgent), just like conversations keep their own priority.
Lesson applied: grep for an existing symbol before declaring (2nd
collision this session — techsForJob, setJobPriority).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Like the omnichannel inbox urgent flag, but for jobs — a 🚩 that
persists and drives the optimizer.
- hub: durable job-flags store (data/job-flags.json) mirroring
job-levels — getJobFlags/setJobFlag + POST /roster/job-flag {name,
urgent}; buildUnassigned sets j.urgent; invalidatePool on write.
- SPA: 🚩 chip on each review entry (replaces the session ⚡). Optimistic
local toggle + persists via roster.setJobFlag; reflects j.urgent from
the pool. Flagged jobs → priority_boost (never dropped) + urgent_weight
(served early). Bumped urgent early-weight 6→10.
Verified: flag round-trips (set→urgent, revert→cleared) through the pool;
chips render (🚩/AM/PM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
estimateForJob: an INSTALLATION whose location matches a pre-wired site
(Lac des pins, Camping, Domaine Dauphinais) takes ¼ of the normal time
(infra already in place). Applied at the estimator source → flows into
est_min everywhere (greedy duration, VRP service_min, occupancy, travel).
Floored at 15 min; adds a "site pré-câblé ×¼" label. Verified: Lac des
pins install 120→30 min; normal install unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the haversine straight-line estimate with real road travel times
from a self-hosted OSRM (free/offline — Mapbox Matrix is billable).
- hub: osrmMatrix() calls OSRM /table for the job+home coords (node order
matches route_solver: jobs then homes), returns a minutes matrix.
Handles PARTIAL coords — OSRM for geolocated nodes, 0/neutral for the
rest (matches the solver's own fallback), so a few tech homes without
coords don't disable OSRM for the geolocated majority. /roster/
optimize-routes injects body.matrix; graceful fallback to haversine if
OSRM is unreachable or too few points.
- config: OSRM_URL (default http://osrm:5000).
- services/osrm/README.md: reproducible setup (Québec extract, MLD
pipeline, osrm-routed on erpnext_erpnext network).
Deployed + verified: OSRM /table hit with the full coord set, no
fallback; optimizer routes on real road times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real vehicle-routing optimization behind the Suggérer UI, reusing our
existing OR-Tools solver service (no new stack). Fixes the greedy's
structural limits (sector-splitting, no global optimization).
- roster-solver: new route_solver.py (OR-Tools Routing / VRPTW).
Minimizes real travel; skills = hard filter (VehicleVar ∈ allowed∪{-1};
SetAllowedVehiclesForIndex has a broken Span typemap in ortools 9.15);
on-site service time within each tech's shift window; optional per-job
time windows; unfittable jobs left unassigned (drop penalty) instead of
infeasible; specialist bias via skill order (per-vehicle arc cost).
New POST /route endpoint. Dockerfile now COPYs route_solver.py.
Unit-tested: skills respected, sectors consolidated, edge cases safe.
- hub: POST /roster/optimize-routes → proxies to solver /route.
- ops SPA: "⚡ Optimiser" strategy. Greedy buckets jobs into days +
placeholders, then the solver re-optimizes each day (assignment +
routes) among shifted techs; result maps into the same review dialog
(occupation bars, route map, swap/merge reused). Graceful fallback to
greedy if the solver is unreachable (never drops jobs). shiftWindowMin
derives each vehicle's shift from templates.
Phase 2 (later): OSRM/Mapbox road-time matrix for exact travel times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint
POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv »
écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue
(IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard).
- Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur
les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves).
- D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech,
domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ;
fallback segments droits + estimation haversine).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Ajout acte 'transport' (Tarif transport, 100$/jour, unité qte) ; retrait de
l'ancien 'tarif_horaire + transport' combiné (remplacé par transport + perte_temps).
- amountOf : palier « FTTH 250 m + » = base 150-250 m (120$) + 1,20$/m au-delà de 250 m
(l'input mètres = distance TOTALE).
- Prix du barème global saisis via /acte/rates (35/26/26/14/29/19/21/60/80/120/…).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).
Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Comms: sélecteur d'expéditeur « De: » (défaut groupe Support TARGO) via resolveSendFrom + alias vérifiés
- Notifs: prefs de feeds PAR utilisateur (/conversations/notif-prefs) + cloche à bascules ; boot tooltip-ux (clic prioritaire + anti-empilement)
- Courriel: invitation à évaluer = modèle Unlayer éditable (transactional-rating-invite-*) ; test-send via Gmail + expansion {{rating}} ; logo TARGO auto-hébergé sur le magasin d'actifs du hub
- Planif: bloc « sans déplacement » (damier, début de quart, alerte si pas de quart), quart éditable dans l'éditeur de jour, icônes de compétence en vue jour (TV pour télé), clic cellule → éditeur, clic gauche lane → liste / clic droit → menu quart, lien ↗ ticket par job
- Tickets: défaut « Non fermés » + correction du filtre « Mes tickets » (owner)
- Inbox: poll Gmail 1 min + rafraîchir à la demande (poll-now)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sender identity on outbound:
- Replies/new emails now go out as e.g. "Gilles Drolet" <support@targo.ca> instead
of the generic alias name "Service TARGO". The name is derived from the agent's SSO
email (gilles.drolet@ -> "Gilles Drolet"); the ADDRESS stays the monitored alias so
replies still land in the shared inbox. Verified: Gmail honors the custom display
name on the send-as alias (sent From header = "Gilles Drolet <support@targo.ca>").
- gmail.sendFrom() exported; conversation.js agentSendFrom() builds the From from the
stamped msg.agent (replies) / x-authentik-email (new sends via email-new).
UI cleanup (space + clarity, Gmail-style):
- One thin separator line between messages; removed the rounded gray card box around
emails (.email-card flat), the email-card-head bar, and the blue expanded-head
highlight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: some F customer records carry our own/staff addresses in email_billing,
so matchCustomer's `email_billing LIKE '%addr%'` resolved support@targo.ca -> "Guylaine
Gagnon" and gilles@targointernet.com -> "Sylvie Juteau". Every thread on one of our
addresses then inherited that wrong customer + name.
- OWN_DOMAINS promoted to a single source in lib/helpers.js (was duplicated in
conversation.js); inbox-triage.matchCustomer() now returns null for any own-domain
address — a customer is never reachable at targo.ca / targointernet.com / gigafibre.ca.
- conversation.js consumes the shared OWN_DOMAINS (removes the local copy).
Also ran a one-shot repair (temporary endpoint, since removed) that unlinked the 6
already-contaminated threads and reset their display name to the real address.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbox identity/grouping (fixes mislabeled threads):
- customerName = real From display name, then a customer matched BY EMAIL,
then the raw address — never the AI's content-guessed name (a gilles@ relay
showed as "Sylvie Juteau").
- never group a thread by one of our own domains (support@targo.ca,
*@targointernet.com): re-ingested outbound was collapsing unrelated threads
into one mislabeled "Guylaine Gagnon" thread.
Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were
drifting across conversation.js + inbox-triage.js; telephonie/television had
no matching triage type). Removed dead export findConversationByEmail.
Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats.
Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL
statuses incl. Completed/Cancelled — the board hid them), tech leaderboard,
inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard,
/conversations/leaderboard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy.
- Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée
client-side sur les techs visibles -> suit le filtre de compétence.
- Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection
DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool.
- Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/
device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée
public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini).
- Chrono job (start/finish), repositionnement carte (set-location), vue satellite,
Street View clic-droit, année devant les dates dues groupées.
- Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch
Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches).
- vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres).
- Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour
au pool ; notification courriel à l'assignation.
Déployé sur le hub ; ce commit aligne le repo sur l'état en production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problème de fond : quand ticket.delivery_id ne se joignait pas, le pont retombait sur l'adresse de
FACTURATION du compte (résidence) → sujet + géocodage faux. Or les 124 tickets ouverts-3301 ont tous un
delivery côté compte (98 via ticket.delivery_id, 26 via le compte).
- fetchTargoTickets : résolution delivery robuste — COALESCE(ticket.delivery_id, delivery du compte AVEC
coords [plus récent], delivery du compte [plus récent]) → l'adresse de SERVICE est toujours disponible.
- buildJob préfère déjà svcAddr (delivery) au billAddr → sujet + géocodage utilisent le service.
- Rafraîchit le `subject` des jobs encore au pool (open + non assigné) pour refléter l'adresse de service
(corrige les anciens sujets basés sur la facturation) ; ne touche pas un job déjà dispatché.
Résultat (re-sync) : delivery 26→37, no_coords 6→1, 0 erreur. Jobs ouverts affichent l'adresse de service
(« Camping Sandysun · 32 Bellevue », « Lac des pins · 25 Érable », « Franklin · 35 rue Hilltop »…).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symptôme : un job de camping (« Lac des pins | Anton Rimerov ») pointait sur la RÉSIDENCE du client
(428 Rue George, Lasalle = 45.58,-73.73) au lieu du camping. Le pont géocodait l'adresse de compte.
- buildJob : détection camping en PRIORITÉ MAX via le registre camping_registry — signal = sujet (label
explicite, prioritaire) puis ville/adresse de delivery. Garde-fou : le texte doit contenir « camping » OU
un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »). coord_src='camping'.
La branche update fait écraser les coords existantes par le camping (comme delivery). 20 jobs ouverts re-coordonnés.
- camping_dispatch_backfill.sql : corrige les jobs DÉJÀ dispatchés (que le sync ne re-traite plus car le ticket
legacy a quitté le pool ouvert-3301) → 4 Lac des Pins + 2 SandySun. Anton Rimerov/Germaine Thibert → 45.0624,-73.9113 ✓.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dernier recours quand l'adresse exacte est introuvable : placer le Service Location au CENTROÏDE
(rqa_addresses) de son code postal (préféré) sinon de sa ville → le job apparaît dans le bon secteur.
- Hub : applyAreaFallback() (CTE centroïdes CP/ville, index-friendly) + POST /address/conformity/apply-area.
Statut 'area', linked_address '≈ centre <CP/ville>'. Hors-QC/junk (absents de rqa_addresses) restent unmatched.
- Ops : carte stat « ≈ Secteur (CP/ville) » + bouton « ≈ Centre CP/ville (reste) » dans la page Conformité.
Exécuté : 317 placés (303 par code postal, 14 par ville) → unmatched 365 → 48 (Toronto/boîtes postales/junk).
État final : validated 16 257 · review 489 · area 317 · unmatched 48 → 99,7 % des services ont une position.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rend le mécanisme réutilisable (« faire de même pour tous les campings ») :
- Hub (address-conformity.js) : GET /address/conformity/campings (registre + nb lots par camping),
POST /campings (upsert {keyword,name,address,lat,lon} → applique direct), POST /campings/apply (réappliquer).
applyCampings() = UPDATE des lots (match ville normalisée) → géoloc fixe du camping.
- Ops (page Conformité adresses) : section « Campings — géoloc de remplacement fixe » : table du registre
(nom, adresse principale, GPS→Google Maps, nb lots) + formulaire d'ajout (nom/mot-clé/adresse/lat/lon)
qui ajoute ET applique, + bouton « réappliquer ». api/address.js : campingsList/Upsert/Apply.
→ Pour un nouveau camping : on saisit son adresse principale + GPS, tous ses lots pointent dessus (le tech
navigue au camping). Registre seedé : Lac des Pins, Dauphinais, SandySun, Frontière, Ensoleillé.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optimisation sûre (vérifiée, 0 régression) :
- helpers.js : `cors()` partagé (en-têtes CORS génériques) au lieu de 2 copies locales.
- address-conformity.js : réutilise `pool` (address-db) + `cors` (helpers) au lieu de redéfinir un Pool +
cors → 1 seul client pg local partagé pour rqa_addresses/fiber.
- address-validate.js : utilise helpers.cors.
docs/architecture/VISION.md (NOUVEAU) — vision + plan de modularisation + roadmap d'optimisation, fondé sur
un audit chiffré (hub 58 modules/23k lignes, Ops 45k lignes, god-files identifiés). Découpe en 9 domaines
(bounded contexts), principe « source de vérité + validation à la saisie + lien stable » (modèle Adresses
généralisé à Client/Device/Service), optimisations P0/P1/P2, métriques de succès. Complète les docs
architecture existants + ENGINEERING_PRACTICES.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plus besoin de re-chercher avec un processus complexe : une page liste les adresses de service non
conformes (review/unmatched) avec leur proposition AQ canonique, et permet de RÉSOUDRE une fois (persisté) :
- Approuver : la proposition AQ devient officielle (validated, coords RQA).
- Corriger : recherche AQ locale (rqa_addresses + fibre) → lier la bonne adresse.
- GPS : saisir/coller lat,long (relevé sur map.targointernet.com qui a la géoloc des unités de camping)
+ lien direct « voir sur la carte » par ligne.
- Rejeter : pas d'adresse civique (boîte postale/hors-QC) → 'no_address'.
Tri par type (camping / civique à corriger / à confirmer / non-adresse) + stats + recherche + pagination.
Backend : lib/address-conformity.js (GET stats|list|candidates, POST resolve) sur le Postgres LOCAL,
routé /address/conformity/* (server.js). Front : api/address.js + pages/AddressConformityPage.vue + route
/conformite-adresses + entrée nav « Conformité adresses » (icône MapPinned, requires view_settings).
État courant : validated 15 195 · review 1 366 · unmatched 550 (camping 540 / civique 333 / non-adresse 93).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le hub n'appelle plus rddrjzptzhypltuzmere.supabase.co. La base RQA + dispo fibre est DÉJÀ locale
dans le Postgres ERPNext (rqa_addresses 5,24M + fiber_availability 21,6k jointes par address_id),
le hub y accède (réseau erpnext_erpnext + module pg).
- NOUVEAU lib/address-db.js : recherche locale. Phase 1 (civique présent) = filtre numero btree +
mots de rue → ~20-150 ms ; Phase 2 (sans civique) = word_similarity (`<%` indexable GIN) au lieu de
similarity() plein (24-76 s sur 5,24M !) → ~700 ms, dans une txn SET LOCAL (seuil 0.6 + statement_timeout 4s).
Renvoie 2 formes : searchLocal (mappée, compat historique) + searchRaw (colonnes brutes de la fonction).
- address-search.js : searchAddresses + searchAddressesRpc délèguent à address-db (plus aucun appel Supabase).
→ onboarding (/address/validate), checkout (/api/address-search) ET le pont (géocodage) passent en LOCAL.
- address-validate.js : endpoints PUBLICS pour le site web (CORS) — POST /rpc/search_addresses (compat
Supabase RPC, tableau direct) + GET /address/search — servis depuis le PG local (fiber_available inclus).
- server.js : route /rpc/ → address-validate.
Résultat pont (vérifié) : couverture 112/125 (vs 109 via Supabase), rqa_geocode 8→25 (table locale plus
complète + search_text désaccentué), Mapbox 37→23, no_coords 16→13, 0 erreur. Le local est meilleur.
Env hub : ADDR_DB_* dans /opt/targo-hub/.env (défauts erpnext-db-1/_eb65bdc0c4b1b2d6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- address-search.js : expose searchAddressesRpc() → RPC Postgres `search_addresses` (pg_trgm), la MÊME
recherche que l'autocomplete de disponibilité fibre. Trouve les rues que l'ilike manquait (générique géré
par la colonne odonyme_recompose_long + phase 2 trigram), priorise les CP J0L/J0S (territoire).
- geocodeRQA() (bridge) bascule de l'ilike vers la RPC. Garde-fou : la phase 2 trigram dérive quand le
civique est absent du RQA (« 2245 René-Vinet » → « Rue Grenet, Montréal »). On n'accepte un résultat que si
le civique concorde + un token de nom de rue correspond + (territoire J0L/J0S OU CP/ville legacy concordants).
Vérifié sur les données réelles : accepte 494 Av Curry / 3055 Routhier / 228 Principale / 61 Jean-François ;
rejette René-Vinet→Grenet/Panet, chemin Ridge→Ferme, rue West→Perras (bons faux positifs écartés).
- Le faible compte RQA (8) = haute précision (l'ilike comptait 17 dont des faux positifs). Mapbox couvre le
reste (rues neuves/civiques absents) ; ~109/125 (87 %) coordonnés ; les « aucune » = campings/villes mal écrites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pont (legacy-dispatch-sync.js) :
- Import des coordonnées par job via cascade : table legacy `delivery` (point de service exact,
JOIN ticket.delivery_id) > Service Location ERPNext > géocodage RQA > géocodage Mapbox.
Validation bornes Québec (coord()). Couverture 153/172 (89%).
- Géocodage RQA corrigé : retrait du générique de voie (Rue/Rang/Chemin absent de
odonyme_recompose_normal) + code postal non accolé au terme (sinon ilike ne matche jamais).
- Repli Mapbox geocoding pour rues trop récentes pour le RQA (MAPBOX_TOKEN).
- Backfill + UPGRADE : coords delivery écrasent des coords SL moins précises (jamais l'inverse).
- Comptabilité honnête : vérifie r.ok sur create/update (erp ne throw pas) → errors + error_samples.
- Verrou de sérialisation sync() : tick + runs manuels ne se chevauchent plus (frappe_pg).
- Subject tronqué à 140 (champ Data) → corrige CharacterLengthExceededError sur jobs sans SL.
- Observabilité : coord_src tally + error_samples dans le résumé.
Ops Planification (éditeur de journée) :
- travelBetween() consulte une matrice Mapbox Matrix chargée à l'ouverture (loadDayRoute) →
temps de trajet ROUTIERS RÉELS ; réordonnancement instantané sans nouvelle requête.
Repli haversine si Mapbox indispo. Indicateur 🚗 réel vs 📏 vol d'oiseau.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- FIX réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (au lieu du splice live jittery)
- durée éditable par job en MINUTES (pas de 5, best practice précision) → persistée via reorder-jobs (duration_h)
- temps de transport estimé entre 2 jobs (haversine sur coords Service Location, 40km/h + 5min) affiché entre les lignes
→ en attendant la géoloc live (Capacitor background-geolocation, noté pour plus tard)
- hub : occupancyByTechDay renvoie lat/lon par job ; reorder-jobs accepte duration_h ; total h en pied
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) :
timeline visuelle (blocs colorés par compétence) + liste éditable des jobs
- réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer
- retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool)
- bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch)
- réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ROOT CAUSE : LEGACY_DB_HOST='legacy-db' = réplica figé au 2026-04-07 → tickets fermés/réassignés depuis
paraissaient encore ouverts (ex. 244659). Fix infra : .env LEGACY_DB_HOST=10.100.80.100 (DB live, SELECT-only).
- closeResolved() : tout DJ issu du pont (open/assigned/On Hold) dont le ticket legacy est 'closed' → status 'Completed'.
Appelé à chaque sync + route POST /dispatch/legacy-sync/close-resolved. Résultat 1er run live : 125 ouverts réels,
102 créés, 44 fermés (dont LEG-244659). NB In Progress non touché.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable),
au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc
(skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill).
- Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec
réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer.
Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order.
- Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pont : extrait le lien connect_ministra.php du ticket_msg (déjà posté par le wizard legacy à la vente)
→ champ legacy_activation_url sur le Dispatch Job (backfill des 10 tickets TV). Read-only legacy, aucun 2e
chemin d'écriture → zéro risque de double-facturation (cf. analyse processus).
- store _mapJob : expose legacyActivationUrl ; RightPanel : bouton violet « 📺 Activer STB (Ministra) »
(MÊME lien que le tech reçoit, ouvre connect_ministra.php avec tid/did/sid/cr/m intacts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pont : stocke legacy_dept (dept osTicket) sur le Dispatch Job + backfill des 70 existants
- useHelpers.jobColor/legacyDeptColor : palette comme legacy (Installation vert, Réparation or,
Télé rose, Téléphonie vert pâle, Désinstallation rouge foncé) ; tech en pause = rouge vif prioritaire
- store _mapJob : expose legacyDept + legacyTicketId
- RightPanel : champ « Ticket legacy » (#id · dept) — le client est déjà un lien cliquable vers la fiche
- doc mise à jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tire régulièrement les tickets ouverts assignés au compte « Tech Targo » (staff 3301)
de la DB legacy MariaDB et crée/maj un Dispatch Job ERPNext (pool à répartir).
- lib/legacy-dispatch-sync.js : fetch (status=open AND assign_to=3301) + mapping
customer (legacy_account_id) / Service Location (coords) / job_type (dept) /
scheduled_date (epoch→America/Toronto) / start_time (am|pm|HH:MM) / priority
- Idempotent via Custom Field Dispatch Job.legacy_ticket_id (lookup avant create) ;
ne clobbe pas le travail du répartiteur (maj date seulement si encore open+non assigné)
- SÉQUENTIEL (frappe_pg) ; endpoints GET preview (dry-run) + POST run
- Récurrence opt-in : startSync() au boot, LEGACY_DISPATCH_SYNC=on + _MIN=15
- server.js : route /dispatch/legacy-sync + startSync()
- doc docs/features/legacy-dispatch-bridge.md + index
Mise en service : 70 tickets importés (0 erreur), récurrence 15 min active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).
Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.
Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).
Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.
Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.
Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clic sur une case → touche « A » (bascule) ou menu « Marquer absent ce jour / Retirer l'absence » →
crée/supprime une Tech Availability d'1 jour APPROUVÉE (hachurée tout de suite). Multi-sélection
supportée (A marque toutes, re-A retire). Endpoint POST /roster/absence/set {tech,date,type,remove}
(remove ne touche que les absences d'un jour, pas les vacances multi-jours). Type défaut Congé.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: « Appliquer à la semaine » n'écrivait qu'une semaine → 1 seul tech (rotation hebdo). Maintenant
« Générer la garde » matérialise la rotation sur N semaines (4/8/12/26) d'un coup, écrit direct (publié),
navigable semaine par semaine — comme un évènement récurrent. Endpoint /roster/garde/apply : wipe ciblé
des shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence). Saut
d'absent conservé. (source='manuel' car le Select n'autorise que solveur/manuel.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: /roster/publish-week supprimait TOUTES les assignations de la semaine puis les recréait toutes
(~2N écritures ERPNext séquentielles → plusieurs secondes/dizaines de s). Maintenant: diff par clé
tech|date|shift — supprime seulement les retirées, crée seulement les nouvelles, ignore les inchangées.
Republier sans changement: 87 assignations → 0 écriture, 37 ms (vs ~174 écritures avant). Une modif
de quelques cellules = quelques écritures seulement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tech Availability gagne long_term (Check). Case à cocher « Longue durée » dans le dialogue Congés.
absencesByTechDay encode « (longue durée) » → applyTemplate force « à remplacer » (pas juste sauter
comme des vacances), même si l'absence ne couvre pas toute la semaine. Complète l'intelligence
permanent vs vacances (avant: déduit de « absent toute la semaine »).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Garde (on_call) exclue partout des heures travaillées + du coût:
- hub statsByDay: heures = somme des shifts NON-garde ; nouveau compteur on_call/jour (techs en dispo).
- Ops: hoursOf (heures/tech + alerte heures supp) et costByDate/weekCost excluent la garde.
- nouvelle ligne de pied '🛡️ Garde' = nb de techs en disponibilité/jour (si applicable).
Cohérent avec l'occupation (déjà hors-garde) : la garde = réserve d'urgence, ni offerte ni facturée.
Vérifié 8 juin: 112 h travaillées (garde 6 h exclue), garde=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modèle: champ on_call (Check) sur Shift Template. Un quart garde:
- N'est JAMAIS offert au booking client (techGaps retourne null) — vérifié: tech Jour+Garde
n'offre que la fenêtre Jour, aucun créneau dans la plage de garde.
- Est EXCLU du dénominateur d'occupation (heures offrables), affiché à part.
- Timeline: bande HACHURÉE (vs neutre pour l'offrable) + 🛡️ dans le label + tag (garde) en infobulle.
- Éditeur de modèles: bascule '🛡️ Garde' pour créer/marquer un quart de garde.
hub: fetchTemplates expose on_call; create/update template le gèrent. Champ ajouté à
setup_dispatch_custom_fields.py (persistance). Démo: Garde 18h-minuit marquée on_call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).
- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.
- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes:
1) prompt « par défaut analyse » → ne déclenchait pas l'action ;
2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut
global En pause, pas les Tech Availability approuvées par jour) ;
3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte.
Fixes:
- roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until
+ Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates.
- roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV
impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les
« à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt
orienté ACTION (signaler une absence = instruction d'exécution).
- Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓,
rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end,
days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots →
cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre).
- Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée
des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking
pendant qu'un agent/client choisit.
- roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine.
- Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: liste plate jj/mm peu lisible. Maintenant: bandeau de semaine ('Semaine du 8 – 14 juin'),
en-têtes de jour complets (lundi 8 juin), sections Matin/Après-midi. Sélection 1-3 préférences inchangée.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: le job gardait scheduled_date → page /book court-circuitait en 'déjà confirmé' sans options.
Maintenant un report = vrai retour au pool → le client choisit un nouveau créneau (vérifié: 50 créneaux proposés, SMS livré).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le copilote peut désormais CHANGER les dispos en langage naturel ('marque Simon malade
aujourd'hui' → crée une Tech Availability approuvée) et réassigner un Dispatch Job, sur
instruction explicite, avec confirmation. Testé OK end-to-end. (SMS client + escalade = suite.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/roster-assistant.js : couche conversationnelle sur le roster (distincte du solveur OR-Tools).
Outils data réels (etat_equipe, jobs_du_technicien) via roster.fetchTechnicians + Dispatch Job.
Ex: 'TECH-4776 malade le 16 juin' → résout le nom, liste les RDV impactés, propose des techs
dispos qualifiés. Routes /roster/assistant + /roster/policy (politique persistée fichier).
Réutilise le moteur geminiChat de lib/agent.js (gemini-2.5-flash). Testé OK avec données réelles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Affiché barré (gris) si > prix de vente (Item Price), sur produits simples + variantes
(les bundles gardent barré=somme des composants). Catalogue lit le champ; page rend <s>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Curation = champ Item.show_in_store (case à cocher) ; catégorie=item_group, prix=Item Price, stock=Bin live
- Bundles via Product Bundle (prix vs somme barrée), variantes mono-attribut (delta prix + stock/valeur)
- Fix filtres erp.list (tableaux, pas objets — listUrl teste .length) + child-tables lues via erp.get parent
- Page: fetch /store/catalog au mount, repli démo, bandeau live/démo, stock simple respecté
Prérequis PG corrigé: retrait ORDER BY NULL (MySQLism) dans erpnext utilities/product.py
(validation de variante) — sinon création de variantes impossible sur PostgreSQL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Prix original (regular_price) disponible sur lignes récurrentes ET ponctuelles (avant: ponctuelles seulement)
- Si Prix original > Prix marketing → barré affiché (sommaire + récap + aperçu live dans l'éditeur)
- Forfait récurrent barré remonte au contrat (monthly_regular) → page d'acceptation affiche <s>99.95</s> 79.95/mois
- Cohérent avec install (install_regular 360→240). Aucun impact CRTC (rabais go-forward, jamais récupéré)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installation = vraie créance financée (champ install_fee : 240$ std → 10$/mois,
120$ simple → 5$/mois). Résiliation résidentielle ne facture que le solde restant
de l'install (service rendu) ; promotions/carte-cadeau jamais récupérées
(termination_fee_benefits=0). Récap reformulé (financement install, plus de
"promotion non étalée"). Conforme avant l'échéance du 12 juin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.
- hub lib/email-queue.js: GET list (by status, recipients read from each row's
full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.
Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 business accounts had commercial=0 but a company name (Ferme X Inc.,
Ville de Farnham, Les Jardins Sorel…), leaking into the residential report.
Rule is now: commercial = (commercial flag) OR (company present). Residential
@90$ drops 739→654 (0 company-bearing rows left); commercial 244→276.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marc Robidoux flagged as overpriced (129.95$) — he has a loyalty discount
(service id 74448) that should lower it. Investigation: 74448 doesn't
exist in the copy (its max service id is 74393), so the discount was added
after the snapshot. Same freshness issue as Julie Dupuis — not a calc bug.
But this also exposed that the freshness banner was wrong: it read the
newest INVOICE date (Apr 30) while the snapshot actually carries SERVICES
created through May 22 — May's recurring billing run simply hadn't executed
at dump time, so invoices lag services by ~3 weeks. For a report that reads
active services/plans/discounts, the service date is the right freshness
signal.
fetchDataAsOf now returns both {services, invoices}; data_as_of (shown in
the banner) is the service date (May 22), with last_invoice kept for
reference. The copy is ~10 days stale, not ~1 month. Marc's loyalty credit
still won't show until the copy is refreshed (task #38).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User flagged Claude Bergeron at 99.95$ "including TV". Investigation: the
report already excludes TV (cat 33/34) — his cat-32 Internet subtotal was
94.95. The real issue was the opposite of what it looked like: a -60$
RAB_FTTH_URBA discount on his account lives in cat 26 ("équipement fibre"),
which the report did NOT count. His true net Internet is 44.95$, so he
should drop off the >90$ list entirely (and now does).
Internet equipment categories (26/29 fibre, 7/8 wireless) carry recurring
items that belong on the Internet bill:
- modem/router rentals: FTTH_LOCMOD +10, LOC_TPL +5, LOCRTHG8245 +6.95
- Internet discounts: RAB_FTTH_URBA (hijacked to -60 for Claude)
Added them to CAT_INTERNET_CORE. The existing price_recurr_type=1 filter
still drops one-time install charges (INSTFIBRE -199, etc.) that share
these categories. Verified HVSECTOUR/INSTTELE (odd high-price items in
cat 7/8) have zero active residential services — no aberrations introduced.
Net effect: the report's "net Internet" is now truly net of every
recurring Internet discount, wherever it's categorized. Residential >90$:
554 → 739 (modem rentals legitimately lift borderline bills; deep
equipment-category discounts like Claude's pull others below the line).
TV and téléphonie remain fully excluded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.
Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):
1. data_as_of — the report now reports MAX(invoice.date_orig) from the
copy and the Ops page shows a banner ("Données legacy au 30 avril —
copie figée, N jours"). Turns orange past 7 days so nobody acts on
stale prices unknowingly.
2. recent_expired_discount column — per-address sum of deactivated credit
lines (status=0, price<0) whose actif_until fell in the last 180 days.
Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
-35 expired 2026-03-01), i.e. the prime retention targets whose bill is
about to jump. Shown in amber with a warning icon + tooltip; included in
the CSV.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User flagged that several listed accounts are inactive (Or Viande Inc,
Denis Henderson). Root cause: I filtered service.status=1 but NOT the
account, so terminated accounts carrying an orphan active service line
slipped through. The legacy billing job (LEGACY-ACCOUNTING-ANALYSIS.md
§6.1) bills only when BOTH service.status=1 AND account.status=1.
Three account-level filters added:
- account.status = 1 → drops terminated accounts. Or Viande Inc is
status=4, terminated 2014 (terminate_date set), but still had a
service.status=1 row. 8602 accounts are status=4 vs 6537 status=1.
- account.group_id = 5 → "Client" per account_group. Drops 6 Prospect,
7 Fournisseur, 8 Relais (network infra, e.g. Denis Henderson's
REL_CHRY_CHARLES tower account), 10 Équipement motorisé.
- customer_id NOT LIKE 'PROPRIO%' → 59 landowner-hosts-our-gear accounts
that live in group 5 but aren't paying customers (Denis Henderson's
other account PROPRIOH_STCHARLES). A genuine same-name customer
(Robert Henderson, ROBEH...) correctly stays.
Residential >90$/mo: 983 → 554 (was inflated ~44% by dead/non-customer
accounts). Commercial: 255 → 240.
Ops page note updated to state "comptes clients actifs uniquement" and
list what's excluded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewed against docs/archive/LEGACY-ACCOUNTING-ANALYSIS.md (the migration
audit) which surfaced two things to check in the overpriced-internet report:
1. service.payment_recurrence (0=annual, 2=monthly, 5=semestrial...) —
checked whether per-cycle prices needed /N normalization. They do NOT:
verified a semestrial FTTH1500I carries product.price=109.95, identical
to the monthly one (billed 6×109.95 every 6 months). Per §6.1
"prix = quantité × prix_unitaire", product.price is already the monthly
unit price. The original monthly logic was correct — no division. The
SKU-LIKE-'%ANN' /12 special-case stays (true annual plans where price
IS the yearly amount, e.g. FTTH_ANN @ 480$/yr).
2. Promo credits carry an actif_until end date (§10). A discount line whose
actif_until is past no longer reduces today's bill, so counting it
understates what the client actually pays. Now excluded.
NULL-safety: the exclusion needs an explicit `actif_until IS NOT NULL`
guard — without it, `NOT (price<0 AND actif_until>0 AND actif_until<now)`
evaluates to NULL for permanent credits (actif_until NULL), which SQL
treats as not-true and silently DROPS every permanent credit line. That
briefly inflated the residential count to 3330; with the guard it's a
correct 1000 (vs 983 before — +17 addresses whose only sub-90 reason was
a now-expired credit).
Net effect: the report reflects the *current* real monthly Internet bill.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Ops report to surface clients whose net monthly Internet bill
exceeds a threshold — for spotting plans that should be revised.
Hub (lib/legacy-reports.js — new module, read-only MariaDB):
- GET /reports/legacy/overpriced-internet (+ .csv variant)
- Queries the legacy gestionclient DB directly via a small mysql2 pool
(reuses cfg.LEGACY_DB_* — same vars as auth.js sync-legacy; added
LEGACY_DB_PASS to the hub .env which was previously unset).
- Grain = delivery (service address), NOT account: a multi-unit
building (account 13166 has 82 doors / 205 services) would otherwise
show a single bogus $2117 line instead of ~45 per door.
- Net monthly Internet = SUM of effective per-line price across
Internet categories (32 fibre, 4 wireless, 23 camping + optional
add-ons 16/17/21), discounts included (products with price<0 are
recurring credits like RAB24M -15$).
- Effective price = service.hijack ? hijack_price : product.price.
- Only recurring lines (product.price_recurr_type=1) — excludes
one-time equipment/install charges.
- Annual plans (SKU LIKE '%ANN', e.g. FTTH_ANN @ 480$/yr) normalized
/12 so they compare correctly against a monthly threshold (was
falsely showing $480 → now $40, drops below 90$).
- Excludes TV (33,34) and téléphonie (9) entirely.
Validated counts at 90$/mo: 983 residential, 297 commercial addresses.
Ops UI:
- src/pages/ReportInternetCherPage.vue — threshold/segment/add-ons
filters, summary cards (count, total monthly, avg, discounts),
sortable+filterable table (client, address, net, gross, discount,
plan detail with full tooltip, contact), CSV download.
- Card on the Rapports hub + route /rapports/internet-cher.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.
Hub:
- New cascadeClickToParent() helper — when a recipient with
parent_campaign_id is flagged as gift_link_clicked, mirror the flag
+ timestamp onto parent.recipients[parent_row_index] and broadcast
a recipient-update SSE event so the parent's open page refreshes
live. Adds a gift_clicked_via_reminder breadcrumb (the child
campaign id) so the parent UI can show "↩ via la relance XXX".
Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
successful redirect (Mailjet webhook can lag or drop; the wrapper
redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
counters for every reminder child of the campaign.
Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
click came via reminder, plus a "↩ via la relance XXX" line in the
tooltip so the operator can trace the engagement channel.
One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The targo-hub container runs with TZ=UTC (no override set). Calls to
toLocaleDateString without an explicit timeZone option were rendering
dates in UTC, which meant a wrapper expiring at 23:59 EDT (= 03:59
UTC next day) showed "22 juin 2026" to the recipient instead of the
intended "21 juin".
All 4 date-formatting sites in lib/campaigns.js now pass
timeZone: 'America/Montreal' explicitly:
- worker (sendCampaignAsync) — main send path
- /campaigns/:id/recipients/:i/view — web fallback render
- POST /templates/:name/test-send sample defaults
- POST /templates/:name/preview sample defaults
Verified on prod: stored UTC "2026-06-22T03:59:59Z" now formats
"21 juin 2026" / "June 21, 2026" with the timeZone option, matching
the operator's intent ("expiration en fin de journée le 21 juin EDT").
Also re-patched the relance draft cmp-20260601-f857cd-rem from
2026-06-21T23:59:59Z (= 19:59 EDT, the early-evening cutoff) to
2026-06-22T03:59:59Z (= 23:59:59 EDT, true end of day). Bonus: this
aligns with the original campaign's recipients which expire around
~11:57-12:04 EDT on June 21, so the reminder always works at least
as long as the original — never the inverse confusion.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.
Worker resolution order:
1. params.gift_expires_at (full ISO datetime, set by the date picker)
— all recipients of this campaign get THIS exact date, regardless
of when the worker fires the send.
2. Fallback: now() + gift_expiry_days (relative deadline, shifts
forward by queue lag).
UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
active so the operator sees both representations
UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.
dateAfterToday validator blocks past dates in the picker.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The reminder copy read as pushy on test sends ("Hâte-toi! ... Tu n'as
encore rien fait, et le délai approche"). Toned down to factual and
friendly: state availability + offer the no-pressure path.
FR before / after:
⏰ Hâte-toi! Ton cadeau de 60 $ expire le ___. (red bold)
→ 🎁 Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026.
(brand dark green)
Tu n'as encore rien fait, et le délai approche. Si tu n'utilises
pas ton cadeau d'ici là, il ne pourra plus être réclamé.
→ On voulait juste s'assurer que tu ne l'as pas manqué — la carte-
cadeau qu'on t'a envoyée peut s'utiliser chez des centaines de
marques canadiennes, en quelques clics.
Si tu préfères ne pas l'utiliser, aucun souci — pas besoin de
répondre à ce courriel.
EN copy mirrored.
Also: {{expires_at_date}} was rendering empty in test sends and
previews because neither the test-send endpoint, the preview
endpoint, nor the editor's testSendForm.vars seeded it. Three fixes:
- Hub preview endpoint: compute now+30d as default sample date.
- Hub test-send endpoint: same default + expose view_url='' so the
Mustache section block collapses cleanly in internal tests.
- Editor test-send dialog: pre-fill expires_at_date (and expires_in_
days) with the same now+30d value, plus expose both fields as
editable inputs so the operator can override per-test.
Verified live on prod: the preview endpoint with no vars now renders
"Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026."
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.
Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
same name/email/language/amount, but cleared gift_token so the worker
generates a brand-new wrapper at send time. Each campaign owns its
own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
for traceability in CSV reports and debugging.
Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: "⏰ Hâte-toi! Ton cadeau de X expire le Y"
using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
main templates so brand stays consistent.
UI:
- Button visible when campaign is sending/completed AND it's not
itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
new wrapper tokens, reminder template, sample expiry date pulled
from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.
Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>