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()
|
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
|
// Template lists for the FR/EN dropdowns in the dialog. Refreshed lazily
|
||||||
// when the dropdown opens so newly-created templates show up without a
|
// when the dropdown opens so newly-created templates show up without a
|
||||||
// page reload.
|
// page reload.
|
||||||
|
|
@ -443,9 +460,15 @@ async function retryFailedRow (row) {
|
||||||
// dropdowns are populated when the dialog mounts.
|
// dropdowns are populated when the dialog mounts.
|
||||||
function openEditParams () {
|
function openEditParams () {
|
||||||
const p = campaign.value?.params || {}
|
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
|
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 = {
|
editParams.value = {
|
||||||
name: campaign.value?.name || '',
|
name: campaign.value?.name || '',
|
||||||
|
|
@ -469,11 +492,13 @@ async function saveEditParams () {
|
||||||
savingParams.value = true
|
savingParams.value = true
|
||||||
try {
|
try {
|
||||||
// Translate the YYYY-MM-DD picker value into the full ISO datetime the
|
// 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
|
// hub stores. Anchor at end-of-day America/Montreal so "until June 21"
|
||||||
// through all of June 15. Empty → fall back to gift_expiry_days on the
|
// stays valid through all of June 21 Quebec time, regardless of where
|
||||||
// hub side. PATCH merges into params, so explicitly send null to clear.
|
// 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
|
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
|
: null
|
||||||
await updateCampaign(id, {
|
await updateCampaign(id, {
|
||||||
name: editParams.value.name,
|
name: editParams.value.name,
|
||||||
|
|
|
||||||
|
|
@ -642,6 +642,17 @@ function dateAfterToday (date) {
|
||||||
d.setHours(23, 59, 59, 999)
|
d.setHours(23, 59, 59, 999)
|
||||||
return d.getTime() > Date.now()
|
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(() => {
|
const daysUntilExplicitDate = computed(() => {
|
||||||
if (!params.value.gift_expires_at_display) return 0
|
if (!params.value.gift_expires_at_display) return 0
|
||||||
const target = new Date(params.value.gift_expires_at_display + 'T23:59:59')
|
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.
|
// local time so "until June 15" really means through all of June 15.
|
||||||
const apiParams = { ...params.value }
|
const apiParams = { ...params.value }
|
||||||
if (apiParams.gift_expires_at_display) {
|
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 {
|
} else {
|
||||||
delete apiParams.gift_expires_at
|
delete apiParams.gift_expires_at
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user