diff --git a/.gitignore b/.gitignore
index b8b4661..26aa856 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
# testing
/coverage
+/.tmp-meetup-tests/
# next.js
/.next/
diff --git a/package.json b/package.json
index 41754f8..8b841d4 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"lint": "next lint",
"format:check": "prettier --check .",
"quality": "tsc --noEmit",
+ "test:meetup": "tsc -p tests/tsconfig.json && node --test .tmp-meetup-tests/tests/meetup.test.js",
"init:tailwind": "tailwindcss init tailwindcss.config.ts"
},
"engines": {
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9cc675b..a334150 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -406,15 +406,7 @@ export default async function Home() {
{past.length ? (
{past.slice(0, 3).map((event) => (
-
+
))}
) : (
diff --git a/src/lib/meetup.ts b/src/lib/meetup.ts
index 0145e83..6e3971c 100644
--- a/src/lib/meetup.ts
+++ b/src/lib/meetup.ts
@@ -37,8 +37,24 @@ type ApolloRecord = {
highResUrl?: string
}
+type PastEventsPage = {
+ events: MeetupEvent[]
+ totalCount?: number
+ endCursor?: string
+ hasNextPage: boolean
+ beforeDateTime?: string
+}
+
+type MeetupGraphQLEvent = ApolloRecord & {
+ venue?: { name?: string; city?: string } | null
+ displayPhoto?: { highResUrl?: string | null } | null
+ featuredEventPhoto?: { highResUrl?: string | null } | null
+}
+
const EVENTS_URL = "https://www.meetup.com/it-IT/coding-bunker/events/"
const PAST_EVENTS_URL = `${EVENTS_URL}?type=past`
+const MEETUP_GRAPHQL_URL = "https://www.meetup.com/gql2"
+const MEETUP_USER_AGENT = "CodingBunkerWebsite/1.0 (+https://codingbunker.it)"
function getNextData(html: string): Record | undefined {
const match = html.match(
@@ -54,6 +70,190 @@ function getNextData(html: string): Record | undefined {
}
}
+function isApolloRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
+function toMeetupEvent(
+ event: ApolloRecord,
+ state?: Record,
+): MeetupEvent | undefined {
+ if (!event.id || !event.title || !event.dateTime || !event.eventUrl) {
+ return undefined
+ }
+
+ const venueReference = event.venue?.__ref
+ const venue = (venueReference ? state?.[venueReference] : event.venue) as
+ | ApolloRecord
+ | undefined
+ const photoReference =
+ event.displayPhoto?.__ref || event.featuredEventPhoto?.__ref
+ const photo = photoReference ? state?.[photoReference] : undefined
+ const graphQLEvent = event as MeetupGraphQLEvent
+ const coverImageUrl =
+ photo?.highResUrl ||
+ graphQLEvent.displayPhoto?.highResUrl ||
+ graphQLEvent.featuredEventPhoto?.highResUrl ||
+ null
+
+ return {
+ id: event.id,
+ title: event.title,
+ description: event.description || "",
+ dateTime: event.dateTime,
+ endTime: event.endTime,
+ eventUrl: event.eventUrl,
+ status: event.status || "",
+ venue: venue?.name || null,
+ city: venue?.city || null,
+ attendeeCount: event.going?.totalCount ?? null,
+ coverImageUrl: typeof coverImageUrl === "string" ? coverImageUrl : null,
+ }
+}
+
+function getApolloState(page: Record | undefined) {
+ return (
+ page as
+ | {
+ props?: {
+ pageProps?: { __APOLLO_STATE__?: Record }
+ }
+ }
+ | undefined
+ )?.props?.pageProps?.__APOLLO_STATE__
+}
+
+/** Extracts event records and cursor metadata from Meetup's public page state. */
+export function parseMeetupPage(
+ page: Record | undefined,
+): PastEventsPage {
+ const state = getApolloState(page)
+ if (!state) return { events: [], hasNextPage: false }
+
+ const events = Object.values(state)
+ .filter(
+ (value) =>
+ value.__typename === "Event" &&
+ value.id &&
+ value.title &&
+ value.dateTime &&
+ value.eventUrl,
+ )
+ .map((event) => toMeetupEvent(event, state))
+ .filter((event): event is MeetupEvent => Boolean(event))
+
+ const group = Object.values(state).find(
+ (value) => value.__typename === "Group",
+ ) as Record | undefined
+ if (!group) return { events, hasNextPage: false }
+
+ const connection = Object.entries(group).find(([key, value]) => {
+ if (!key.includes('"status":["ACTIVE","PAST","CANCELLED"]')) {
+ return false
+ }
+ return isApolloRecord(value) && isApolloRecord(value.pageInfo)
+ })
+ const [key, value] = connection || []
+ const pageInfo =
+ isApolloRecord(value) && isApolloRecord(value.pageInfo)
+ ? value.pageInfo
+ : undefined
+ const beforeDateTime = key?.match(/"beforeDateTime":"([^"]+)"/)?.[1]
+
+ return {
+ events,
+ totalCount:
+ isApolloRecord(value) && typeof value.totalCount === "number"
+ ? value.totalCount
+ : undefined,
+ endCursor:
+ typeof pageInfo?.endCursor === "string" ? pageInfo.endCursor : undefined,
+ hasNextPage: pageInfo?.hasNextPage === true,
+ beforeDateTime,
+ }
+}
+
+const pastEventsQuery = `
+ query getPastGroupEvents(
+ $urlname: String!
+ $after: String
+ $beforeDateTime: DateTime
+ ) {
+ groupByUrlname(urlname: $urlname) {
+ events(
+ filter: {
+ status: [ACTIVE, PAST, CANCELLED]
+ beforeDateTime: $beforeDateTime
+ }
+ sort: DESC
+ first: 10
+ after: $after
+ ) {
+ totalCount
+ pageInfo { endCursor hasNextPage }
+ edges {
+ node {
+ id title description dateTime endTime eventUrl status
+ going { totalCount }
+ venue { name city }
+ displayPhoto { highResUrl }
+ featuredEventPhoto { highResUrl }
+ }
+ }
+ }
+ }
+ }
+`
+
+async function fetchPastEventsPage(
+ after: string,
+ beforeDateTime: string,
+): Promise {
+ const response = await fetch(MEETUP_GRAPHQL_URL, {
+ method: "POST",
+ next: { revalidate: 3600 },
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": MEETUP_USER_AGENT,
+ },
+ body: JSON.stringify({
+ operationName: "getPastGroupEvents",
+ variables: { urlname: "coding-bunker", after, beforeDateTime },
+ query: pastEventsQuery,
+ }),
+ })
+ if (!response.ok) return undefined
+
+ const payload = (await response.json()) as {
+ data?: {
+ groupByUrlname?: {
+ events?: {
+ totalCount?: number
+ pageInfo?: { endCursor?: string; hasNextPage?: boolean }
+ edges?: Array<{ node?: MeetupGraphQLEvent }>
+ }
+ }
+ }
+ }
+ const events = payload.data?.groupByUrlname?.events
+ if (!events) return undefined
+
+ return {
+ events: (events.edges || [])
+ .map(({ node }) => (node ? toMeetupEvent(node) : undefined))
+ .filter((event): event is MeetupEvent => Boolean(event)),
+ totalCount: events.totalCount,
+ endCursor: events.pageInfo?.endCursor,
+ hasNextPage: events.pageInfo?.hasNextPage === true,
+ }
+}
+
+export function deduplicateAndSortMeetupEvents(events: MeetupEvent[]) {
+ return [...new Map(events.map((event) => [event.id, event])).values()].sort(
+ (a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime(),
+ )
+}
+
/**
* Meetup does not provide a public, keyless event API for this group. Its public
* event page includes the same event data in Next/Apollo state, which we read at
@@ -65,58 +265,37 @@ export async function fetchMeetupEvents(): Promise {
[EVENTS_URL, PAST_EVENTS_URL].map(async (url) => {
const response = await fetch(url, {
next: { revalidate: 3600 },
- headers: {
- "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)",
- },
+ headers: { "User-Agent": MEETUP_USER_AGENT },
})
return response.ok ? getNextData(await response.text()) : undefined
}),
)
- const events = pages.flatMap((page) => {
- const state = (
- page as
- | {
- props?: {
- pageProps?: { __APOLLO_STATE__?: Record }
- }
- }
- | undefined
- )?.props?.pageProps?.__APOLLO_STATE__
- if (!state) return []
- return Object.entries(state)
- .filter(
- ([, value]) =>
- value.__typename === "Event" &&
- value.id &&
- value.title &&
- value.dateTime &&
- value.eventUrl,
- )
- .map(([key, event]) => {
- const venueReference = event.venue?.__ref
- const venue = venueReference ? state[venueReference] : undefined
- const photoReference =
- event.displayPhoto?.__ref || event.featuredEventPhoto?.__ref
- const photo = photoReference ? state[photoReference] : undefined
- return {
- id: event.id || key,
- title: event.title || "Evento Coding Bunker",
- description: event.description || "",
- dateTime: event.dateTime || "",
- endTime: event.endTime,
- eventUrl: event.eventUrl || EVENTS_URL,
- status: event.status || "",
- venue: venue?.name || null,
- city: venue?.city || null,
- attendeeCount: event.going?.totalCount ?? null,
- coverImageUrl:
- typeof photo?.highResUrl === "string" ? photo.highResUrl : null,
- }
- })
- })
- return [...new Map(events.map((event) => [event.id, event])).values()].sort(
- (a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(),
- )
+ const [currentPage, initialPastPage] = pages.map(parseMeetupPage)
+ const pastEvents = [...initialPastPage.events]
+ const totalCount = initialPastPage.totalCount
+ let cursor = initialPastPage.endCursor
+ let hasNextPage = initialPastPage.hasNextPage
+ const beforeDateTime = initialPastPage.beforeDateTime
+
+ while (
+ hasNextPage &&
+ cursor &&
+ beforeDateTime &&
+ (totalCount === undefined ||
+ new Set(pastEvents.map((event) => event.id)).size < totalCount)
+ ) {
+ const nextPage = await fetchPastEventsPage(cursor, beforeDateTime)
+ if (!nextPage) break
+ pastEvents.push(...nextPage.events)
+ if (!nextPage.endCursor || nextPage.endCursor === cursor) break
+ cursor = nextPage.endCursor
+ hasNextPage = nextPage.hasNextPage
+ }
+
+ return deduplicateAndSortMeetupEvents([
+ ...currentPage.events,
+ ...pastEvents,
+ ])
} catch {
return []
}
@@ -128,7 +307,7 @@ export async function fetchMeetupPastEventCount(): Promise {
const response = await fetch(EVENTS_URL, {
next: { revalidate: 3600 },
headers: {
- "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)",
+ "User-Agent": MEETUP_USER_AGENT,
},
})
if (!response.ok) return undefined
@@ -164,7 +343,7 @@ export async function fetchMeetupGroupStats(): Promise {
const response = await fetch(EVENTS_URL, {
next: { revalidate: 3600 },
headers: {
- "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)",
+ "User-Agent": MEETUP_USER_AGENT,
},
})
if (!response.ok) return {}
@@ -206,11 +385,16 @@ export async function fetchMeetupGroupStats(): Promise {
export function splitMeetupEvents(events: MeetupEvent[], now = new Date()) {
const current = now.getTime()
return {
- upcoming: events.filter(
- (event) =>
- new Date(event.dateTime).getTime() >= current &&
- event.status !== "CANCELLED",
- ),
+ upcoming: events
+ .filter(
+ (event) =>
+ new Date(event.dateTime).getTime() >= current &&
+ event.status !== "CANCELLED",
+ )
+ .sort(
+ (a, b) =>
+ new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(),
+ ),
past: events
.filter(
(event) =>
diff --git a/tests/fixtures/meetup/initial-past-page.json b/tests/fixtures/meetup/initial-past-page.json
new file mode 100644
index 0000000..75e7489
--- /dev/null
+++ b/tests/fixtures/meetup/initial-past-page.json
@@ -0,0 +1,43 @@
+{
+ "props": {
+ "pageProps": {
+ "__APOLLO_STATE__": {
+ "Event:3": {
+ "__typename": "Event",
+ "id": "3",
+ "title": "Newest",
+ "description": "Latest event",
+ "dateTime": "2026-10-20T18:30:00+02:00",
+ "eventUrl": "https://www.meetup.com/coding-bunker/events/3/",
+ "status": "PAST",
+ "displayPhoto": { "__ref": "Photo:3" }
+ },
+ "Event:2": {
+ "__typename": "Event",
+ "id": "2",
+ "title": "Duplicate",
+ "description": "Repeated at the cursor boundary",
+ "dateTime": "2026-10-10T18:30:00+02:00",
+ "eventUrl": "https://www.meetup.com/coding-bunker/events/2/",
+ "status": "PAST"
+ },
+ "Photo:3": {
+ "__typename": "Photo",
+ "highResUrl": "https://images.example/newest.jpg"
+ },
+ "Group:1": {
+ "__typename": "Group",
+ "events({\"filter\":{\"beforeDateTime\":\"2026-10-26T18:05:38.295Z\",\"status\":[\"ACTIVE\",\"PAST\",\"CANCELLED\"]},\"first\":10,\"sort\":\"DESC\"})": {
+ "__typename": "GroupEventConnection",
+ "totalCount": 3,
+ "pageInfo": {
+ "__typename": "PageInfo",
+ "endCursor": "cursor-1",
+ "hasNextPage": true
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/meetup/next-past-page.json b/tests/fixtures/meetup/next-past-page.json
new file mode 100644
index 0000000..26591c9
--- /dev/null
+++ b/tests/fixtures/meetup/next-past-page.json
@@ -0,0 +1,35 @@
+{
+ "data": {
+ "groupByUrlname": {
+ "events": {
+ "totalCount": 3,
+ "pageInfo": { "endCursor": "cursor-2", "hasNextPage": false },
+ "edges": [
+ {
+ "node": {
+ "id": "2",
+ "title": "Duplicate",
+ "description": "Repeated at the cursor boundary",
+ "dateTime": "2026-10-10T18:30:00+02:00",
+ "eventUrl": "https://www.meetup.com/coding-bunker/events/2/",
+ "status": "PAST"
+ }
+ },
+ {
+ "node": {
+ "id": "1",
+ "title": "Oldest",
+ "description": "First event",
+ "dateTime": "2026-10-01T18:30:00+02:00",
+ "eventUrl": "https://www.meetup.com/coding-bunker/events/1/",
+ "status": "PAST",
+ "featuredEventPhoto": {
+ "highResUrl": "https://images.example/oldest.jpg"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/meetup.test.ts b/tests/meetup.test.ts
new file mode 100644
index 0000000..568063c
--- /dev/null
+++ b/tests/meetup.test.ts
@@ -0,0 +1,87 @@
+import assert from "node:assert/strict"
+import { readFileSync } from "node:fs"
+import test from "node:test"
+import {
+ deduplicateAndSortMeetupEvents,
+ fetchMeetupEvents,
+ parseMeetupPage,
+} from "../src/lib/meetup"
+
+const fixture = (name: string) =>
+ JSON.parse(readFileSync(`tests/fixtures/meetup/${name}`, "utf8")) as Record<
+ string,
+ unknown
+ >
+
+test("parses cursor metadata and event images from the initial past-events page", () => {
+ const page = parseMeetupPage(fixture("initial-past-page.json"))
+
+ assert.equal(page.totalCount, 3)
+ assert.equal(page.endCursor, "cursor-1")
+ assert.equal(page.hasNextPage, true)
+ assert.equal(page.beforeDateTime, "2026-10-26T18:05:38.295Z")
+ assert.equal(
+ page.events[0]?.coverImageUrl,
+ "https://images.example/newest.jpg",
+ )
+})
+
+test("fetches every cursor page, deduplicates IDs, and returns newest first", async () => {
+ const originalFetch = global.fetch
+ const initial = fixture("initial-past-page.json")
+ const next = fixture("next-past-page.json")
+ let request = 0
+ global.fetch = async (input) => {
+ request += 1
+ const url = String(input)
+ if (url.includes("gql2")) {
+ return new Response(JSON.stringify(next), { status: 200 })
+ }
+ return new Response(
+ ``,
+ { status: 200 },
+ )
+ }
+
+ try {
+ const events = await fetchMeetupEvents()
+ assert.equal(request, 3)
+ assert.deepEqual(
+ events.map((event) => event.id),
+ ["3", "2", "1"],
+ )
+ assert.equal(events[2]?.coverImageUrl, "https://images.example/oldest.jpg")
+ } finally {
+ global.fetch = originalFetch
+ }
+})
+
+test("keeps parsed events when a payload changes or omits the connection", () => {
+ const page = parseMeetupPage({
+ props: {
+ pageProps: {
+ __APOLLO_STATE__: {
+ "Event:1": {
+ __typename: "Event",
+ id: "1",
+ title: "Still usable",
+ dateTime: "2026-01-01T18:30:00+01:00",
+ eventUrl: "https://www.meetup.com/coding-bunker/events/1/",
+ },
+ },
+ },
+ },
+ })
+
+ assert.deepEqual(
+ page.events.map((event) => event.id),
+ ["1"],
+ )
+ assert.equal(page.hasNextPage, false)
+ assert.deepEqual(
+ deduplicateAndSortMeetupEvents([...page.events, ...page.events]).map(
+ (event) => event.id,
+ ),
+ ["1"],
+ )
+})
diff --git a/tests/tsconfig.json b/tests/tsconfig.json
new file mode 100644
index 0000000..d3ee46e
--- /dev/null
+++ b/tests/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "../.tmp-meetup-tests",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "jsx": "react-jsx"
+ },
+ "include": ["../next-env.d.ts", "../src/lib/meetup.ts", "./meetup.test.ts"]
+}