Share event date handling and stop depending on the server timezone

Event dates are stored as Oslo wall-clock strings, but were parsed in
the server's local timezone, so times were only correct because the
container sets TZ=Europe/Oslo. Move date parsing and formatting into
lib/dates, interpret those strings as Oslo time, and also accept ISO
timestamps in case the Studio writes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leanderandClaude Opus 5.5 committed 2026-10-30 22:20:46 +01:00
1 parent 35c175c1ad
commit 5c612d995d
4 files changed
+85 -65

No files matched your search

+3 -31
View File
@@ -1,6 +1,7 @@
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo" import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import Back from "@/components/ui/back" import Back from "@/components/ui/back"
import { formatEventDate } from "@/lib/dates"
import { getEvent } from "@/lib/sanity" import { getEvent } from "@/lib/sanity"
import { Metadata, Viewport } from "next" import { Metadata, Viewport } from "next"
@@ -33,25 +34,11 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: "metaTags" }) 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({ return generateSeoMetadata({
locale, locale,
title: data.title, title: data.title,
description: [ description: [
`${t("time")}: ${formatDate(data.date)}`, `${t("time")}: ${formatEventDate(data.date, locale)}`,
data.location && `${t("location")}: ${data.location}`, data.location && `${t("location")}: ${data.location}`,
data.description && `\n${data.description}`, data.description && `\n${data.description}`,
] ]
@@ -71,21 +58,6 @@ const Event = async ({ params }: EventPageParams) => {
if (!data) return notFound() 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<Back /> <Back />
@@ -97,7 +69,7 @@ const Event = async ({ params }: EventPageParams) => {
</div> </div>
<div className={styles.metadata}> <div className={styles.metadata}>
<FaCalendarAlt /> <FaCalendarAlt />
<p>{formatDate(data.date)}</p> <p>{formatEventDate(data.date, locale)}</p>
</div> </div>
<p className={styles.description}>{data.description}</p> <p className={styles.description}>{data.description}</p>
</div> </div>
+2 -16
View File
@@ -1,3 +1,4 @@
import { formatEventDate } from "@/lib/dates"
import { getUpcomingEvents } from "@/lib/sanity" import { getUpcomingEvents } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server" import { getLocale, getTranslations } from "next-intl/server"
@@ -27,21 +28,6 @@ const Events = async () => {
const events: Event[] = await getUpcomingEvents(2) 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) => { const previewDescription = (description: string | null) => {
if (!description) return null if (!description) return null
@@ -75,7 +61,7 @@ const Events = async () => {
</div> </div>
<div className={styles.metadata}> <div className={styles.metadata}>
<FaCalendarAlt /> <FaCalendarAlt />
<p>{formatDate(event.date)}</p> <p>{formatEventDate(event.date, locale)}</p>
</div> </div>
</div> </div>
<p>{previewDescription(event.description)}</p> <p>{previewDescription(event.description)}</p>
+67
View File
@@ -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,
})
+13 -18
View File
@@ -1,5 +1,7 @@
import { createClient } from "@sanity/client" import { createClient } from "@sanity/client"
import { toOsloDateString } from "./dates"
export const sanityClient = createClient({ export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!, dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
@@ -72,26 +74,19 @@ export async function getEvent(slug: string) {
} }
export async function getUpcomingEvents(limit: number) { export async function getUpcomingEvents(limit: number) {
// Event dates are stored as Oslo local time strings ("2026-11-05 17:00"), // Most event dates are Oslo wall-clock strings ("2026-11-05 17:00"), which
// which sort correctly as text, so compare against the current time in the // sort correctly as text, so compare them against the current Oslo time in
// same format // the same format. ISO timestamps from the Studio are compared as datetimes
const parts = Object.fromEntries( const now = toOsloDateString(new Date())
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}`
const query = ` 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, _id,
title, title,
location, location,