Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 12 additions & 29 deletions frontend/src/components/schedule_overlap/ScheduleOverlap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,7 @@ import {
_delete,
get,
getDateDayOffset,
getSpecificTimesDayStarts,
isDateBetween,
generateEnabledCalendarsPayload,
isTouchEnabled,
Expand Down Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/utils/date_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/utils/date_utils.test.js
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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",
])
})
})
Loading