mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Fix availability spanning the night the clocks change growing by an hour every time the week was saved
This commit is contained in:
@@ -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.
|
||||
|
||||
170
app/features/availability/core/MySchedule.server.test.ts
Normal file
170
app/features/availability/core/MySchedule.server.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
5
changelog/2026-09-02-schedule-clock-change.md
Normal file
5
changelog/2026-09-02-schedule-clock-change.md
Normal 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
|
||||
Reference in New Issue
Block a user