[]>;
}) => {
- const daysInWeek = Object.keys(days);
- const firstDay = daysInWeek[0];
- const lastDay = daysInWeek[daysInWeek.length - 1];
-
- const weekStart = startOfWeek(firstDay, { weekStartsOn: 1 });
- const weekEnd = endOfWeek(lastDay, { weekStartsOn: 1 });
-
- const emptyCellsBefore = differenceInCalendarDays(firstDay, weekStart);
- const emptyCellsAfter = differenceInCalendarDays(weekEnd, lastDay);
+ const { before: emptyCellsBefore, after: emptyCellsAfter } =
+ calendarWeekPadding(Object.keys(days));
return (
@@ -237,7 +229,7 @@ const CalendarWeek = ({
function srOnlyYear(yearMonthString: string, today: Date) {
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
- const yearMonth = new Date(yearMonthString);
+ const yearMonth = parseDay(yearMonthString);
const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
if (!isCurrentYear) {
return null;
diff --git a/web/src/lib/date.ts b/web/src/lib/date.ts
index 6ea566c..20c6a34 100644
--- a/web/src/lib/date.ts
+++ b/web/src/lib/date.ts
@@ -1,10 +1,13 @@
import {
+ differenceInCalendarDays,
+ endOfWeek,
+ format as formatWithoutTimeZone,
isBefore,
parse,
+ startOfWeek,
compareAsc,
addDays,
isSameDay,
- startOfDay,
} from "date-fns";
import { nb } from "date-fns/locale";
import { toZonedTime, format, formatInTimeZone } from "date-fns-tz";
@@ -26,9 +29,43 @@ export function osloDay(now: Date = new Date()): string {
return formatInTimeZone(now, timeZone, "yyyy-MM-dd");
}
+// Two kinds of Date are used here. An instant is a real point in time (what
+// the CMS sends). A wall-clock Date has its own fields set to what a clock in
+// Oslo shows, whatever timezone the code runs in: toLocalTime() makes one
+// from an instant, parseDay() from a day string. Convert once, then stay on
+// wall-clock Dates: formatDate() takes instants, formatWallClock() the rest
+
+// "yyyy-MM-dd" or "yyyy-MM" as a wall-clock Date (midnight, first of month)
+export function parseDay(day: string): Date {
+ return parse(day, day.length === 7 ? "yyyy-MM" : "yyyy-MM-dd", new Date(0));
+}
+
+export function formatWallClock(date: Date, formatStr: string) {
+ return formatWithoutTimeZone(date, formatStr, { locale: nb });
+}
+
+// Formats a "yyyy-MM-dd" or "yyyy-MM" string
+export function formatDay(day: string, formatStr: string) {
+ return formatWallClock(parseDay(day), formatStr);
+}
+
// Start of an Oslo day, comparable with toLocalTime() values
export function startOfOsloDay(day: string): Date {
- return parse(day, "yyyy-MM-dd", new Date(0));
+ return parseDay(day);
+}
+
+// How many empty cells a calendar week needs before its first and after its
+// last day ("yyyy-MM-dd", in order) to span mandag–søndag
+export function calendarWeekPadding(days: string[]) {
+ const first = parseDay(days[0]);
+ const last = parseDay(days[days.length - 1]);
+ return {
+ before: differenceInCalendarDays(
+ first,
+ startOfWeek(first, { weekStartsOn: 1 })
+ ),
+ after: differenceInCalendarDays(endOfWeek(last, { weekStartsOn: 1 }), last),
+ };
}
export function formatDate(date: Date | string | number, formatStr: string) {
@@ -37,11 +74,11 @@ export function formatDate(date: Date | string | number, formatStr: string) {
export function formatYearMonth(yearMonth: string, today: Date) {
// full name of month if year is current year, otherwise name of month + year
- const parsed = parse(yearMonth, "yyyy-MM", new Date(0));
+ const parsed = parseDay(yearMonth);
if (parsed.getFullYear() === today.getFullYear()) {
- return formatDate(parsed, "MMMM");
+ return formatWallClock(parsed, "MMMM");
}
- return formatDate(parsed, "MMMM yyyy");
+ return formatWallClock(parsed, "MMMM yyyy");
}
export function formatExtendedDateTime(
@@ -76,16 +113,15 @@ export function isConsecutiveDays(a: Date, b: Date): boolean {
export function groupConsecutiveDates(dates: string[]): string[][] {
const groupedDates: string[][] = [];
- const uniqueDays = unique(
- dates.map((date) => format(startOfDay(toLocalTime(date)), "yyyy-MM-dd"))
+ const uniqueDays: string[] = unique(
+ dates.map((date) => formatWallClock(toLocalTime(date), "yyyy-MM-dd"))
).sort();
let group: string[] = [];
for (let i = 0; i < uniqueDays.length; i++) {
if (
i === 0 ||
- isSameDay(uniqueDays[i - 1], uniqueDays[i]) ||
- isConsecutiveDays(uniqueDays[i - 1], uniqueDays[i])
+ isConsecutiveDays(parseDay(uniqueDays[i - 1]), parseDay(uniqueDays[i]))
) {
group.push(uniqueDays[i]);
} else {
@@ -125,38 +161,39 @@ export function formatOccurrenceMonths(starts: string[]): string {
return groups
.map((g) => {
- const first = parse(g[0], "yyyy-MM", new Date(0));
+ const first = parseDay(g[0]);
if (g.length === 1) {
- return formatDate(first, "MMMM yyyy");
+ return formatWallClock(first, "MMMM yyyy");
}
- const last = parse(g[g.length - 1], "yyyy-MM", new Date(0));
+ const last = parseDay(g[g.length - 1]);
const firstFmt =
first.getFullYear() === last.getFullYear()
- ? formatDate(first, "MMMM")
- : formatDate(first, "MMMM yyyy");
- return `${firstFmt} – ${formatDate(last, "MMMM yyyy")}`;
+ ? formatWallClock(first, "MMMM")
+ : formatWallClock(first, "MMMM yyyy");
+ return `${firstFmt} – ${formatWallClock(last, "MMMM yyyy")}`;
})
.join(", ");
}
+// Takes "yyyy-MM-dd" days in order, as grouped by groupConsecutiveDates
export function formatDateRange(dates: string[]): string {
if (dates.length === 1) {
- return formatDate(dates[0], "d. MMMM");
+ return formatDay(dates[0], "d. MMMM");
}
- const firstDate = toLocalTime(dates[0]);
- const lastDate = toLocalTime(dates[dates.length - 1]);
+ const firstDate = parseDay(dates[0]);
+ const lastDate = parseDay(dates[dates.length - 1]);
if (firstDate.getMonth() === lastDate.getMonth()) {
// same month
- return `${firstDate.getDate()}.—${lastDate.getDate()}. ${formatDate(
+ return `${firstDate.getDate()}.—${lastDate.getDate()}. ${formatWallClock(
firstDate,
"MMMM"
)}`;
}
// continues into next month
- const formattedFirstDate = formatDate(firstDate, "d. MMMM");
- const formattedLastDate = formatDate(lastDate, "d. MMMM");
+ const formattedFirstDate = formatWallClock(firstDate, "d. MMMM");
+ const formattedLastDate = formatWallClock(lastDate, "d. MMMM");
return `${formattedFirstDate}—${formattedLastDate}`;
}
diff --git a/web/src/lib/event.ts b/web/src/lib/event.ts
index ed39fbf..82bb13f 100644
--- a/web/src/lib/event.ts
+++ b/web/src/lib/event.ts
@@ -8,7 +8,7 @@ import {
} from "date-fns";
import {
toLocalTime,
- formatDate,
+ formatWallClock,
compareDates,
} from "./date";
import { graphql, unmaskFragment } from "@/gql";
@@ -266,28 +266,30 @@ export function organizeEventsInCalendar(
const calendar: EventCalendar = {};
- const minDate = new Date(sortedEvents[0]?.occurrence.start);
- const maxDate = new Date(
+ // wall-clock dates from here on: weeks and days are Oslo's, not the
+ // runtime's
+ const minDate = toLocalTime(sortedEvents[0]?.occurrence.start);
+ const maxDate = toLocalTime(
sortedEvents[sortedEvents.length - 1]?.occurrence.start
);
let currentDate = startOfWeek(minDate, { weekStartsOn: 1 });
while (currentDate <= maxDate) {
- const week = formatDate(currentDate, "w");
+ const week = formatWallClock(currentDate, "w");
const daysOfWeek = eachDayOfInterval({
start: currentDate,
end: endOfWeek(currentDate, { weekStartsOn: 1 }),
});
daysOfWeek.forEach((day) => {
- const yearMonth = formatDate(day, "yyyy-MM");
+ const yearMonth = formatWallClock(day, "yyyy-MM");
if (!calendar[yearMonth]) {
calendar[yearMonth] = {};
}
if (!calendar[yearMonth][week]) {
calendar[yearMonth][week] = {};
}
- const formattedDay = formatDate(day, "yyyy-MM-dd");
+ const formattedDay = formatWallClock(day, "yyyy-MM-dd");
if (!calendar[yearMonth][week][formattedDay]) {
calendar[yearMonth][week][formattedDay] = [];
}
@@ -298,9 +300,9 @@ export function organizeEventsInCalendar(
sortedEvents.forEach((event) => {
const start = toLocalTime(event.occurrence.start);
- const yearMonth = formatDate(start, "yyyy-MM");
- const week = formatDate(start, "w");
- const day = formatDate(start, "yyyy-MM-dd");
+ const yearMonth = formatWallClock(start, "yyyy-MM");
+ const week = formatWallClock(start, "w");
+ const day = formatWallClock(start, "yyyy-MM-dd");
if (!calendar[yearMonth]) {
calendar[yearMonth] = {};
}
@@ -341,7 +343,7 @@ export function organizeEventsByDate(
sortedEvents.forEach((event) => {
const start = toLocalTime(event.occurrence.start);
- const day = formatDate(start, "yyyy-MM-dd");
+ const day = formatWallClock(start, "yyyy-MM-dd");
if (!eventsByDate[day]) {
eventsByDate[day] = [];
}
diff --git a/web/src/lib/timezone.test.ts b/web/src/lib/timezone.test.ts
new file mode 100644
index 0000000..0a33e7b
--- /dev/null
+++ b/web/src/lib/timezone.test.ts
@@ -0,0 +1,140 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ calendarWeekPadding,
+ formatDateRange,
+ formatDay,
+ formatOccurrenceMonths,
+ formatYearMonth,
+ groupConsecutiveDates,
+ isTodayOrFuture,
+ parseDay,
+ startOfOsloDay,
+} from "./date.ts";
+import {
+ getFutureOccurrences,
+ getSingularEvents,
+ organizeEventsByDate,
+ organizeEventsInCalendar,
+} from "./event.ts";
+
+// Much of this code also runs in the visitor's browser, in whatever timezone
+// that is set to. Everything shown is Oslo time, so the result must not
+// depend on the runtime's timezone
+const TIMEZONES = [
+ "Europe/Oslo",
+ "UTC",
+ "Asia/Tokyo",
+ "Pacific/Auckland",
+ "America/New_York",
+ "America/Los_Angeles",
+];
+
+const originalTz = process.env.TZ;
+afterEach(() => {
+ process.env.TZ = originalTz;
+});
+
+const makeEvent = (id: string, starts: string[]) => ({
+ id,
+ occurrences: starts.map((start, i) => ({ id: `${id}-${i}`, start, end: null })),
+});
+
+// 00:30 on onsdag 1. juli 2026 in Oslo; still 30. juni in UTC and the Americas
+const JUST_PAST_MIDNIGHT = "2026-06-30T22:30:00Z";
+
+describe.each(TIMEZONES)("with the runtime in %s", (tz) => {
+ const inTz = (name: string, fn: () => void) =>
+ it(name, () => {
+ process.env.TZ = tz;
+ fn();
+ });
+
+ inTz("formats day and month strings as written", () => {
+ expect(formatDay("2026-07-01", "eeee dd.MM.")).toBe("onsdag 01.07.");
+ expect(formatDay("2026-01", "MMMM yyyy")).toBe("januar 2026");
+ expect(parseDay("2026-01").getFullYear()).toBe(2026);
+ expect(parseDay("2026-01-01").getDate()).toBe(1);
+ });
+
+ inTz("formats month headings", () => {
+ const today = startOfOsloDay("2026-07-07");
+ expect(formatYearMonth("2026-07", today)).toBe("juli");
+ expect(formatYearMonth("2026-01", today)).toBe("januar");
+ expect(formatYearMonth("2027-01", today)).toBe("januar 2027");
+ });
+
+ inTz("formats occurrence months and date ranges", () => {
+ expect(formatOccurrenceMonths([JUST_PAST_MIDNIGHT])).toBe("juli 2026");
+ expect(
+ formatOccurrenceMonths(["2026-12-05T12:00:00Z", "2027-01-05T12:00:00Z"])
+ ).toBe("desember 2026 – januar 2027");
+ expect(formatDateRange(["2026-07-01"])).toBe("1. juli");
+ expect(formatDateRange(["2026-07-01", "2026-07-03"])).toBe("1.—3. juli");
+ expect(formatDateRange(["2026-06-30", "2026-07-01"])).toBe(
+ "30. juni—1. juli"
+ );
+ });
+
+ inTz("groups consecutive Oslo days", () => {
+ expect(
+ groupConsecutiveDates([
+ JUST_PAST_MIDNIGHT,
+ "2026-07-02T18:00:00Z",
+ "2026-07-04T18:00:00Z",
+ ])
+ ).toEqual([["2026-07-01", "2026-07-02"], ["2026-07-04"]]);
+ // across the 25-hour DST fall-back day
+ expect(
+ groupConsecutiveDates([
+ "2026-10-24T18:00:00Z",
+ "2026-10-25T18:00:00Z",
+ "2026-10-26T18:00:00Z",
+ ])
+ ).toEqual([["2026-10-24", "2026-10-25", "2026-10-26"]]);
+ });
+
+ inTz("places events on their Oslo day in the calendar", () => {
+ const events = getSingularEvents([makeEvent("a", [JUST_PAST_MIDNIGHT])]);
+
+ const calendar = organizeEventsInCalendar(events);
+
+ // the seeded week starts mandag 29. juni; June holds only padding
+ expect(Object.keys(calendar)).toEqual(["2026-07"]);
+ const [week] = Object.values(calendar["2026-07"]);
+ expect(Object.keys(week)).toEqual([
+ "2026-07-01",
+ "2026-07-02",
+ "2026-07-03",
+ "2026-07-04",
+ "2026-07-05",
+ ]);
+ expect(week["2026-07-01"].map((e) => e.id)).toEqual(["a"]);
+
+ expect(Object.keys(organizeEventsByDate(events))).toEqual(["2026-07-01"]);
+ });
+
+ inTz("pads a partial calendar week to mandag and søndag", () => {
+ // onsdag 1. – søndag 5. juli
+ expect(
+ calendarWeekPadding(["2026-07-01", "2026-07-02", "2026-07-05"])
+ ).toEqual({ before: 2, after: 0 });
+ // mandag 27. – tirsdag 30. juni
+ expect(calendarWeekPadding(["2026-06-29", "2026-06-30"])).toEqual({
+ before: 0,
+ after: 5,
+ });
+ });
+
+ inTz("judges past and future by the Oslo day", () => {
+ const today = startOfOsloDay("2026-07-01");
+ expect(isTodayOrFuture(JUST_PAST_MIDNIGHT, today)).toBe(true);
+ expect(isTodayOrFuture("2026-06-30T21:30:00Z", today)).toBe(false);
+ expect(
+ getFutureOccurrences(
+ makeEvent("a", ["2026-06-30T21:30:00Z", JUST_PAST_MIDNIGHT]),
+ today
+ ).map((o) => o.start)
+ ).toEqual([JUST_PAST_MIDNIGHT]);
+ });
+});