diff --git a/src/app/[locale]/event/[slug]/page.tsx b/src/app/[locale]/event/[slug]/page.tsx index 03de1fd..bc1a6d7 100644 --- a/src/app/[locale]/event/[slug]/page.tsx +++ b/src/app/[locale]/event/[slug]/page.tsx @@ -1,6 +1,7 @@ import { generateSeoMetadata, generateSeoViewport } from "@/components/seo" import Back from "@/components/ui/back" +import { formatEventDate } from "@/lib/dates" import { getEvent } from "@/lib/sanity" import { Metadata, Viewport } from "next" @@ -33,25 +34,11 @@ export async function generateMetadata({ const t = await getTranslations({ locale, namespace: "metaTags" }) - const formatDate = (dateString: string) => { - const date = new Date(dateString) - const options: Intl.DateTimeFormatOptions = { - weekday: "long", - month: "long", - year: "numeric", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZone: "Europe/Oslo", - } - return date.toLocaleString(locale, options) - } - return generateSeoMetadata({ locale, title: data.title, description: [ - `${t("time")}: ${formatDate(data.date)}`, + `${t("time")}: ${formatEventDate(data.date, locale)}`, data.location && `${t("location")}: ${data.location}`, data.description && `\n${data.description}`, ] @@ -71,21 +58,6 @@ const Event = async ({ params }: EventPageParams) => { if (!data) return notFound() - const formatDate = (dateString: string) => { - const date = new Date(dateString) - const options: Intl.DateTimeFormatOptions = { - weekday: "long", - month: "long", - year: "numeric", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZone: "Europe/Oslo", - } - - return date.toLocaleString(locale, options) - } - return (
@@ -97,7 +69,7 @@ const Event = async ({ params }: EventPageParams) => {
-

{formatDate(data.date)}

+

{formatEventDate(data.date, locale)}

{data.description}

diff --git a/src/components/page-sections/events.tsx b/src/components/page-sections/events.tsx index ddc5ef9..bec2296 100644 --- a/src/components/page-sections/events.tsx +++ b/src/components/page-sections/events.tsx @@ -1,3 +1,4 @@ +import { formatEventDate } from "@/lib/dates" import { getUpcomingEvents } from "@/lib/sanity" import { getLocale, getTranslations } from "next-intl/server" @@ -27,21 +28,6 @@ const Events = async () => { const events: Event[] = await getUpcomingEvents(2) - const formatDate = (dateString: string) => { - const date = new Date(dateString) - const options: Intl.DateTimeFormatOptions = { - weekday: "long", - month: "long", - year: "numeric", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZone: "Europe/Oslo", - } - - return date.toLocaleString(locale, options) - } - const previewDescription = (description: string | null) => { if (!description) return null @@ -75,7 +61,7 @@ const Events = async () => {
-

{formatDate(event.date)}

+

{formatEventDate(event.date, locale)}

{previewDescription(event.description)}

diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..6d7d3ba --- /dev/null +++ b/src/lib/dates.ts @@ -0,0 +1,67 @@ +import type { Locale } from "next-intl" + +const TIME_ZONE = "Europe/Oslo" + +// Event dates are usually stored as Oslo wall-clock time without an offset +// ("2026-11-05 17:00"). The Studio's datetime field stores ISO timestamps +// instead, so both formats have to be handled +const LOCAL_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/ + +const osloPartsFormatter = new Intl.DateTimeFormat("en-GB", { + timeZone: TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}) + +const getOsloParts = (date: Date) => + Object.fromEntries( + osloPartsFormatter + .formatToParts(date) + .map(({ type, value }) => [type, value]) + ) + +/** Formats a date as Oslo wall-clock time, e.g. "2026-11-05 17:00" */ +export const toOsloDateString = (date: Date) => { + const { year, month, day, hour, minute } = getOsloParts(date) + return `${year}-${month}-${day} ${hour}:${minute}` +} + +// How far Oslo's clock is ahead of UTC at the given moment (1 or 2 hours) +const getOsloOffset = (timestamp: number) => { + const { year, month, day, hour, minute } = getOsloParts(new Date(timestamp)) + const osloAsUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute) + return osloAsUtc - Math.floor(timestamp / 60_000) * 60_000 +} + +/** + * Parses an event date. Oslo wall-clock strings are interpreted in Oslo time + * regardless of the server's timezone + */ +export const parseEventDate = (value: string) => { + const match = value.match(LOCAL_DATE_FORMAT) + if (!match) return new Date(value) + + const [year, month, day, hour, minute] = match.slice(1).map(Number) + const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute) + + // The offset depends on daylight saving time at the resulting moment, so + // guess with the offset at the wall-clock time, then correct + const guess = wallClockAsUtc - getOsloOffset(wallClockAsUtc) + return new Date(wallClockAsUtc - getOsloOffset(guess)) +} + +/** Formats an event date like "torsdag 5. november 2026 kl. 17:00" */ +export const formatEventDate = (value: string, locale: Locale) => + parseEventDate(value).toLocaleString(locale, { + weekday: "long", + month: "long", + year: "numeric", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: TIME_ZONE, + }) diff --git a/src/lib/sanity.ts b/src/lib/sanity.ts index d0e4f9e..222b932 100644 --- a/src/lib/sanity.ts +++ b/src/lib/sanity.ts @@ -1,5 +1,7 @@ import { createClient } from "@sanity/client" +import { toOsloDateString } from "./dates" + export const sanityClient = createClient({ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!, @@ -72,26 +74,19 @@ export async function getEvent(slug: string) { } export async function getUpcomingEvents(limit: number) { - // Event dates are stored as Oslo local time strings ("2026-11-05 17:00"), - // which sort correctly as text, so compare against the current time in the - // same format - const parts = Object.fromEntries( - new Intl.DateTimeFormat("en-GB", { - timeZone: "Europe/Oslo", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }) - .formatToParts(new Date()) - .map(({ type, value }) => [type, value]) - ) - const now = `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}` + // Most event dates are Oslo wall-clock strings ("2026-11-05 17:00"), which + // sort correctly as text, so compare them against the current Oslo time in + // the same format. ISO timestamps from the Studio are compared as datetimes + const now = toOsloDateString(new Date()) const query = ` - *[_type == "event" && date >= $now] | order(date asc)[0...$limit] { + *[ + _type == "event" && + select( + defined(dateTime(date)) => dateTime(date) >= dateTime(now()), + date >= $now + ) + ] | order(date asc)[0...$limit] { _id, title, location,