diff --git a/nuxt-app/components/MeetupCard.vue b/nuxt-app/components/MeetupCard.vue
index 9aa63ee1..ddaf455e 100644
--- a/nuxt-app/components/MeetupCard.vue
+++ b/nuxt-app/components/MeetupCard.vue
@@ -17,11 +17,11 @@
{{ meetup.title }}
-
+
- {{ description }}
+ {{ excerpt }}
@@ -48,7 +48,7 @@ export default defineComponent({
props: {
meetup: {
type: Object as PropType<
- Pick
+ Pick
>,
required: true,
},
@@ -61,11 +61,23 @@ export default defineComponent({
// Create href to meetup subpage
const href = computed(() => `/meetup/${props.meetup.slug}`)
- const description = computed(() => getPlainText(props.meetup.description))
+ // The teaser prose belongs in `intro`; `description` carries the event details, and since
+ // late 2025 that is the recurring call for Lightning-Talk speakers plus the agenda, which
+ // reads as boilerplate on every card. Meetups created before then left `intro` empty and put
+ // their prose in `description`, so fall back to it rather than showing an empty card.
+ //
+ // Both fields are CMS rich text, so both go through `getPlainText`: it strips the markup and
+ // decodes entities, and it also turns an `intro` that is null or nothing but empty tags into
+ // an empty string, which is what the fallback tests.
+ const excerpt = computed(() => {
+ const intro = getPlainText(props.meetup.intro).trim()
+
+ return intro || getPlainText(props.meetup.description)
+ })
return {
href,
- description,
+ excerpt,
}
},
})
diff --git a/nuxt-app/components/MeetupSection.vue b/nuxt-app/components/MeetupSection.vue
index 77a77b57..80c51926 100644
--- a/nuxt-app/components/MeetupSection.vue
+++ b/nuxt-app/components/MeetupSection.vue
@@ -44,7 +44,10 @@ export default defineComponent({
props: {
meetups: {
type: Array as PropType<
- Pick[]
+ Pick<
+ MeetupItem,
+ 'id' | 'slug' | 'start_on' | 'end_on' | 'title' | 'cover_image' | 'intro' | 'description'
+ >[]
>,
required: true,
},
diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts
index dcb82792..5b9b097b 100644
--- a/nuxt-app/composables/useDirectus.ts
+++ b/nuxt-app/composables/useDirectus.ts
@@ -629,6 +629,7 @@ export function useDirectus() {
'start_on',
'end_on',
'title',
+ 'intro',
'description',
'cover_image.*',
'tags.tag.id',
diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts
index 68f63389..ad9fcd29 100644
--- a/nuxt-app/helpers/index.ts
+++ b/nuxt-app/helpers/index.ts
@@ -3,6 +3,7 @@ export * from './formatAudioTimestamp'
export * from './getCookie'
export * from './getHashCode'
export * from './getMetaInfo'
+export * from './meetupSchedule'
export * from './normalizeExternalUrl'
export * from './getTrimmedString'
export * from './parseCmsDate'
diff --git a/nuxt-app/helpers/meetupSchedule.ts b/nuxt-app/helpers/meetupSchedule.ts
new file mode 100644
index 00000000..2a709b24
--- /dev/null
+++ b/nuxt-app/helpers/meetupSchedule.ts
@@ -0,0 +1,52 @@
+import type { DirectusMeetupItem } from '~/types/directus'
+
+/**
+ * Selects and orders meetups by their date, so the meetup overview and the
+ * homepage derive their lists the same way.
+ *
+ * `getMeetups` sorts newest first, which is what a list of past meetups wants:
+ * the most recent one on top. Upcoming meetups have to run the other way
+ * round, otherwise the date furthest out is shown first and the next one sits
+ * at the bottom of the section.
+ *
+ * Both selections take `now` as an argument rather than reading the clock
+ * themselves, so a page that renders a past *and* an upcoming list compares
+ * every meetup against a single reference time.
+ *
+ * The boundary belongs to the upcoming side: a meetup whose `start_on` is
+ * exactly `now` has not happened yet, so it counts as upcoming and not as
+ * past. The two selections therefore partition the meetups completely — every
+ * meetup lands in exactly one of the lists, and one starting at the very
+ * moment the page renders cannot fall out of both.
+ */
+type ScheduledMeetup = Pick
+
+/**
+ * Upcoming meetups, soonest first.
+ *
+ * @param meetups The meetups to select from, in any order.
+ * @param now The reference time a meetup's `start_on` is compared against.
+ *
+ * @returns A new array holding the meetups that start at or after `now`, sorted ascending.
+ */
+export function getUpcomingMeetups(meetups: T[], now: Date): T[] {
+ return meetups
+ .filter((meetup) => new Date(meetup.start_on) >= now)
+ .sort((a, b) => new Date(a.start_on).getTime() - new Date(b.start_on).getTime())
+}
+
+/**
+ * Past meetups, in the order they came in — the CMS query already sorts them
+ * newest first, which is the order the past list is shown in.
+ *
+ * Strictly before `now`: a meetup starting exactly at the reference time is
+ * upcoming, not past.
+ *
+ * @param meetups The meetups to select from.
+ * @param now The reference time a meetup's `start_on` is compared against.
+ *
+ * @returns A new array holding the meetups that started before `now`.
+ */
+export function getPastMeetups(meetups: T[], now: Date): T[] {
+ return meetups.filter((meetup) => new Date(meetup.start_on) < now)
+}
diff --git a/nuxt-app/pages/index.vue b/nuxt-app/pages/index.vue
index 3a014ef2..6213cba6 100644
--- a/nuxt-app/pages/index.vue
+++ b/nuxt-app/pages/index.vue
@@ -98,6 +98,7 @@ import BrandLogoIcon from '~/assets/images/brand-logo.svg'
import PrimaryPbButton from '~/components/PrimaryPbButton.vue'
import TestimonialSlider from '~/components/TestimonialSlider.vue'
import { useDirectus } from '~/composables/useDirectus'
+import { getUpcomingMeetups } from '~/helpers'
import { getAssetUrl } from '~/helpers/getAssetUrl'
import { generatePodcastSeries } from '~/helpers/jsonLdGenerator'
import { computed, type ComputedRef } from 'vue'
@@ -120,10 +121,7 @@ const { data: pageData } = useAsyncData(async () => {
directus.getTestimonials(),
])
- const upcomingMeetups = meetups.filter((meetup) => {
- const now = new Date()
- return new Date(meetup.start_on) > now
- })
+ const upcomingMeetups = getUpcomingMeetups(meetups, new Date())
return { homePage, latestPodcasts, podcastCount, upcomingMeetups, testimonials }
})
diff --git a/nuxt-app/pages/meetup/index.vue b/nuxt-app/pages/meetup/index.vue
index 8513a89e..963602ea 100644
--- a/nuxt-app/pages/meetup/index.vue
+++ b/nuxt-app/pages/meetup/index.vue
@@ -39,6 +39,7 @@
import TestimonialSlider from '~/components/TestimonialSlider.vue'
import { useLoadingScreen, usePageMeta } from '~/composables'
import { useDirectus } from '~/composables/useDirectus'
+import { getPastMeetups, getUpcomingMeetups } from '~/helpers'
import type { DirectusMeetupItem, DirectusMeetupPage, DirectusTestimonialItem } from '~/types'
import { computed, type ComputedRef } from 'vue'
@@ -53,17 +54,11 @@ const { data: pageData } = useAsyncData(async () => {
directus.getTestimonials(),
])
- const pastMeetups = meetups.filter((meetup) => {
- const now = new Date()
+ // One reference time for both lists, so a meetup cannot fall out of either.
+ const now = new Date()
- return new Date(meetup.start_on) < now
- })
-
- const upcomingMeetups = meetups.filter((meetup) => {
- const now = new Date()
-
- return new Date(meetup.start_on) > now
- })
+ const pastMeetups = getPastMeetups(meetups, now)
+ const upcomingMeetups = getUpcomingMeetups(meetups, now)
return { meetupPage, upcomingMeetups, pastMeetups, testimonials }
})
diff --git a/nuxt-app/test/meetupSchedule.test.ts b/nuxt-app/test/meetupSchedule.test.ts
new file mode 100644
index 00000000..aa956dba
--- /dev/null
+++ b/nuxt-app/test/meetupSchedule.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest'
+import { getPastMeetups, getUpcomingMeetups } from '../helpers/meetupSchedule'
+
+// The ordering is the reason this exists. `getMeetups` sorts newest first, which put the meetup
+// furthest out at the top of the upcoming section and the next one at the bottom.
+
+const NOW = new Date('2026-06-15T12:00:00Z')
+
+// Newest first, the order the CMS query returns.
+const MEETUPS = [
+ { slug: 'in-three-months', start_on: '2026-09-01T18:00:00Z' },
+ { slug: 'next-week', start_on: '2026-06-22T18:00:00Z' },
+ { slug: 'tomorrow', start_on: '2026-06-16T18:00:00Z' },
+ { slug: 'last-month', start_on: '2026-05-10T18:00:00Z' },
+ { slug: 'last-year', start_on: '2025-06-10T18:00:00Z' },
+]
+
+// A meetup starting at the exact reference time. Both selections used a strict comparison once, so
+// this one fell out of the upcoming *and* the past list — invisible on the page for that one moment.
+const STARTING_NOW = { slug: 'starting-now', start_on: NOW.toISOString() }
+
+describe('getUpcomingMeetups', () => {
+ it('returns only meetups that start after the reference time', () => {
+ expect(getUpcomingMeetups(MEETUPS, NOW).map((meetup) => meetup.slug)).toEqual([
+ 'tomorrow',
+ 'next-week',
+ 'in-three-months',
+ ])
+ })
+
+ it('sorts soonest first, whatever order it is given', () => {
+ const reversed = [...MEETUPS].reverse()
+
+ expect(getUpcomingMeetups(reversed, NOW).map((meetup) => meetup.slug)).toEqual([
+ 'tomorrow',
+ 'next-week',
+ 'in-three-months',
+ ])
+ })
+
+ it('leaves the input array untouched', () => {
+ const meetups = [...MEETUPS]
+
+ getUpcomingMeetups(meetups, NOW)
+
+ expect(meetups).toEqual(MEETUPS)
+ })
+
+ it('returns nothing when every meetup is in the past', () => {
+ expect(getUpcomingMeetups(MEETUPS, new Date('2027-01-01T00:00:00Z'))).toEqual([])
+ })
+
+ it('counts a meetup starting exactly at the reference time as upcoming', () => {
+ expect(getUpcomingMeetups([...MEETUPS, STARTING_NOW], NOW).map((meetup) => meetup.slug)).toEqual([
+ 'starting-now',
+ 'tomorrow',
+ 'next-week',
+ 'in-three-months',
+ ])
+ })
+})
+
+describe('getPastMeetups', () => {
+ it('returns only meetups that started before the reference time', () => {
+ expect(getPastMeetups(MEETUPS, NOW).map((meetup) => meetup.slug)).toEqual(['last-month', 'last-year'])
+ })
+
+ it('does not count a meetup starting exactly at the reference time as past', () => {
+ expect(getPastMeetups([...MEETUPS, STARTING_NOW], NOW).map((meetup) => meetup.slug)).toEqual([
+ 'last-month',
+ 'last-year',
+ ])
+ })
+
+ it('keeps the incoming order, which the CMS query already sorts newest first', () => {
+ expect(getPastMeetups(MEETUPS, new Date('2027-01-01T00:00:00Z')).map((meetup) => meetup.slug)).toEqual([
+ 'in-three-months',
+ 'next-week',
+ 'tomorrow',
+ 'last-month',
+ 'last-year',
+ ])
+ })
+})
+
+describe('the two selections together', () => {
+ it('split the meetups without dropping or duplicating one', () => {
+ const upcoming = getUpcomingMeetups(MEETUPS, NOW)
+ const past = getPastMeetups(MEETUPS, NOW)
+
+ expect(upcoming.length + past.length).toBe(MEETUPS.length)
+ expect([...upcoming, ...past].map((meetup) => meetup.slug).sort()).toEqual(
+ MEETUPS.map((meetup) => meetup.slug).sort()
+ )
+ })
+
+ it('still split cleanly when a meetup starts exactly at the reference time', () => {
+ // The regression: with a strict comparison on both sides, `starting-now` was in neither list.
+ const meetups = [...MEETUPS, STARTING_NOW]
+ const upcoming = getUpcomingMeetups(meetups, NOW)
+ const past = getPastMeetups(meetups, NOW)
+
+ expect(upcoming.length + past.length).toBe(meetups.length)
+ expect([...upcoming, ...past].map((meetup) => meetup.slug).sort()).toEqual(
+ meetups.map((meetup) => meetup.slug).sort()
+ )
+ expect(upcoming.map((meetup) => meetup.slug)).toContain('starting-now')
+ expect(past.map((meetup) => meetup.slug)).not.toContain('starting-now')
+ })
+})