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>
- Plages combinables: « Soirs de semaine » (L-V) + « Fin de semaine » (S-D) en toggles (+ chips fins).
- Rotation par défaut = PAR SEMAINE (garde = bloc hebdo) → corrige la séquence brisée (par jour, un
week-end pouvait avoir 2 personnes). Index de semaine continu dans le temps → ordre respecté en
naviguant. Séquence [A,A,B] = A 2 semaines consécutives, puis B. (Sélecteur jour/semaine conservé.)
- APERÇU live : qui est de garde sur les 8 prochaines semaines, reflète la file en cours d'édition →
on voit l'ordre respecté + l'effet des modifs avant d'appliquer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: rotation par semaine → dans une vue d'1 semaine, un seul membre apparaissait. Maintenant la
rotation avance par OCCURRENCE de garde (occurrenceIndex = nb de jours matching weekdays depuis l'époque)
→ chaque jour de garde prend le membre suivant de la séquence (A,A,B,C…), visible sur toute la vue.
Sélecteur « Rotation : par jour de garde / par semaine » (+ N consécutifs). Défaut = par jour ;
les règles existantes (sans unit) basculent en par-jour. Saut d'absent + doublons conservés.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le multi-select est remplacé par un constructeur de SUITE ordonnée : « Ajouter un tech à la suite »
(push, doublons autorisés → tours inégaux ex. A,A,B,C) ; chaque position a un select pour REMPLACER
le tech, + ↑/↓ pour réordonner, + ✕ pour retirer. Couvre rotations inégales et substitutions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix listes vides: département = champ libre optionnel (existants OU texte), liste des techs = TOUS
(plus de désactivation sur dept). Réordonnancement de la rotation (↑/↓), édition d'une règle (crayon
→ recharge dans le formulaire → Mettre à jour). Champ « Sem. consécutives / tech » (mettre 2 = un tech
fait 2 semaines de suite). Annuler l'édition.
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>
Dialogue « Garde » : règles par département (tech_group) = {shift de garde, jours, période (toutes
les X sem.), techs en rotation ordonnés}. Indépendantes entre départements (non synchronisées).
« Appliquer à la semaine » génère les gardes : pour chaque jour ciblé, le tech de garde = rotation
(index de semaine / période % liste) ; un tech absent est SAUTÉ au profit du suivant. Règles persistées
(localStorage roster-garde-rules-v1). Les gardes s'affichent en pointillé ambre (on_call), hors heures
travaillées/booking déjà en place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marquer un modèle de semaine comme défaut (★ dans le menu Modèles, un seul à la fois) →
bouton ★ <nom> dans la barre pour l'appliquer en 1 clic (avec l'intelligence d'absences déjà en place).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyTemplate respecte maintenant les absences : on n'assigne pas un tech absent ce jour-là.
- Absent toute la semaine (≈ congé permanent: maternité/blessure) → signalé « à remplacer » (warning).
- Absent quelques jours (≈ vacances) → ces jours sautés, le reste du patron tient.
Le toast résume: N assignations, absences partielles ignorées, et qui est à remplacer (avec le type).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Menu réduit: 2 raccourcis (Normal 8–17, Soir 16–20) + slider d'ajustement remontés EN HAUT
(près du clic) ; Appliquer dans la rangée du slider → reste visible même si la case est en bas
(max-height 85vh + flip auto Quasar). Retrait de la longue liste des modèles.
- quickShift(min,max) + applyWindow() factorisés. Shifts en place + Copier/Coller/Vider en icônes
compactes sous le slider. Nettoyage cellCode/cellColor/codeByShift/colorByShift/addFromMenu inutilisés.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onKey: garde anti-champ (n'intercepte pas dans input/textarea/select/contenteditable) + empêche
le 'retour arrière' du navigateur (preventDefault). Suppr/⌫ vide la sélection (ou la case cliquée),
avec pushHistory (annulable). Hint de légende mis à jour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La bande pâle se confondait avec le fond gris du timeline → contour 1px bleu-violet foncé
(rgba 55,65,120,.5) box-sizing border-box pour ne pas déborder. Garde = contour brun.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- user-select:none sur le menu (rendu en portail → n'héritait pas du no-select de la grille)
→ le glissement ne sélectionne plus le texte.
- Retrait des bulles de label de la q-range (8h/18:30h) qui changeaient de largeur → le menu ne
se redimensionne plus. La valeur reste affichée en direct sous le slider (8h–18:30h).
- Largeur du menu fixée (260px) + @mousedown.stop sur le slider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- En-tête de chaque jour: règle horaire (graduations alignées sur l'axe adaptatif, ex 8/12/16/20).
- Cellule réduite à LA barre de dispo (dégradé matin→soir, occupé assombri, garde hachuré) —
plus de texte d'intervalle : on lit la position contre la règle, détails au survol.
- Barre un peu plus haute (11px). Retrait cellLabel/cell-int/cell-chips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cellules: plus de chip-lettre (J/S/M/D). La bande de la timeline est colorée par l'HEURE :
dégradé bleu pâle le matin → violet le soir (hsl 210→270). On lit 'quand' d'un coup d'œil.
- Occupé = assombrit la bande (clair = libre/offrable, foncé = pris) ; rouge si surbooké.
(Remplace le feu vert/orange/rouge — plus sobre.) Garde reste hachurée.
- Légende: échantillon dégradé « matin → soir » + « garde » (hachuré) au lieu de la liste de lettres.
- Intervalle (8–16) gardé en texte. occColor retiré.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Nettoyage des modèles auto en double fait côté données (8h–16h→Jour, 7h–15h→Matinal,
9h–17h→Décalé, 8h–17h→Jour, 8h–22h→Soir) — restent 5 presets propres.
- Barre d'assignation + légende = presetTemplates (modèles NOMMÉS seulement) → restent propres
même si des modèles auto réapparaissent.
- Fix copier-coller : le clic ouvrait le menu ET vidait la sélection → Cmd+C voyait 0 cellule.
Maintenant on mémorise activeCell (dernière case cliquée) ; Cmd+C/V ferment le menu et marchent
sans multi-sélection. Coller = vers la sélection si multi, sinon la case active.
- Indicateur « N copié(s) » visible. Coller multi-cases via la barre (déjà) + Cmd+V sur sélection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUG: le menu de case plantait sur les cases occupées (cellHours retiré mais encore appelé l.259)
→ c'est ce qui cassait le copier-coller au clic. Corrigé ({{ a.hours }}h).
- Copier/Coller déplacés dans le MENU de la case (clic simple, plus besoin de Cmd+clic) :
« Copier cette case » / « Coller ». (Boutons barre + Ctrl/Cmd+C/V conservés.)
- « Ajuster l'horaire (glisser) » : q-range dans le menu → élargir/réduire le créneau de dispo,
+ toggle Garde. Applique = trouve/crée un modèle auto-nommé (8h–18h) et remplace la case.
→ la dispo élargie est aussitôt offerte au booking (modèle standard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Copier-coller pour bâtir l'horaire vite : sélectionne une case → Copier (ses shifts) →
sélectionne des cibles → Coller (duplique). Boutons dans la barre + raccourcis Ctrl+C / Ctrl+V.
Copier une case vide puis Coller = vider les cases.
- Créneaux CUSTOM : nouveau modèle créé via slider q-range (2 poignées, pas 0.5 h) → plus besoin
de prévoir tous les types. Nom AUTO si vide (« 8h–17h » d'après les heures).
- Presets standards semés : 7h–15h, 9h–17h, 8h–17h (+ Jour 8h-16h existant) — triés par usage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retrait des icônes en cellule (☀/🌆/🌙/🛡️). Le libellé = uniquement l'intervalle début–fin
(ex 8–16) ; le timeline (bande pleine vs garde hachurée) porte le reste visuellement.
Tag garde dans l'infobulle: '(garde)' au lieu de l'emoji.
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>
#1 axe trop large (0-24) → axe ADAPTATIF calé sur l'amplitude réelle des shifts réguliers de la
semaine (garde n'élargit pas) → barres plus larges, position lisible. Intervalle début–fin REMIS
en texte dans la cellule (☀ 8–16 🛡️) = référence sans survol. Infobulle = capacité offrable
(corrige aussi un bug: shiftH→bookableH).
#2 modèles d'assignation TRIÉS PAR USAGE (les plus utilisés en premier) + infobulle nom.
(Rappel: créer des modèles custom = éditeur « Types de shift ».)
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>
RendezVousPage:
- Vue segmentée À planifier / À recontacter / Tous.
- Créneaux proposés groupés Semaine → Jour (se situer dans le temps, comme /book).
- Hold à la sélection: bookHold(date,start,10min) → bloque les autres; libéré à la confirmation
ou au changement de job (onBeforeUnmount).
- File À recontacter (jobs À reporter) + actions: Lien client (copie URL self-serve),
Aviser par SMS (notify-reschedule: désassigne + SMS lien /book).
CopilotePage: carte réglages des créneaux offerts (#56) — lead_hours, plage horaire,
horizon, max/jour, hold, jours offerts (chips) → savePolicy({booking}).
api/roster.js: bookHold, bookLink, jobsToReschedule, notifyReschedule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: ne rechargeait que sur 401/403 stricts → ratait la redirection IdP / page HTML de
login (vrai cas d'expiration) → données vides nécessitant un refresh manuel. Maintenant:
détecte redirection auth.targo.ca + HTML-au-lieu-de-JSON → reload auto (anti-boucle 20s),
+ 1 retry sur coupure réseau transitoire (ex: backend qui redémarre).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Corrige les manques signalés: champ technicien (congés) → autosuggest typeahead; compétences
(demande de shift + éditeur équipe) → chips au lieu de texte libre. Composants réutilisables
pour une UX cohérente partout (et le copilote/réassignation à venir).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nouvelle page /copilote : chat texte/voix (Web Speech API fr-CA) vers le copilote Gemini Flash
(impact d'absence + propositions de réassignation), + sélecteur de politique de reprise
(réassign/SMS/escalade) persistée. Route + nav (icône Sparkles ; ajout CalendarRange/CalendarClock
manquantes dans la map d'icônes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Presets Standard 360→240 (10$/mois) / Simple 180→120 (5$/mois) / Aucune
- 2 cases éditables Prix original / Prix financé + aperçu live (barré, $/mois, crédité→0$)
- Alimente install_fee/install_regular du Service Contract → page d'acceptation affiche le détail
- Placé sous Code de référence dans la vue résidentielle
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>
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>
Adds a sortable 'Ville' column (field city) to the report. Quasar's default
filter scans all columns, so the existing search box now matches city too.
Street address caption drops the now-redundant city (keeps postal code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cogeco's address checker is gated by reCAPTCHA Enterprise (risk-score 401
on the protected /boutique/api/address/search call), so per-address
serviceability can't be scraped reliably from a datacenter IP without a
residential proxy. Per product decision, pivot to an assisted spot-check
instead of automated qualification.
- ReportInternetCherPage: add a "Concurrent" column with a one-click
button that copies the full service address and opens Cogeco's
availability page in a new tab (human reads the verdict in ~10s, only
for the leads that matter). fullAddress() builds "addr, city, QC ZIP".
- cogeco-checker: harden the POC anyway — track service-address/search
responses, retry the verdict call on 401 (re-register cadence), and
prioritize the authoritative JSON body in interpret(). Recon confirmed
the wall is reCAPTCHA scoring, not a timing/selector bug.
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>
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 edit-params picker was showing "2026-06-22" for an expiry stored
as 2026-06-22T03:59:59Z because it sliced the UTC string. But that
UTC instant is actually 23:59 EDT on June 21 in Montreal, which is
what the email recipient sees (and what the operator picked).
Fixes both sides of the round-trip:
DISPLAY (UTC → picker)
- Convert stored ISO UTC to YYYY-MM-DD interpreted in America/Montreal
using en-CA locale (which returns ISO-style YYYY-MM-DD).
SAVE (picker → ISO UTC)
- New endOfDayMontreal() helper that probes Montreal's offset for the
target date (noon UTC always lands in morning Montreal, never spans
a day) and anchors at 23:59:59.999 local. Handles EDT/EST swaps
automatically — verified with edge cases 2026-03-08 (post-DST-spring),
2026-06-21 (mid-summer), 2026-11-01 (post-DST-fall), 2026-12-31 (winter).
Previously the save path relied on the BROWSER's local TZ inference
(new Date('YYYY-MM-DDT23:59:59').toISOString()) which is fine for
Quebec operators but quietly wrong for anyone editing from elsewhere.
The bulk email send was already correct because the worker's
toLocaleDateString uses timeZone: 'America/Montreal' (last commit).
This commit only fixes what the OPERATOR sees in the picker.
🤖 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>
Two new buttons on the campaign detail page header — both visible only
when campaign.status === 'draft' to keep operators from accidentally
mutating a campaign mid-send.
"Éditer les paramètres" → q-dialog with:
- name (internal)
- subject (the email Subject: line)
- from (sender)
- amount displayed in the body (overrides per-recipient default)
- commitment_months
- expiry text
- template_fr / template_en dropdowns (refresh on popup-show so newly
created templates show up without a page reload)
Saves via the existing PATCH /campaigns/:id, which merges into
params. A live load() refresh updates the Confirmation recap and any
visible counters.
"Éditer le template" → opens the Unlayer editor in a new tab on the
campaign's configured template_fr (most TARGO customers FR). For
campaign-specific tweaks the dialog tells the operator to create a
variant template (+ Nouveau) and select it here.
Addresses the gap a user hit on a reminder draft — they wanted to add
a condition to the body before launching but had no edit affordance
on the detail page.
🤖 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>
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.
Three changes:
1. lib/email.js exposes getLastError() — a side-channel for the most
recent nodemailer error message, cleared at the start of every
sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
the false-return contract; only the campaign worker reads the
side-channel for detailed error capture.
2. The worker now retries each recipient up to 3 times (initial +
2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
transient Mailjet errors recover on the second attempt. We observed
exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
single socket close interrupted 1 of 202 sends; auto-retry would
have caught it. retry_count is now stored on the recipient.
3. POST /campaigns/:id/recipients/:row/retry resets a single failed
row back to pending and re-fires the worker. Surfaced in the
detail-page table as a small 🔁 button next to the error text on
any row with status=failed. Useful when auto-retry exhausted its
3 attempts on a one-off transient.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.
Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →
The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.
The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>