web: show event dates in Oslo time whatever the visitor's timezone
This commit is contained in:
4 files changed
+219
-48
No files matched your search
@@ -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
@@ -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
@@ -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] = [];
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user