From b67ea59bde736388277baaac9b724e6dd0359f91 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:14:59 +0300 Subject: [PATCH] Fix availability spanning the night the clocks change growing by an hour every time the week was saved --- .../availability/core/Availability.ts | 27 +++ .../core/MySchedule.server.test.ts | 170 ++++++++++++++++++ .../availability/core/MySchedule.server.ts | 17 +- changelog/2026-09-02-schedule-clock-change.md | 5 + 4 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 app/features/availability/core/MySchedule.server.test.ts create mode 100644 changelog/2026-09-02-schedule-clock-change.md diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index de8715b85..88fcd5029 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -1,10 +1,12 @@ import { TZDate } from "@date-fns/tz"; import { addWeeks, + differenceInCalendarDays, format, getISOWeek, isMonday, isSunday, + parseISO, startOfWeek, } from "date-fns"; import * as R from "remeda"; @@ -119,6 +121,31 @@ export function dayMinutesToTimestamp({ ); } +/** + * Minutes from midnight of `date` in `timezone`, the inverse of {@link dayMinutesToTimestamp}. + * A timestamp on a later day counts on past 1440, read off the wall clock so a DST change inside + * the range does not stretch or shrink it. + */ +export function timestampToDayMinutes({ + date, + timestamp, + timezone, +}: { + date: string; + timestamp: number; + timezone: string; +}) { + const dayDifference = differenceInCalendarDays( + parseISO(dateInTimezone(timestamp, timezone)), + parseISO(date), + ); + + return ( + dayDifference * DAY_MINUTES + + timeToMinutes(timeInTimezone(timestamp, timezone)) + ); +} + /** * `YYYY-MM-DD` of the timestamp in `timezone`. A slot's day depends on who is looking, * so it is always resolved from the timestamp, never from the day its author entered it on. diff --git a/app/features/availability/core/MySchedule.server.test.ts b/app/features/availability/core/MySchedule.server.test.ts new file mode 100644 index 000000000..80262075f --- /dev/null +++ b/app/features/availability/core/MySchedule.server.test.ts @@ -0,0 +1,170 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import * as v from "valibot"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { viewerTimezoneAsyncLocalStorage } from "~/features/timezone/timezone-context.server"; +import { wrappedAction } from "~/utils/Test"; +import { action as eventsAction } from "../actions/events.server"; +import { saveWeekSchema } from "../availability-schemas"; +import * as Availability from "./Availability"; +import { myScheduleData } from "./MySchedule.server"; + +const DAY_SECONDS = 24 * 60 * 60; +const TIMEZONE = "UTC"; + +const saveWeek = wrappedAction({ + action: eventsAction, + isJsonSubmission: true, +}); + +const weekDays = () => { + const weekStartsAt = Availability.weekStartsAt( + addWeeks(new Date(), 1), + TIMEZONE, + ); + + return R.range(0, 7).map((dayIndex) => ({ + date: Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + TIMEZONE, + ), + ranges: [] as Array<{ start: number; end: number }>, + note: "", + })); +}; + +// editor with both expanders open: 06:00 -> 06:00 the next day +const WHOLE_TRACK = { start: 6 * 60, end: 30 * 60 }; +const FRIDAY = 4; +const SATURDAY = 5; + +const HELSINKI = "Europe/Helsinki"; +// Tuesday of the week whose Saturday night has the fall-back transition (Sun 2026-10-25 04:00 -> 03:00) +const NOW = new Date("2026-10-20T12:00:00Z"); +const SATURDAY_NIGHT = { start: 22 * 60, end: 28 * 60 }; + +const inViewerTimezone = (fn: () => Promise) => + viewerTimezoneAsyncLocalStorage.run({ timezone: HELSINKI }, fn); + +const currentWeekDays = () => { + const weekStartsAt = Availability.weekStartsAt(NOW, HELSINKI); + + return R.range(0, 7).map((dayIndex) => ({ + date: Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + HELSINKI, + ), + ranges: [] as Array<{ start: number; end: number }>, + note: "", + })); +}; + +describe("myScheduleData", () => { + test("reads back a day painted right after another day's range that reaches into it", async () => { + const user = await UserFactory.createRegular(); + const days = weekDays(); + days[FRIDAY].ranges = [{ start: 20 * 60, end: 30 * 60 }]; + days[SATURDAY].ranges = [{ start: 6 * 60, end: 10 * 60 }]; + + const response = await saveWeek( + { _action: "SAVE_WEEK", days }, + { user: "regular" }, + ); + expect(response).toBeNull(); + + const data = await myScheduleData(user.id); + const nextWeek = data.weeks[1]; + + expect(nextWeek.days[FRIDAY].ranges).toEqual([ + { start: 20 * 60, end: 30 * 60 }, + ]); + expect(nextWeek.days[SATURDAY].ranges).toEqual([ + { start: 6 * 60, end: 10 * 60 }, + ]); + }); + + test("reads back a weekend painted as whole tracks", async () => { + const user = await UserFactory.createRegular(); + const days = weekDays(); + days[FRIDAY].ranges = [WHOLE_TRACK]; + days[SATURDAY].ranges = [WHOLE_TRACK]; + + const response = await saveWeek( + { _action: "SAVE_WEEK", days }, + { user: "regular" }, + ); + expect(response).toBeNull(); + + const data = await myScheduleData(user.id); + const nextWeek = data.weeks[1]; + + expect(nextWeek.days[SATURDAY].ranges).toEqual([WHOLE_TRACK]); + }); + + test("reads a weekend painted as whole tracks back in a shape that can be saved again", async () => { + const user = await UserFactory.createRegular(); + const days = weekDays(); + days[FRIDAY].ranges = [WHOLE_TRACK]; + days[SATURDAY].ranges = [WHOLE_TRACK]; + + await saveWeek({ _action: "SAVE_WEEK", days }, { user: "regular" }); + + const data = await myScheduleData(user.id); + const nextWeek = data.weeks[1]; + + const resubmission = v.safeParse(saveWeekSchema, { + _action: "SAVE_WEEK", + days: nextWeek.days, + }); + expect(resubmission.success).toBe(true); + }); + + describe("across the DST fall-back night", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("reads Saturday 22:00-04:00 back as painted", async () => { + const user = await UserFactory.createRegular(); + const days = currentWeekDays(); + days[SATURDAY].ranges = [SATURDAY_NIGHT]; + + const response = await inViewerTimezone(() => + saveWeek({ _action: "SAVE_WEEK", days }, { user: "regular" }), + ); + expect(response).toBeNull(); + + const data = await inViewerTimezone(() => myScheduleData(user.id)); + + expect(data.weeks[0].days[SATURDAY].ranges).toEqual([SATURDAY_NIGHT]); + }); + + test("keeps the week as it is when it is saved again untouched", async () => { + const user = await UserFactory.createRegular(); + const days = currentWeekDays(); + days[SATURDAY].ranges = [SATURDAY_NIGHT]; + + await inViewerTimezone(() => + saveWeek({ _action: "SAVE_WEEK", days }, { user: "regular" }), + ); + const firstRead = await inViewerTimezone(() => myScheduleData(user.id)); + + await inViewerTimezone(() => + saveWeek( + { _action: "SAVE_WEEK", days: firstRead.weeks[0].days }, + { user: "regular" }, + ), + ); + const secondRead = await inViewerTimezone(() => myScheduleData(user.id)); + + expect(secondRead.weeks[0].days[SATURDAY].ranges).toEqual( + firstRead.weeks[0].days[SATURDAY].ranges, + ); + }); + }); +}); diff --git a/app/features/availability/core/MySchedule.server.ts b/app/features/availability/core/MySchedule.server.ts index c4272e33e..a54ff6402 100644 --- a/app/features/availability/core/MySchedule.server.ts +++ b/app/features/availability/core/MySchedule.server.ts @@ -111,11 +111,20 @@ function editorWeek({ } function slotToDayRange(slot: TimeRange, timezone: string): DayTimeRange { - const start = Availability.timeToMinutes( - Availability.timeInTimezone(slot.startsAt, timezone), - ); + const date = Availability.dateInTimezone(slot.startsAt, timezone); - return { start, end: start + Math.round((slot.endsAt - slot.startsAt) / 60) }; + return { + start: Availability.timestampToDayMinutes({ + date, + timestamp: slot.startsAt, + timezone, + }), + end: Availability.timestampToDayMinutes({ + date, + timestamp: slot.endsAt, + timezone, + }), + }; } function noteOfDay(week: ReportedWeek, date: string, timezone: string) { diff --git a/changelog/2026-09-02-schedule-clock-change.md b/changelog/2026-09-02-schedule-clock-change.md new file mode 100644 index 000000000..722558227 --- /dev/null +++ b/changelog/2026-09-02-schedule-clock-change.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: bug +--- +Fixed availability spanning the night the clocks change growing by an hour every time the week was saved