web: fix timezone issues so midnight ISR happens the right day

This commit is contained in:
2026-09-04 21:28:17 +02:00
parent b7ff7a3ddc
commit a678adc9d3
7 changed files with 72 additions and 13 deletions
+2
View File
@@ -33,6 +33,8 @@ WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime. # Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
# Date logic is Oslo-aware in code, but keep the process TZ matching anyway
ENV TZ=Europe/Oslo
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
+16
View File
@@ -10,6 +10,7 @@ import {
groupConsecutiveDates, groupConsecutiveDates,
isConsecutiveDays, isConsecutiveDays,
isTodayOrFuture, isTodayOrFuture,
startOfTodayInOslo,
toLocalTime, toLocalTime,
} from "./date.ts"; } from "./date.ts";
@@ -30,6 +31,21 @@ describe("toLocalTime", () => {
}); });
}); });
describe("startOfTodayInOslo", () => {
it("returns the Oslo date even when the server runs in UTC", () => {
const tz = process.env.TZ;
process.env.TZ = "UTC";
try {
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
const today = startOfTodayInOslo();
expect(today.getDate()).toBe(8);
expect(today.getHours()).toBe(0);
} finally {
process.env.TZ = tz;
}
});
});
describe("formatDate", () => { describe("formatDate", () => {
it("formats in Oslo time with Norwegian locale", () => { it("formats in Oslo time with Norwegian locale", () => {
expect(formatDate("2026-07-07T18:00:00Z", "dd.MM.yyyy 'kl.' HH:mm")).toBe( expect(formatDate("2026-07-07T18:00:00Z", "dd.MM.yyyy 'kl.' HH:mm")).toBe(
+5
View File
@@ -20,6 +20,11 @@ export function toLocalTime(date: Date | string | number) {
return toZonedTime(date, timeZone); return toZonedTime(date, timeZone);
} }
// Start of the current Oslo day, regardless of the server's TZ
export function startOfTodayInOslo() {
return startOfDay(toLocalTime(new Date()));
}
export function formatDate(date: Date | string | number, formatStr: string) { export function formatDate(date: Date | string | number, formatStr: string) {
return format(toLocalTime(date), formatStr, { timeZone, locale: nb }); return format(toLocalTime(date), formatStr, { timeZone, locale: nb });
} }
+19
View File
@@ -164,6 +164,25 @@ describe("getFutureOccurrences", () => {
"2026-07-14T18:00:00Z", "2026-07-14T18:00:00Z",
]); ]);
}); });
it("rolls over at Oslo midnight when the server runs in UTC", () => {
const tz = process.env.TZ;
process.env.TZ = "UTC";
try {
// 22:20 UTC on tirsdag is 00:20 onsdag in Oslo
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
const event = makeEvent("a", [
"2026-07-07T18:00:00Z",
"2026-07-08T18:00:00Z",
]);
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
"2026-07-08T18:00:00Z",
]);
} finally {
process.env.TZ = tz;
}
});
}); });
describe("getEventPig", () => { describe("getEventPig", () => {
+7 -3
View File
@@ -1,13 +1,17 @@
import { import {
endOfWeek, endOfWeek,
startOfToday,
startOfWeek, startOfWeek,
isAfter, isAfter,
eachDayOfInterval, eachDayOfInterval,
addWeeks, addWeeks,
parseISO, parseISO,
} from "date-fns"; } from "date-fns";
import { toLocalTime, formatDate, compareDates } from "./date"; import {
toLocalTime,
formatDate,
compareDates,
startOfTodayInOslo,
} from "./date";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
import { import {
type EventCategoryFragment, type EventCategoryFragment,
@@ -338,7 +342,7 @@ export function organizeEventsByDate<E extends EventListable>(
export function getFutureOccurrences<E extends EventListable>( export function getFutureOccurrences<E extends EventListable>(
event: E event: E
): E["occurrences"][number][] { ): E["occurrences"][number][] {
const today = startOfToday(); const today = startOfTodayInOslo();
const occurrences = event?.occurrences ?? []; const occurrences = event?.occurrences ?? [];
const futureOccurrences = occurrences.filter((occurrence) => const futureOccurrences = occurrences.filter((occurrence) =>
isAfter(toLocalTime(occurrence.start), today) isAfter(toLocalTime(occurrence.start), today)
+19
View File
@@ -161,4 +161,23 @@ describe("getTodaysOpeningHoursForFunction", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé")).toBe("?"); expect(getTodaysOpeningHoursForFunction(openingHours, "kafé")).toBe("?");
}); });
it("uses the Oslo weekday when the server runs in UTC", () => {
const tz = process.env.TZ;
process.env.TZ = "UTC";
try {
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
"15:00—01:00"
);
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
"15:00—01:00"
);
} finally {
process.env.TZ = tz;
}
});
}); });
+4 -10
View File
@@ -1,13 +1,7 @@
import { import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
compareDesc,
getISODay,
isAfter,
isSameDay,
parseISO,
startOfToday,
} from "date-fns";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { startOfTodayInOslo } from "@/lib/date";
import { cachedUntilOsloMidnight } from "@/lib/revalidation"; import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
import type { import type {
@@ -63,7 +57,7 @@ export async function fetchOpeningHoursSets() {
} }
export async function getOpeningHours() { export async function getOpeningHours() {
const today = startOfToday(); const today = startOfTodayInOslo();
const sets = await fetchOpeningHoursSets(); const sets = await fetchOpeningHoursSets();
const validSets = sets const validSets = sets
.filter((set) => { .filter((set) => {
@@ -213,7 +207,7 @@ export function getTodaysOpeningHoursForFunction(
if (!week) { if (!week) {
return "?"; return "?";
} }
const weekdayIndex = getISODay(startOfToday()) - 1; const weekdayIndex = getISODay(startOfTodayInOslo()) - 1;
const weekday = WEEKDAYS[weekdayIndex]; const weekday = WEEKDAYS[weekdayIndex];
const hours = week[weekday]; const hours = week[weekday];
if (hours?.timeFrom && hours?.timeTo) { if (hours?.timeFrom && hours?.timeTo) {