diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 4580671cd3..2f78a61022 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -50,6 +50,10 @@ const footerSections = computed>(( name: t('footer.blog'), href: '/blog', }, + { + name: t('nav.events'), + href: '/events', + }, { name: t('footer.about'), href: '/about', diff --git a/app/components/Events/Card.vue b/app/components/Events/Card.vue new file mode 100644 index 0000000000..cb1e7cfc09 --- /dev/null +++ b/app/components/Events/Card.vue @@ -0,0 +1,101 @@ + + + diff --git a/app/components/Events/Gallery.vue b/app/components/Events/Gallery.vue new file mode 100644 index 0000000000..4988a3c969 --- /dev/null +++ b/app/components/Events/Gallery.vue @@ -0,0 +1,131 @@ + + + diff --git a/app/components/Events/TalkRow.vue b/app/components/Events/TalkRow.vue new file mode 100644 index 0000000000..67ac9f4611 --- /dev/null +++ b/app/components/Events/TalkRow.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/composables/useCommandPaletteGlobalCommands.ts b/app/composables/useCommandPaletteGlobalCommands.ts index 0ba7af3c6e..299ea1ff2a 100644 --- a/app/composables/useCommandPaletteGlobalCommands.ts +++ b/app/composables/useCommandPaletteGlobalCommands.ts @@ -347,6 +347,19 @@ export function useCommandPaletteGlobalCommands() { ), to: { name: 'blog' }, }, + { + id: 'events', + group: 'npmx', + label: t('events.title'), + keywords: [t('events.title'), t('events.talks')], + iconClass: 'i-lucide:calendar', + active: route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'), + activeLabel: activeLabel( + route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'), + t('command_palette.here'), + ), + to: { name: 'events' }, + }, { id: 'noodles', group: 'npmx', diff --git a/app/composables/useEvents.ts b/app/composables/useEvents.ts new file mode 100644 index 0000000000..554bca5dca --- /dev/null +++ b/app/composables/useEvents.ts @@ -0,0 +1,38 @@ +import type { EventDetail, EventKind, EventSummary } from '~/types/events' +import { isPastEvent } from '~/utils/events/format' + +export function useEvents() { + const { locale } = useI18n() + const { data, pending, error } = useFetch('/api/events', { + key: 'events', + default: () => [], + }) + + const all = computed(() => data.value ?? []) + + const upcoming = computed(() => + all.value.filter(e => !isPastEvent(e)).sort((a, b) => a.startsAt.localeCompare(b.startsAt)), + ) + + const past = computed(() => + all.value.filter(e => isPastEvent(e)).sort((a, b) => b.startsAt.localeCompare(a.startsAt)), + ) + + const kinds = computed(() => { + const set = new Set() + for (const e of all.value) set.add(e.kind) + return [...set] + }) + + function findBySlug(slug: string): EventDetail | undefined { + return all.value.find(e => e.slug === slug) + } + + function relatedTo(slug: string, limit = 3): EventSummary[] { + const current = findBySlug(slug) + if (!current) return [] + return all.value.filter(e => e.slug !== slug && e.kind === current.kind).slice(0, limit) + } + + return { all, pending, error, upcoming, past, kinds, findBySlug, relatedTo, locale } +} diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue new file mode 100644 index 0000000000..c2c66f82f0 --- /dev/null +++ b/app/pages/events/[slug].vue @@ -0,0 +1,232 @@ + + + diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue new file mode 100644 index 0000000000..44f7f56f0c --- /dev/null +++ b/app/pages/events/index.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/types/events.ts b/app/types/events.ts new file mode 100644 index 0000000000..008a7286a9 --- /dev/null +++ b/app/types/events.ts @@ -0,0 +1,78 @@ +export type EventKind = 'meetup' | 'theatre' | 'meeting' | 'conference' | 'talk' +export type EventMode = 'inperson' | 'virtual' | 'hybrid' +export type EventStatus = 'scheduled' | 'planned' | 'cancelled' | 'postponed' | 'rescheduled' + +export interface Attendee { + name: string + handle?: string + did?: string + avatar?: string +} + +export interface EventHost { + name: string + uri?: string +} + +export interface Speaker { + name: string + handle?: string + did?: string + avatar?: string +} + +export interface Talk { + id: string + title: string + abstract?: string + speakers: Speaker[] + startsAt?: string + watchUrl?: string + slidesUrl?: string + pdfUrl?: string +} + +export interface EventLocation { + name?: string + locality?: string + country?: string + lat?: string + lon?: string +} + +export interface EventLink { + uri: string + name?: string +} + +export interface GalleryImage { + url: string + alt?: string +} + +export interface EventSummary { + slug: string + name: string + description?: string + kind: EventKind + mode: EventMode + status: EventStatus + startsAt: string + endsAt?: string + cover?: string + location?: EventLocation + tags: string[] + attendees: Attendee[] + attendeeCount: number +} + +export interface EventDetail extends EventSummary { + hosts: EventHost[] + scheduleImage?: string + schedule?: Array<{ time: string; label: string }> + links: EventLink[] + registerUrl?: string + bskyPostUrl?: string + talks: Talk[] + gallery?: GalleryImage[] +} diff --git a/app/utils/events/format.ts b/app/utils/events/format.ts new file mode 100644 index 0000000000..0f6051130e --- /dev/null +++ b/app/utils/events/format.ts @@ -0,0 +1,43 @@ +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export function formatEventDateRange(startsAt: string, endsAt?: string, locale = 'en'): string { + const start = new Date(startsAt) + const end = endsAt ? new Date(endsAt) : undefined + + const day = (d: Date) => new Intl.DateTimeFormat(locale, { day: 'numeric' }).format(d) + const dayMonth = (d: Date) => + new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(d) + const full = (d: Date) => + new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(d) + const time = (d: Date) => + new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(d) + + if (!end || sameDay(start, end)) { + const base = full(start) + if (end && (start.getHours() !== end.getHours() || start.getMinutes() !== end.getMinutes())) { + return `${base} · ${time(start)}–${time(end)}` + } + return base + } + + const sameYear = start.getFullYear() === end.getFullYear() + const sameMonth = sameYear && start.getMonth() === end.getMonth() + + if (sameMonth) return `${day(start)}–${full(end)}` + if (sameYear) return `${dayMonth(start)} – ${full(end)}` + return `${full(start)} – ${full(end)}` +} + +export function isPastEvent( + event: { startsAt: string; endsAt?: string }, + now = new Date(), +): boolean { + const reference = event.endsAt ? new Date(event.endsAt) : new Date(event.startsAt) + return reference.getTime() < now.getTime() +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..dc5204b5bc 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -212,7 +212,49 @@ "mobile_menu": "Navigation menu", "open_menu": "Open menu", "links": "Links", - "tap_to_search": "Tap to search" + "tap_to_search": "Tap to search", + "events": "events" + }, + "events": { + "title": "events", + "meta_description": "npmx community events, meetups, talks and recordings.", + "intro": "All the magic of npmx is achieved through its community — strong, active, and warm. And behind the development itself are real people and their encounters.", + "upcoming": "Upcoming", + "past": "Past", + "empty": "No events yet.", + "filter_all": "all", + "kind": { + "meetup": "meetup", + "theatre": "theatre", + "meeting": "meeting", + "conference": "conference", + "talk": "talk" + }, + "mode": { + "online": "online", + "in_person": "in person", + "hybrid": "hybrid" + }, + "about": "About", + "location": "Location", + "talks": "Talks", + "watch": "Watch", + "slides": "Slides", + "pdf": "PDF", + "gallery": "Gallery", + "gallery_prev": "Previous image", + "gallery_next": "Next image", + "social": "Social", + "related": "Related events", + "attending": "Attending", + "hosted_by": "Hosted by", + "register": "Register", + "back": "All events", + "missing": { + "label": "no such event", + "title": "This event isn't on the calendar.", + "body": "We couldn't find an event at \"{slug}\". It may have been moved, or it never happened. Head back to all events to see what's on." + } }, "blog": { "title": "Blog", diff --git a/lexicons/community/lexicon/calendar/event.json b/lexicons/community/lexicon/calendar/event.json new file mode 100644 index 0000000000..657e947985 --- /dev/null +++ b/lexicons/community/lexicon/calendar/event.json @@ -0,0 +1,143 @@ +{ + "lexicon": 1, + "id": "community.lexicon.calendar.event", + "defs": { + "main": { + "type": "record", + "description": "A calendar event.", + "key": "tid", + "record": { + "type": "object", + "required": ["createdAt", "name"], + "properties": { + "name": { + "type": "string", + "description": "The name of the event." + }, + "description": { + "type": "string", + "description": "The description of the event." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event was created." + }, + "startsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event starts." + }, + "endsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event ends." + }, + "mode": { + "type": "ref", + "ref": "community.lexicon.calendar.event#mode", + "description": "The attendance mode of the event." + }, + "status": { + "type": "ref", + "ref": "community.lexicon.calendar.event#status", + "description": "The status of the event." + }, + "locations": { + "type": "array", + "description": "The locations where the event takes place.", + "items": { + "type": "union", + "refs": [ + "community.lexicon.calendar.event#uri", + "community.lexicon.location.address", + "community.lexicon.location.geo" + ] + } + }, + "uris": { + "type": "array", + "description": "URIs associated with the event.", + "items": { + "type": "ref", + "ref": "community.lexicon.calendar.event#uri" + } + }, + "rsvpExpected": { + "type": "boolean", + "description": "Whether a response is requested from attendees." + } + } + } + }, + "mode": { + "type": "string", + "description": "The mode of the event.", + "default": "community.lexicon.calendar.event#inperson", + "knownValues": [ + "community.lexicon.calendar.event#hybrid", + "community.lexicon.calendar.event#inperson", + "community.lexicon.calendar.event#virtual" + ] + }, + "virtual": { + "type": "token", + "description": "A virtual event that takes place online." + }, + "inperson": { + "type": "token", + "description": "An in-person event that takes place offline." + }, + "hybrid": { + "type": "token", + "description": "A hybrid event that takes place both online and offline." + }, + "status": { + "type": "string", + "description": "The status of the event.", + "default": "community.lexicon.calendar.event#scheduled", + "knownValues": [ + "community.lexicon.calendar.event#cancelled", + "community.lexicon.calendar.event#planned", + "community.lexicon.calendar.event#postponed", + "community.lexicon.calendar.event#rescheduled", + "community.lexicon.calendar.event#scheduled" + ] + }, + "planned": { + "type": "token", + "description": "The event has been created, but not finalized." + }, + "scheduled": { + "type": "token", + "description": "The event has been created and scheduled." + }, + "rescheduled": { + "type": "token", + "description": "The event has been rescheduled." + }, + "cancelled": { + "type": "token", + "description": "The event has been cancelled." + }, + "postponed": { + "type": "token", + "description": "The event has been postponed and a new start date has not been set." + }, + "uri": { + "type": "object", + "description": "A URI associated with the event.", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string", + "description": "The display name of the URI." + } + } + } + } +} diff --git a/lexicons/community/lexicon/calendar/rsvp.json b/lexicons/community/lexicon/calendar/rsvp.json new file mode 100644 index 0000000000..4d338922b0 --- /dev/null +++ b/lexicons/community/lexicon/calendar/rsvp.json @@ -0,0 +1,42 @@ +{ + "lexicon": 1, + "id": "community.lexicon.calendar.rsvp", + "defs": { + "main": { + "type": "record", + "description": "An RSVP for an event.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "status"], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "status": { + "type": "string", + "default": "community.lexicon.calendar.rsvp#going", + "knownValues": [ + "community.lexicon.calendar.rsvp#interested", + "community.lexicon.calendar.rsvp#going", + "community.lexicon.calendar.rsvp#notgoing" + ] + } + } + } + }, + "interested": { + "type": "token", + "description": "Interested in the event" + }, + "going": { + "type": "token", + "description": "Going to the event" + }, + "notgoing": { + "type": "token", + "description": "Not going to the event" + } + } +} diff --git a/lexicons/community/lexicon/location/address.json b/lexicons/community/lexicon/location/address.json new file mode 100644 index 0000000000..2b4903f869 --- /dev/null +++ b/lexicons/community/lexicon/location/address.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "community.lexicon.location.address", + "defs": { + "main": { + "type": "object", + "description": "A physical location in the form of a street address.", + "required": ["country"], + "properties": { + "country": { + "type": "string", + "description": "The ISO 3166 country code. Preferably the 2-letter code.", + "minLength": 2, + "maxLength": 10 + }, + "postalCode": { + "type": "string", + "description": "The postal code of the location." + }, + "region": { + "type": "string", + "description": "The administrative region of the country. For example, a state in the USA." + }, + "locality": { + "type": "string", + "description": "The locality of the region. For example, a city in the USA." + }, + "street": { + "type": "string", + "description": "The street address." + }, + "name": { + "type": "string", + "description": "The name of the location." + } + } + } + } +} diff --git a/lexicons/community/lexicon/location/geo.json b/lexicons/community/lexicon/location/geo.json new file mode 100644 index 0000000000..bd703b8df9 --- /dev/null +++ b/lexicons/community/lexicon/location/geo.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "community.lexicon.location.geo", + "defs": { + "main": { + "type": "object", + "description": "A physical location in the form of a WGS84 coordinate.", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + }, + "altitude": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name of the location." + } + } + } + } +} diff --git a/lexicons/dev/npmx/calendar/eventMeta.json b/lexicons/dev/npmx/calendar/eventMeta.json new file mode 100644 index 0000000000..86dc7853a4 --- /dev/null +++ b/lexicons/dev/npmx/calendar/eventMeta.json @@ -0,0 +1,77 @@ +{ + "lexicon": 1, + "id": "dev.npmx.calendar.eventMeta", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "npmx-specific presentation metadata that decorates a community.lexicon.calendar.event. Kept separate so the underlying event record stays portable and standard.", + "record": { + "type": "object", + "required": ["event", "createdAt"], + "properties": { + "event": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A strong reference to the community.lexicon.calendar.event this metadata decorates." + }, + "slug": { + "type": "string", + "description": "URL-friendly identifier used for the event page path.", + "maxLength": 512 + }, + "kind": { + "type": "string", + "description": "The npmx event category, used for filtering.", + "knownValues": ["meetup", "theatre", "meeting", "conference", "talk"] + }, + "cover": { + "type": "blob", + "accept": ["image/png", "image/jpeg", "image/webp"], + "maxSize": 2000000, + "description": "A cover image shown on event cards and the event header." + }, + "tags": { + "type": "array", + "maxLength": 12, + "items": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + } + }, + "hosts": { + "type": "array", + "description": "Organisations or people hosting the event.", + "items": { + "type": "ref", + "ref": "#host" + } + }, + "bskyPostRef": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A Bluesky post to feature as the event's social embed." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "host": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/lexicons/dev/npmx/calendar/talk.json b/lexicons/dev/npmx/calendar/talk.json new file mode 100644 index 0000000000..2654d97f1f --- /dev/null +++ b/lexicons/dev/npmx/calendar/talk.json @@ -0,0 +1,102 @@ +{ + "lexicon": 1, + "id": "dev.npmx.calendar.talk", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A talk given at an npmx community event.", + "record": { + "type": "object", + "required": ["title", "createdAt"], + "properties": { + "event": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A strong reference to the community.lexicon.calendar.event this talk was given at." + }, + "title": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300 + }, + "abstract": { + "type": "string", + "maxLength": 30000, + "maxGraphemes": 3000, + "description": "A short summary of the talk." + }, + "speakers": { + "type": "array", + "description": "The people who gave the talk.", + "items": { + "type": "ref", + "ref": "#speaker" + } + }, + "startsAt": { + "type": "string", + "format": "datetime" + }, + "slides": { + "type": "ref", + "ref": "#asset", + "description": "The presentation slides, hosted as a blob or linked externally." + }, + "recording": { + "type": "ref", + "ref": "#asset", + "description": "A recording of the talk, hosted as a blob or linked externally." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "speaker": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + }, + "did": { + "type": "string", + "format": "did", + "description": "The speaker's atproto account, if they have one." + }, + "handle": { + "type": "string", + "format": "handle" + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + } + } + }, + "asset": { + "type": "object", + "description": "A media asset, either hosted as a blob or linked externally.", + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "External link to the asset (e.g. YouTube, SpeakerDeck, Google Slides)." + }, + "blob": { + "type": "blob", + "description": "The hosted file." + }, + "name": { + "type": "string" + } + } + } + } +} diff --git a/server/api/events/index.get.ts b/server/api/events/index.get.ts new file mode 100644 index 0000000000..433dbd07bb --- /dev/null +++ b/server/api/events/index.get.ts @@ -0,0 +1,93 @@ +import type { EventDetail, EventKind, EventLink, EventMode, EventStatus } from '~/types/events' + +const NPMX_PDS_HOST = 'https://npmx.social' +const NPMX_EVENTS_DID = 'did:plc:u5zp7npt5kpueado77kuihyz' +const EVENT_COLLECTION = 'community.lexicon.calendar.event' + +interface RawBlobRef { + ref?: { $link?: string } + mimeType?: string +} + +interface RawEvent { + name: string + description?: string + mode?: string + status?: string + startsAt?: string + endsAt?: string + createdAt: string + uris?: Array<{ uri: string; name?: string }> + locations?: Array<{ uri?: string; name?: string }> + media?: Array<{ role?: string; content?: RawBlobRef }> +} + +interface ListRecordsResponse { + records: Array<{ uri: string; value: RawEvent }> + cursor?: string +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function tokenTail(value: string | undefined, fallback: string): string { + if (!value) return fallback + const tail = value.split('#').pop() + return tail || fallback +} + +function blobUrl(did: string, ref?: RawBlobRef): string | undefined { + const cid = ref?.ref?.$link + if (!cid) return undefined + return `${NPMX_PDS_HOST}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` +} + +function mapEvent(did: string, value: RawEvent): EventDetail { + const uris = value.uris ?? [] + const links: EventLink[] = uris.map(u => ({ uri: u.uri, name: u.name })) + const registerUrl = uris.find(u => u.uri.includes('guild.host'))?.uri + const cover = blobUrl(did, value.media?.find(m => m.role === 'thumbnail')?.content) + + return { + slug: slugify(value.name), + name: value.name, + description: value.description, + kind: 'meetup' as EventKind, + mode: tokenTail(value.mode, 'inperson') as EventMode, + status: tokenTail(value.status, 'scheduled') as EventStatus, + startsAt: value.startsAt ?? value.createdAt, + endsAt: value.endsAt, + cover, + tags: [], + attendees: [], + attendeeCount: 0, + hosts: [], + links, + registerUrl, + talks: [], + } +} + +export default defineEventHandler(async event => { + const url = new URL(`${NPMX_PDS_HOST}/xrpc/com.atproto.repo.listRecords`) + url.searchParams.set('repo', NPMX_EVENTS_DID) + url.searchParams.set('collection', EVENT_COLLECTION) + url.searchParams.set('limit', '100') + + const response = await fetch(url.toString()) + if (!response.ok) { + throw createError({ statusCode: 502, message: 'Failed to load events from the npmx PDS' }) + } + + const data = (await response.json()) as ListRecordsResponse + const events = data.records + .map(record => mapEvent(NPMX_EVENTS_DID, record.value)) + .sort((a, b) => b.startsAt.localeCompare(a.startsAt)) + + setHeader(event, 'cache-control', 's-maxage=300, stale-while-revalidate=3600') + return events +}) diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 738612c2a7..9b22a2e121 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -21,6 +21,7 @@ const pages = [ '/blog', '/brand', '/compare', + '/events', '/noodles', '/sponsors', '/org', diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index d6f82a4d62..923ffe83e5 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -63,6 +63,10 @@ const SKIPPED_COMPONENTS: Record = { 'Translation/StatusByFile.unused.vue': 'Unused component, might be needed in the future', 'ColorScheme/Img.vue': 'Image component, basic ui', 'VideoPlayer.vue': 'Atproto video component, basic ui', + 'Events/Card.vue': 'Presentational event card - a NuxtLink wrapper, no interactive state', + 'Events/TalkRow.vue': 'Presentational talk row - external links only, no interactive state', + 'Events/Gallery.vue': + 'Masonry gallery with a Teleport lightbox, keyboard nav and scroll lock - requires full app context', } function normalizeComponentPath(filePath: string): string { diff --git a/test/unit/events-format.spec.ts b/test/unit/events-format.spec.ts new file mode 100644 index 0000000000..03675cd4d5 --- /dev/null +++ b/test/unit/events-format.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { formatEventDateRange, isPastEvent } from '~/utils/events/format' + +describe('formatEventDateRange', () => { + it('collapses a same-day event to one line with a time range', () => { + const out = formatEventDateRange('2026-07-26T14:00:00Z', '2026-07-26T20:00:00Z', 'en-GB') + expect(out).toMatch(/2026/) + expect(out).toContain('·') + expect(out).toContain('–') + }) + + it('shows a compact day range for a multi-day, same-month event', () => { + const out = formatEventDateRange('2026-07-26T00:00:00Z', '2026-07-27T00:00:00Z', 'en-GB') + // e.g. "26–27 Jul 2026" — no time range, single year/month + expect(out).toMatch(/26.*27/) + expect(out).not.toContain('·') + }) + + it('spans months when needed', () => { + const out = formatEventDateRange('2026-07-30T00:00:00Z', '2026-08-02T00:00:00Z', 'en-GB') + expect(out).toMatch(/Jul/) + expect(out).toMatch(/Aug/) + }) + + it('falls back to a single date when there is no end', () => { + const out = formatEventDateRange('2026-07-26T14:00:00Z', undefined, 'en-GB') + expect(out).not.toContain('–') + }) +}) + +describe('isPastEvent', () => { + const now = new Date('2026-07-31T00:00:00Z') + + it('is past when the end date is before now', () => { + expect( + isPastEvent({ startsAt: '2026-07-26T14:00:00Z', endsAt: '2026-07-26T20:00:00Z' }, now), + ).toBe(true) + }) + + it('is upcoming when the start date is after now', () => { + expect(isPastEvent({ startsAt: '2026-08-10T14:00:00Z' }, now)).toBe(false) + }) +})