/* --------------------------------------------------------------------------- * Events resource. * * Slug auto-generates from title on create when blank; on edit it's a regular * editable text field (changing it breaks any external links — admin's call). * ------------------------------------------------------------------------- */ import { createEvent, updateEvent, deleteEvent, getEventById, getEventBySlug, getAllEvents, getEventRsvpCount, type Event, type EventKind, } from '../../lib/db'; import { eventKindLabel } from '../../lib/format'; import { fmtDateTime } from '../../lib/markdown'; import type { Resource } from '../resource-types'; function slugify(s: string): string { return s .toLowerCase() .normalize('NFKD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function uniqueEventSlug(base: string): string { let slug = base || 'event'; let n = 1; while (getEventBySlug(slug)) { n += 1; slug = `${base}-${n}`; } return slug; } function toSqliteDatetime(s: string): string { if (!s) return ''; return s.replace('T', ' ') + (s.length === 16 ? ':00' : ''); } /** Compute a human duration label from start + end SQLite datetimes. * Returns null when ends_at is missing (open-ended event). */ function computeDurationLabel(startsAt: string, endsAt: string | null): string | null { if (!endsAt) return null; const start = new Date(startsAt.replace(' ', 'T') + 'Z').getTime(); const end = new Date(endsAt.replace(' ', 'T') + 'Z').getTime(); const ms = end - start; if (!Number.isFinite(ms) || ms <= 0) return null; const minutes = Math.round(ms / 60_000); if (minutes < 90) return `${minutes} min`; const hours = ms / 3_600_000; if (hours < 4) { const rounded = Math.round(hours * 2) / 2; // nearest half hour return Number.isInteger(rounded) ? `${rounded} hr` : `${rounded} hr`; } if (hours < 7) return 'Half day'; if (hours < 10) return 'Full day'; const days = Math.round(hours / 24); return days === 1 ? '1 day' : `${days} days`; } export const eventsResource: Resource = { key: 'events', label: 'Events', pluralLabel: 'Events', singularLabel: 'Event', groupKey: 'publishing', description: 'Gatherings, dinners, virtual sessions — anything that shows up at /events.', publicRoutePattern: (item) => `/events/${item.slug}`, list: { queryFn: () => getAllEvents(), columns: [ { key: 'title', label: 'Title', primary: true, width: '2fr', render: (item) => ({ title: item.title, subtitle: item.location, }), }, { key: 'kind', label: 'Kind', kind: 'pill', width: '140px', pillVariants: { dinner: { label: 'Dinner', class: 'pill-decision' }, office_hours: { label: 'Studio hours', class: 'pill-update' }, summit: { label: 'Summit', class: 'pill-note' }, virtual: { label: 'Virtual', class: 'pill-bts' }, working_session: { label: 'Working session', class: 'pill-considering' }, }, }, { key: 'starts_at', label: 'Date', width: '180px', render: (item) => ({ title: item.starts_at ? fmtDateTime(item.starts_at) : '—', }), }, { key: 'capacity', label: 'Capacity', kind: 'number', width: '90px', }, ], filters: [ { key: 'all', label: 'All', predicate: () => true, isDefault: true, }, { key: 'upcoming', label: 'Upcoming', predicate: (i) => new Date(i.starts_at).getTime() >= Date.now(), }, { key: 'past', label: 'Past', predicate: (i) => new Date(i.starts_at).getTime() < Date.now(), }, ], search: { placeholder: 'Search by title or location…', fields: ['title', 'location', 'description'], }, defaultSort: { key: 'starts_at', direction: 'desc' }, pageSize: 25, }, form: { fields: [ { key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 }, { key: 'slug', label: 'Slug', kind: 'text', maxLength: 80, helperText: 'URL path under /events/. Leave blank on create to auto-generate from the title.', }, { key: 'kind', label: 'Kind', kind: 'select', required: true, options: (['dinner', 'office_hours', 'summit', 'virtual', 'working_session'] as EventKind[]).map( (k) => ({ value: k, label: eventKindLabel(k) }), ), defaultValue: 'dinner', }, { key: 'description', label: 'Description', kind: 'textarea', rows: 5, required: true, }, { key: 'location', label: 'Location', kind: 'text', required: true, maxLength: 200, helperText: 'Address, room, or video link.', }, { key: 'starts_at', label: 'Starts at', kind: 'datetime', required: true }, { key: 'ends_at', label: 'Ends at', kind: 'datetime' }, { key: 'capacity', label: 'Capacity', kind: 'number', min: 0, max: 999, helperText: 'Leave blank for uncapped.', }, { key: 'audience', label: 'Audience', kind: 'text', maxLength: 200, helperText: 'Free-form note about who the event is for (e.g. "Council members only").', }, { key: 'action_label', label: 'Action button label', kind: 'text', maxLength: 40, helperText: 'Override the default per-kind CTA (e.g. "Reserve your table").', }, { key: 'photo_url', label: 'Photo URL', kind: 'text', maxLength: 400, helperText: 'Optional hero image for the event detail page.', }, { key: 'notes_url', label: 'Notes URL', kind: 'text', maxLength: 400, helperText: 'Optional link to event notes published after the gathering.', }, ], }, ops: { getById: (id) => getEventById(id), create: (data, ctx) => { const rawSlug = ((data.slug as string) ?? '').trim(); const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title))); const startsAt = toSqliteDatetime(String(data.starts_at)); const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null; return createEvent({ slug, title: String(data.title), kind: data.kind as EventKind, description: String(data.description), location: String(data.location), starts_at: startsAt, ends_at: endsAt, capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity), photo_url: ((data.photo_url as string) ?? '').trim() || null, audience: ((data.audience as string) ?? '').trim() || null, duration_label: computeDurationLabel(startsAt, endsAt), action_label: ((data.action_label as string) ?? '').trim() || null, notes_url: ((data.notes_url as string) ?? '').trim() || null, created_by: ctx.user.id, }); }, update: (id, data) => { const startsAt = toSqliteDatetime(String(data.starts_at)); const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null; updateEvent(id, { title: String(data.title), kind: data.kind as EventKind, description: String(data.description), location: String(data.location), starts_at: startsAt, ends_at: endsAt, capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity), photo_url: ((data.photo_url as string) ?? '').trim() || null, audience: ((data.audience as string) ?? '').trim() || null, duration_label: computeDurationLabel(startsAt, endsAt), action_label: ((data.action_label as string) ?? '').trim() || null, notes_url: ((data.notes_url as string) ?? '').trim() || null, }); }, delete: (id) => deleteEvent(id), }, }; // Internal use, not exported on the resource — used by future row subtitles // if we want RSVP counts in the list view. Left here as a marker. export { getEventRsvpCount };