mirror of
https://github.com/UiO-Gaming/uiogaming.no.git
synced 2026-11-05 13:56:47 +01:00
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:
1 parent
35c175c1ad
commit
5c612d995d
4 files changed
+85
-65
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user