diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index d196ccf..4436449 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -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, diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue index 63e5d78..0ce95f8 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -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 }