fix(campaigns/expiry-picker): show + save dates in America/Montreal TZ

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>
This commit is contained in:
louispaulb 2026-06-01 15:06:57 -04:00
parent ddedd60320
commit a07b45235a
2 changed files with 44 additions and 7 deletions

View File

@ -253,6 +253,23 @@ function dateAfterToday (dateStr) {
return d.getTime() > Date.now()
}
// Convert a YYYY-MM-DD string to the ISO UTC of end-of-day on that date,
// interpreted as Montreal local. Handles EDT (UTC-4 summer) and EST (UTC-5
// winter) by probing what hour Intl reports at noon UTC on the same date
// noon UTC is always morning in Montreal, so the date never rolls over.
function endOfDayMontreal (yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number)
const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)) // noon UTC on the target date
const mtlHour = parseInt(new Intl.DateTimeFormat('en-US', {
hour: '2-digit', hour12: false, timeZone: 'America/Montreal',
}).format(probe), 10)
// Noon UTC reads as 08:00 in EDT or 07:00 in EST offset = 12 - mtlHour
const offsetHours = 12 - mtlHour
// End of day Montreal = 23:59:59 Mtl (23 + offsetHours):59:59 UTC, rolls
// over to the next UTC day during summer (EDT).
return new Date(Date.UTC(y, m - 1, d, 23 + offsetHours, 59, 59, 999)).toISOString()
}
// Template lists for the FR/EN dropdowns in the dialog. Refreshed lazily
// when the dropdown opens so newly-created templates show up without a
// page reload.
@ -443,9 +460,15 @@ async function retryFailedRow (row) {
// dropdowns are populated when the dialog mounts.
function openEditParams () {
const p = campaign.value?.params || {}
// Convert the stored full ISO datetime back to YYYY-MM-DD for the picker.
// Convert the stored UTC datetime back to YYYY-MM-DD as displayed in
// America/Montreal without this, a timestamp stored as 03:59 UTC
// (= 23:59 EDT the previous day) shows the WRONG day in the picker
// because .toISOString() outputs UTC. en-CA locale gives YYYY-MM-DD.
const expiresAtDisplay = p.gift_expires_at
? new Date(p.gift_expires_at).toISOString().slice(0, 10)
? new Intl.DateTimeFormat('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit',
timeZone: 'America/Montreal',
}).format(new Date(p.gift_expires_at))
: ''
editParams.value = {
name: campaign.value?.name || '',
@ -469,11 +492,13 @@ async function saveEditParams () {
savingParams.value = true
try {
// Translate the YYYY-MM-DD picker value into the full ISO datetime the
// hub stores. Anchor at end-of-day local so "until June 15" stays valid
// through all of June 15. Empty fall back to gift_expiry_days on the
// hub side. PATCH merges into params, so explicitly send null to clear.
// hub stores. Anchor at end-of-day America/Montreal so "until June 21"
// stays valid through all of June 21 Quebec time, regardless of where
// the operator's browser is set. Empty fall back to gift_expiry_days
// on the hub side. PATCH merges into params, so explicitly send null
// to clear.
const giftExpiresAt = editParams.value.gift_expires_at_display
? new Date(editParams.value.gift_expires_at_display + 'T23:59:59').toISOString()
? endOfDayMontreal(editParams.value.gift_expires_at_display)
: null
await updateCampaign(id, {
name: editParams.value.name,

View File

@ -642,6 +642,17 @@ function dateAfterToday (date) {
d.setHours(23, 59, 59, 999)
return d.getTime() > Date.now()
}
// Mirror of CampaignDetailPage's helper see that file for the algorithm.
// Anchors at end-of-day America/Montreal regardless of the operator's TZ.
function endOfDayMontreal (yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number)
const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0))
const mtlHour = parseInt(new Intl.DateTimeFormat('en-US', {
hour: '2-digit', hour12: false, timeZone: 'America/Montreal',
}).format(probe), 10)
const offsetHours = 12 - mtlHour
return new Date(Date.UTC(y, m - 1, d, 23 + offsetHours, 59, 59, 999)).toISOString()
}
const daysUntilExplicitDate = computed(() => {
if (!params.value.gift_expires_at_display) return 0
const target = new Date(params.value.gift_expires_at_display + 'T23:59:59')
@ -1082,7 +1093,8 @@ async function launchSend () {
// local time so "until June 15" really means through all of June 15.
const apiParams = { ...params.value }
if (apiParams.gift_expires_at_display) {
apiParams.gift_expires_at = new Date(apiParams.gift_expires_at_display + 'T23:59:59').toISOString()
// Anchor at 23:59:59 America/Montreal handles EDT/EST automatically.
apiParams.gift_expires_at = endOfDayMontreal(apiParams.gift_expires_at_display)
} else {
delete apiParams.gift_expires_at
}