From 7487a2f59c0168fc540632f9e474e51927e3e1b8 Mon Sep 17 00:00:00 2001 From: wslany <1747888+wslany@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:42:10 +0100 Subject: [PATCH 1/3] Add failing DST regression for duplicate specific-times days # Conflicts: # frontend/src/utils/date_utils.js # frontend/src/utils/date_utils.test.js --- .../schedule_overlap/ScheduleOverlap.vue | 41 +++----- frontend/src/utils/date_utils.js | 46 +++++++++ frontend/src/utils/date_utils.test.js | 95 +++++++++++++++++++ 3 files changed, 153 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue index c4886e71..d860c228 100644 --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue @@ -1019,6 +1019,7 @@ import { _delete, get, getDateDayOffset, + getSpecificTimesDayStarts, isDateBetween, generateEnabledCalendarsPayload, isTouchEnabled, @@ -1477,35 +1478,17 @@ export default { (this.state === this.states.SET_SPECIFIC_TIMES || this.event.times?.length === 0) ) { - let prevDate = null // Stores the prevDate to check if the current date is consecutive to the previous date - for (let i = 0; i < this.event.dates.length; ++i) { - const date = new Date(this.event.dates[i]) - const localDate = new Date( - date.getTime() - this.timezoneOffset * 60 * 1000 - ) - localDate.setUTCHours(0, 0, 0, 0) - localDate.setTime( - localDate.getTime() + this.timezoneOffset * 60 * 1000 - ) - - if (!datesSoFar.has(localDate.getTime())) { - datesSoFar.add(localDate.getTime()) - - let isConsecutive = true - if (prevDate) { - isConsecutive = - prevDate.getTime() === localDate.getTime() - 24 * 60 * 60 * 1000 - } - const { dayString, dateString } = getDateString(localDate) - days.push({ - dayText: dayString, - dateString, - dateObject: localDate, - isConsecutive, - }) - - prevDate = new Date(localDate) - } + for (const day of getSpecificTimesDayStarts( + this.event.dates, + this.timezoneOffset + )) { + const { dayString, dateString } = getDateString(day.dateObject) + days.push({ + dayText: dayString, + dateString, + dateObject: day.dateObject, + isConsecutive: day.isConsecutive, + }) } return days } diff --git a/frontend/src/utils/date_utils.js b/frontend/src/utils/date_utils.js index 33a5e744..64bfeb86 100644 --- a/frontend/src/utils/date_utils.js +++ b/frontend/src/utils/date_utils.js @@ -228,6 +228,52 @@ export const getScheduleTimezoneOffset = ( ) } +const getDateInTimezone = (date, curTimezone) => { + if (curTimezone?.value) { + return dayjs(date).tz(curTimezone.value) + } + + if ("offset" in curTimezone) { + return dayjs(date).utcOffset(curTimezone.offset) + } + + return dayjs(date) +} + +/** Returns the unique day-start datetimes for specific-times events */ +export const getSpecificTimesDayStarts = (eventDates, curTimezone) => { + const days = [] + const datesSoFar = new Set() + let prevDay = null + + for (const eventDate of eventDates) { + const localDate = getDateInTimezone(eventDate, curTimezone) + .startOf("day") + .toDate() + + if (!datesSoFar.has(localDate.getTime())) { + datesSoFar.add(localDate.getTime()) + + let isConsecutive = true + if (prevDay) { + isConsecutive = prevDay.add(1, "day").isSame( + getDateInTimezone(localDate, curTimezone), + "day" + ) + } + + days.push({ + dateObject: localDate, + isConsecutive, + }) + + prevDay = getDateInTimezone(localDate, curTimezone) + } + } + + return days +} + /** * Returns a date, transformed to be in the same week of the dows array. * `reverse` determines whether to do the opposite calculation (dow date to date) diff --git a/frontend/src/utils/date_utils.test.js b/frontend/src/utils/date_utils.test.js index 119a68a9..34854e36 100644 --- a/frontend/src/utils/date_utils.test.js +++ b/frontend/src/utils/date_utils.test.js @@ -1,10 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { getScheduleTimezoneOffset, + getSpecificTimesDayStarts, getTimezoneOffsetForDate, getTimezoneReferenceDateForEvent, } from "./date_utils" import { eventTypes } from "../constants" +import dayjs from "dayjs" +import utcPlugin from "dayjs/plugin/utc" +import timezonePlugin from "dayjs/plugin/timezone" + +dayjs.extend(utcPlugin) +dayjs.extend(timezonePlugin) describe("DST timezone regression", () => { beforeEach(() => { @@ -113,3 +120,91 @@ describe("DST timezone regression", () => { ).toBe(-60) }) }) + +describe("specific-times DST regression", () => { + it("falls back to the browser timezone when curTimezone is empty", () => { + const eventDates = [ + "2026-01-12", + "2026-01-13", + "2026-01-14", + "2026-01-15", + ].map((day) => dayjs.tz(`${day} 00:00`, "America/Los_Angeles").toDate()) + + const expectedDates = [ + ...new Set( + eventDates.map((eventDate) => + dayjs(eventDate).startOf("day").toDate().toISOString() + ) + ), + ] + + expect( + getSpecificTimesDayStarts(eventDates, {}).map((day) => + day.dateObject.toISOString() + ) + ).toEqual(expectedDates) + }) + + it("keeps one civil day per selected date during non-DST periods", () => { + const timezone = "America/Los_Angeles" + const eventDates = [ + "2026-01-12", + "2026-01-13", + "2026-01-14", + "2026-01-15", + ].map((day) => dayjs.tz(`${day} 00:00`, timezone).toDate()) + + expect( + getSpecificTimesDayStarts(eventDates, { value: timezone }).map((day) => + day.dateObject.toISOString() + ) + ).toEqual([ + "2026-01-12T08:00:00.000Z", + "2026-01-13T08:00:00.000Z", + "2026-01-14T08:00:00.000Z", + "2026-01-15T08:00:00.000Z", + ]) + }) + + it("keeps one civil day per selected date across spring-forward DST changes", () => { + const timezone = "America/Los_Angeles" + const eventDates = [ + "2026-03-07", + "2026-03-08", + "2026-03-09", + "2026-03-10", + ].map((day) => dayjs.tz(`${day} 00:00`, timezone).toDate()) + + expect( + getSpecificTimesDayStarts(eventDates, { value: timezone }).map((day) => + day.dateObject.toISOString() + ) + ).toEqual([ + "2026-03-07T08:00:00.000Z", + "2026-03-08T08:00:00.000Z", + "2026-03-09T07:00:00.000Z", + "2026-03-10T07:00:00.000Z", + ]) + }) + + it("keeps one civil day per selected date across fall-back DST changes", () => { + const timezone = "America/Los_Angeles" + const eventDates = [ + "2026-10-31", + "2026-11-01", + "2026-11-02", + "2026-11-03", + ].map((day) => dayjs.tz(`${day} 00:00`, timezone).toDate()) + + expect( + getSpecificTimesDayStarts(eventDates, { value: timezone }).map((day) => + day.dateObject.toISOString() + ) + ).toEqual([ + "2026-10-31T07:00:00.000Z", + "2026-11-01T07:00:00.000Z", + "2026-11-02T08:00:00.000Z", + "2026-11-03T08:00:00.000Z", + ]) + }) +}) From 507d60091e666e8354a6ce0b8cfc73b352d90f6a Mon Sep 17 00:00:00 2001 From: wslany <1747888+wslany@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:43:36 +0100 Subject: [PATCH 2/3] Fix DST day generation for specific-times events # Conflicts: # frontend/src/utils/date_utils.js --- frontend/src/components/schedule_overlap/ScheduleOverlap.vue | 2 +- frontend/src/utils/date_utils.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue index d860c228..64a00b7a 100644 --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue @@ -1480,7 +1480,7 @@ export default { ) { for (const day of getSpecificTimesDayStarts( this.event.dates, - this.timezoneOffset + this.curTimezone )) { const { dayString, dateString } = getDateString(day.dateObject) days.push({ diff --git a/frontend/src/utils/date_utils.js b/frontend/src/utils/date_utils.js index 64bfeb86..58601a86 100644 --- a/frontend/src/utils/date_utils.js +++ b/frontend/src/utils/date_utils.js @@ -227,7 +227,6 @@ export const getScheduleTimezoneOffset = ( getTimezoneReferenceDateForEvent(event, weekOffset) ) } - const getDateInTimezone = (date, curTimezone) => { if (curTimezone?.value) { return dayjs(date).tz(curTimezone.value) From a9c600772fd348384e6533f3a7467a188267aeb3 Mon Sep 17 00:00:00 2001 From: wslany <1747888+wslany@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:44:15 +0100 Subject: [PATCH 3/3] Add a non-DST control case for specific-times day generation # Conflicts: # frontend/src/utils/date_utils.test.js --- frontend/src/utils/date_utils.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/utils/date_utils.test.js b/frontend/src/utils/date_utils.test.js index 34854e36..dc0d21ea 100644 --- a/frontend/src/utils/date_utils.test.js +++ b/frontend/src/utils/date_utils.test.js @@ -144,7 +144,6 @@ describe("specific-times DST regression", () => { ) ).toEqual(expectedDates) }) - it("keeps one civil day per selected date during non-DST periods", () => { const timezone = "America/Los_Angeles" const eventDates = [