diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue index c4886e71..64a00b7a 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.curTimezone + )) { + 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..58601a86 100644 --- a/frontend/src/utils/date_utils.js +++ b/frontend/src/utils/date_utils.js @@ -227,6 +227,51 @@ export const getScheduleTimezoneOffset = ( getTimezoneReferenceDateForEvent(event, weekOffset) ) } +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. diff --git a/frontend/src/utils/date_utils.test.js b/frontend/src/utils/date_utils.test.js index 119a68a9..dc0d21ea 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,90 @@ 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", + ]) + }) +})