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:
parent
ddedd60320
commit
a07b45235a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user