web: show event dates in Oslo time whatever the visitor's timezone

This commit is contained in:
ponas committed 2026-10-14 00:07:40 +02:00
1 parent db286434ba
commit 5f9a8b93cb
4 files changed
+219 -48

No files matched your search

+9 -17
View File
@@ -26,13 +26,12 @@ import {
import { isTodayOrFuture } from "@/lib/date";
import { useToday } from "@/components/general/Today";
import styles from "./eventContainer.module.scss";
import { formatDate, formatYearMonth } from "@/lib/date";
import {
startOfWeek,
endOfWeek,
differenceInCalendarDays,
parse,
} from "date-fns";
calendarWeekPadding,
formatDay,
formatYearMonth,
parseDay,
} from "@/lib/date";
import { Icon } from "../general/Icon";
import { useState } from "react";
import { VenueFragment } from "@/gql/graphql";
@@ -191,7 +190,7 @@ const CalendarDay = ({
<div
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
>
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date(0)), "eeee dd.MM.")}</h3>
<h3>{formatDay(day, "eeee dd.MM.")}</h3>
<ul>
{events.map((event) => (
<EventItem
@@ -210,15 +209,8 @@ const CalendarWeek = ({
}: {
days: Record<string, SingularEvent<EventOverviewItemFragment>[]>;
}) => {
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 (
<div className={styles.calendarWeek}>
@@ -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;
+58 -21
View File
@@ -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}`;
}
+12 -10
View File
@@ -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<E extends EventListable>(
const calendar: EventCalendar<E> = {};
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<E extends EventListable>(
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<E extends EventListable>(
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] = [];
}
+140
View File
@@ -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]);
});
});