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') + }) +})