+
+ {session.title}
+
+
+ }
timezone={timezone}
tagColors={tagColors}
className="px-2 py-8 sm:px-3 lg:py-12"
@@ -109,7 +121,9 @@ function SessionBlock({
+ }
/>
>
)}
@@ -130,80 +144,15 @@ function SessionBlock({
)
}
-function SessionDescription({
- description,
- sideSpeaker,
-}: {
- description: string
- sideSpeaker: EventSpeaker | null
-}) {
- const [expanded, setExpanded] = useState(false)
- const paragraphs = parseParagraphs(description)
- const hasMore = paragraphs.length > 1
- const visible = expanded ? paragraphs : paragraphs.slice(0, 1)
- const splitAt = sideSpeaker ? Math.max(0, visible.length - 2) : visible.length
- const lead = visible.slice(0, splitAt)
- const tail = visible.slice(splitAt)
- const lastInLead = tail.length === 0 ? lead.length - 1 : -1
- const lastInTail = tail.length - 1
-
- const toggle = hasMore && (
- <>
- {" "}
-
- >
- )
-
- return (
-
- {lead.map((html, i) => (
-
-
- {i === lastInLead && toggle}
-
- ))}
- {tail.length > 0 && (
-
-
- {tail.map((html, i) => (
-
-
- {i === lastInTail && toggle}
-
- ))}
-
- {sideSpeaker && (
-
-
-
- )}
-
- )}
-
- )
-}
-
-function parseParagraphs(html: string): string[] {
- const formatted = formatDescription(html)
- const matches = formatted.match(/[\s\S]*?<\/p>/g)
- if (!matches) return [formatted]
- return matches.map(p => p.replace(/^
/, "").replace(/<\/p>$/, ""))
-}
-
-function SessionHeader({
+export function SessionHeader({
session,
+ heading,
timezone,
tagColors,
className,
}: {
session: EventSession
+ heading: ReactNode
timezone: string
tagColors: Record
className?: string
@@ -224,7 +173,7 @@ function SessionHeader({
return (
- {session.title}
+ {heading}
@@ -259,7 +208,7 @@ function SessionHeader({
)
}
-function SessionSpeakers({
+export function SessionSpeakers({
speakers,
className,
}: {
@@ -389,7 +338,7 @@ function SpeakerSocialLinks({ links }: { links: EventSpeaker["socialurls"] }) {
)
}
-function Hr({ className }: { className?: string }) {
+export function Hr({ className }: { className?: string }) {
return (
{
- pageTitle: string
+ city: string
date: string
location: string
}
export function GenericDayOpengraphImage({
- pageTitle,
+ city,
date,
location,
...rest
@@ -45,11 +45,7 @@ export function GenericDayOpengraphImage({
}}
{...rest}
>
-
+
GraphQL Day
-
at FOST {pageTitle}
+
at FOST {city}
- ),
+
,
{
...size,
fonts: await fonts,
diff --git a/src/app/day/2026/components/og-images/talk-opengraph-image.tsx b/src/app/day/2026/components/og-images/talk-opengraph-image.tsx
new file mode 100644
index 0000000000..395cef453c
--- /dev/null
+++ b/src/app/day/2026/components/og-images/talk-opengraph-image.tsx
@@ -0,0 +1,164 @@
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import { ImageResponse } from "next/og"
+import type { StaticImageData } from "next/image"
+
+import { loadFontsForOG } from "@/app/fonts/og/load-fonts-for-og"
+
+import type { DayEvent } from "../../day-events"
+import type { EventSession } from "../event-schedule-section"
+import {
+ colors,
+ DayOpengraphImageHeader,
+ fonts,
+} from "./day-opengraph-image-header"
+
+export const contentType = "image/png"
+export const size = {
+ width: 1200,
+ height: 630,
+}
+
+/**
+ * Satori needs the image bytes. Imported images point at the copy Next emits
+ * to `.next/static/media`, which exists when OG images are prerendered.
+ */
+function avatarDataUri(avatar: StaticImageData) {
+ const file = join(
+ process.cwd(),
+ ".next",
+ avatar.src.replace(/^\/_next\//, ""),
+ )
+ const type = avatar.src.endsWith(".png") ? "png" : "jpeg"
+ return `data:image/${type};base64,${readFileSync(file).toString("base64")}`
+}
+
+export async function TalkOpengraphImage({
+ event,
+ session,
+}: {
+ event: DayEvent
+ session: EventSession
+}) {
+ const ogFonts = loadFontsForOG()
+ const { title, speakers } = session
+
+ return new ImageResponse(
+ (
+
+
+
+
+
+ GraphQL Day at FOST {event.city}
+
+
+ {title}
+
+
+
+
+ {speakers.map(speaker => (
+
+ {speaker.avatar && (
+
})
+ )}
+
+ 1 ? "32px" : "40px",
+ lineHeight: "1",
+ color: colors.neu900,
+ }}
+ >
+ {speaker.name}
+
+ {speaker.company && speaker.company !== "-" && (
+
+ {speaker.company}
+
+ )}
+
+
+ ))}
+
+
+
+ ),
+ {
+ ...size,
+ fonts: await ogFonts,
+ },
+ )
+}
diff --git a/src/app/day/2026/components/session-description.tsx b/src/app/day/2026/components/session-description.tsx
new file mode 100644
index 0000000000..a31168af94
--- /dev/null
+++ b/src/app/day/2026/components/session-description.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import { useState, type ReactNode } from "react"
+
+import { formatDescription } from "@/app/conf/2026/schedule/[id]/format-description"
+
+export function SessionDescription({
+ description,
+ aside,
+}: {
+ description: string
+ /** Shown next to the last paragraphs on wide screens. */
+ aside?: ReactNode
+}) {
+ const [expanded, setExpanded] = useState(false)
+ const paragraphs = parseParagraphs(description)
+ const hasMore = paragraphs.length > 1
+ const visible = expanded ? paragraphs : paragraphs.slice(0, 1)
+ const splitAt = aside ? Math.max(0, visible.length - 2) : visible.length
+ const lead = visible.slice(0, splitAt)
+ const tail = visible.slice(splitAt)
+ const lastInLead = tail.length === 0 ? lead.length - 1 : -1
+ const lastInTail = tail.length - 1
+
+ const toggle = hasMore && (
+ <>
+ {" "}
+
+ >
+ )
+
+ return (
+
+ {lead.map((html, i) => (
+
+
+ {i === lastInLead && toggle}
+
+ ))}
+ {tail.length > 0 && (
+
+
+ {tail.map((html, i) => (
+
+
+ {i === lastInTail && toggle}
+
+ ))}
+
+ {aside && (
+
+ {aside}
+
+ )}
+
+ )}
+
+ )
+}
+
+function parseParagraphs(html: string): string[] {
+ const formatted = formatDescription(html)
+ const matches = formatted.match(/
[\s\S]*?<\/p>/g)
+ if (!matches) return [formatted]
+ return matches.map(p => p.replace(/^
/, "").replace(/<\/p>$/, ""))
+}
diff --git a/src/app/day/2026/day-events.ts b/src/app/day/2026/day-events.ts
new file mode 100644
index 0000000000..509d950cf0
--- /dev/null
+++ b/src/app/day/2026/day-events.ts
@@ -0,0 +1,115 @@
+import type { EventSession } from "./components/event-schedule-section"
+import {
+ amsterdamSessions,
+ AMSTERDAM_TIMEZONE,
+ tagColors as amsterdamTagColors,
+} from "./amsterdam/schedule-data"
+import {
+ bengaluruSessions,
+ BENGALURU_TIMEZONE,
+ tagColors as bengaluruTagColors,
+} from "./bengaluru/schedule-data"
+import {
+ londonSessions,
+ LONDON_TIMEZONE,
+ tagColors as londonTagColors,
+} from "./london/schedule-data"
+import {
+ melbourneSessions,
+ MELBOURNE_TIMEZONE,
+ tagColors as melbourneTagColors,
+} from "./melbourne/schedule-data"
+import {
+ nycSessions,
+ NYC_TIMEZONE,
+ tagColors as nycTagColors,
+} from "./nyc/schedule-data"
+import {
+ singaporeSessions,
+ SINGAPORE_TIMEZONE,
+ tagColors as singaporeTagColors,
+} from "./singapore/schedule-data"
+
+export interface DayEvent {
+ city: string
+ /** Short date shown in social previews. */
+ date: string
+ location: string
+ timezone: string
+ sessions: EventSession[]
+ tagColors: Record
+}
+
+/** Keyed by the city's URL segment under /day/2026/. */
+export const dayEvents = {
+ singapore: {
+ city: "Singapore",
+ date: "Apr 14-15, 2026",
+ location: "Singapore",
+ timezone: SINGAPORE_TIMEZONE,
+ sessions: singaporeSessions,
+ tagColors: singaporeTagColors,
+ },
+ nyc: {
+ city: "NYC",
+ date: "May 13-14, 2026",
+ location: "Convene 360 Madison, New York",
+ timezone: NYC_TIMEZONE,
+ sessions: nycSessions,
+ tagColors: nycTagColors,
+ },
+ amsterdam: {
+ city: "Amsterdam",
+ date: "Jun 9-10, 2026",
+ location: "Tolhuistuin, Amsterdam",
+ timezone: AMSTERDAM_TIMEZONE,
+ sessions: amsterdamSessions,
+ tagColors: amsterdamTagColors,
+ },
+ bengaluru: {
+ city: "Bengaluru",
+ date: "Aug 19-20, 2026",
+ location: "Conrad Bengaluru, India",
+ timezone: BENGALURU_TIMEZONE,
+ sessions: bengaluruSessions,
+ tagColors: bengaluruTagColors,
+ },
+ london: {
+ city: "London",
+ date: "Oct 1, 2026",
+ location: "Convene Sancroft, London",
+ timezone: LONDON_TIMEZONE,
+ sessions: londonSessions,
+ tagColors: londonTagColors,
+ },
+ melbourne: {
+ city: "Melbourne",
+ date: "Oct 29, 2026",
+ location: "Melbourne, Australia",
+ timezone: MELBOURNE_TIMEZONE,
+ sessions: melbourneSessions,
+ tagColors: melbourneTagColors,
+ },
+ paris: {
+ city: "Paris",
+ date: "Dec 1-3, 2026",
+ location: "CNIT Forest, Paris",
+ timezone: "Europe/Paris",
+ sessions: [],
+ tagColors: {},
+ },
+} satisfies Record
+
+export type DayEventSlug = keyof typeof dayEvents
+
+export function findDayTalk(city: string, id: string) {
+ const event: DayEvent | undefined = dayEvents[city as DayEventSlug]
+ const session = event?.sessions.find(s => s.uuid === id)
+ return event && session ? { event, session } : undefined
+}
+
+export function getDayTalkParams() {
+ return Object.entries(dayEvents).flatMap(([city, event]) =>
+ event.sessions.map(s => ({ city, id: s.uuid })),
+ )
+}
diff --git a/src/app/day/2026/london/opengraph-image.tsx b/src/app/day/2026/london/opengraph-image.tsx
index 95918e1e46..76dccce6d7 100644
--- a/src/app/day/2026/london/opengraph-image.tsx
+++ b/src/app/day/2026/london/opengraph-image.tsx
@@ -1,12 +1,9 @@
import { SimpleOpengraphImage } from "../components/og-images/simple-opengraph-image"
+import { dayEvents } from "../day-events"
export {
generateStaticParams,
contentType,
size,
} from "../components/og-images/simple-opengraph-image"
-export default SimpleOpengraphImage.bind(null, {
- pageTitle: "London",
- date: "Sep 30-Oct 1, 2026",
- location: "Convene Sancroft, London",
-})
+export default SimpleOpengraphImage.bind(null, dayEvents.london)
diff --git a/src/app/day/2026/london/page.tsx b/src/app/day/2026/london/page.tsx
index 0fcbcf70a7..209b458551 100644
--- a/src/app/day/2026/london/page.tsx
+++ b/src/app/day/2026/london/page.tsx
@@ -32,7 +32,7 @@ const MARQUEE_ITEMS = [
]
export const metadata: Metadata = {
- title: "GraphQL Day @ FOST London — Sep 30-Oct 1",
+ title: "GraphQL Day @ FOST London — Oct 1",
}
export default function LondonPage() {
@@ -42,8 +42,8 @@ export default function LondonPage() {
@@ -62,8 +62,12 @@ export default function LondonPage() {
-
+
= {
export const melbourneSessions: EventSession[] = [
{
id: 5001,
+ uuid: "64a4db37-cbc2-4726-8637-025e7efa4b49",
title: "Federation for the JVM",
start: "2026-10-29T11:00:00+11:00",
end: "2026-10-29T11:25:00+11:00",
@@ -48,6 +49,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5002,
+ uuid: "2362071a-320d-448c-9384-f8dbe2e64a62",
title:
"Closing the Loop: How GraphQL Gives Coding Agents Eyes on What Actually Matters",
start: "2026-10-29T11:30:00+11:00",
@@ -76,6 +78,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5003,
+ uuid: "0dfb8add-40b2-4f26-9c25-2f69616b9318",
title:
"Ask, Don't Fetch: Why GraphQL + MCP Is the Native Language of AI Agents",
start: "2026-10-29T12:00:00+11:00",
@@ -102,6 +105,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5004,
+ uuid: "2ca7fe95-80c2-40d1-a2e9-f1ff3ea25dee",
title:
"Telling Your Success Story: Illustrating Graph Impact Through Observability",
start: "2026-10-29T12:30:00+11:00",
@@ -128,6 +132,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5005,
+ uuid: "e3ff6be8-9b34-49e5-ba50-7deea5de55dd",
title:
"GraphQL in the Real World: The Security and Governance Playbook Nobody Gives You",
start: "2026-10-29T14:00:00+11:00",
@@ -154,6 +159,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5006,
+ uuid: "44ca595b-2b0f-4b59-88e0-3d2d131ccab2",
title: "Teach Your AI Agent GraphQL",
start: "2026-10-29T14:30:00+11:00",
end: "2026-10-29T14:55:00+11:00",
@@ -176,6 +182,7 @@ export const melbourneSessions: EventSession[] = [
},
{
id: 5007,
+ uuid: "05db7eb0-02d4-4348-a72f-deb01da4613a",
title: "Beyond the Experience Graph: The Data Graph Meets AI",
start: "2026-10-29T15:00:00+11:00",
end: "2026-10-29T15:25:00+11:00",
diff --git a/src/app/day/2026/nyc/opengraph-image.tsx b/src/app/day/2026/nyc/opengraph-image.tsx
index 6ed5b759da..078937a4c0 100644
--- a/src/app/day/2026/nyc/opengraph-image.tsx
+++ b/src/app/day/2026/nyc/opengraph-image.tsx
@@ -1,12 +1,9 @@
import { SimpleOpengraphImage } from "../components/og-images/simple-opengraph-image"
+import { dayEvents } from "../day-events"
export {
generateStaticParams,
contentType,
size,
} from "../components/og-images/simple-opengraph-image"
-export default SimpleOpengraphImage.bind(null, {
- pageTitle: "NYC",
- date: "May 13-14, 2026",
- location: "Convene 360 Madison, New York",
-})
+export default SimpleOpengraphImage.bind(null, dayEvents.nyc)
diff --git a/src/app/day/2026/nyc/page.tsx b/src/app/day/2026/nyc/page.tsx
index dfdfaf35b4..edef79c64f 100644
--- a/src/app/day/2026/nyc/page.tsx
+++ b/src/app/day/2026/nyc/page.tsx
@@ -63,6 +63,7 @@ export default function NYCPage() {