Fix availability spanning the night the clocks change growing by an hour every time the week was saved

This commit is contained in:
Kalle
2026-09-02 21:14:59 +03:00
parent bfe76a5e2b
commit b67ea59bde
4 changed files with 215 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<typeof saveWeekSchema>({
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 = <T>(fn: () => Promise<T>) =>
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,
);
});
});
});

View File

@@ -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) {

View File

@@ -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