project-bifrost-platform/src/admin/resources/events.ts
Jonathan Hvid 4aaf0957dd fix: nine test-note follow-ups
1. Markdown preview in the admin edit panel now re-renders from the
   textarea's current value on every toggle (dynamic-imports marked on
   the client). Previously the panel showed the server-rendered seed
   value forever, so new dispatches always previewed empty.

2. Pulse sub-form drops the opens_at field (opens on dispatch publish
   automatically) and changes closes_at to a date input — the chosen
   day is treated as end-of-day in the DB.

3. /dispatches/[slug] reading width widened 50% (720 → 1080px).

4. Roadmap display_order cascades on insert / update / delete:
   inserting at N bumps N..end up by 1, deleting N pulls N+1..end
   down by 1, moving from A to B shifts the intermediate range by 1
   in the appropriate direction. Order stays dense — no gaps, no
   collisions. All three transitions run in a transaction.

5. /roadmap always anchors at scrollLeft=0 on mount so the first
   milestone aligns with the content-column left edge. Previously
   the page jumped to the last-shipping milestone, which felt random
   once items past the viewport landed.

6. Events admin list shows the actual date (fmtDateTime) instead of
   "in 3 days" — easier to scan when planning across months.

7. duration_label is auto-computed from starts_at + ends_at on save
   (minutes < 90, hours < 4, "Half day", "Full day", "N days").
   The manual field is gone from the admin form; the column on the
   member-facing event pages keeps reading the stored value as before.

8. Pulse hero still skips office hours per the existing logic — no
   change. Confirmed via the test note's clarification.

9. Pulse "also coming up" strip relabeled to Previous + Upcoming.
   Previous = most recent past non-office-hours event. Upcoming =
   next non-office-hours event after the hero. Each card now carries
   a small terracotta eyebrow with the label.

Typecheck clean, build clean, 147/147 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:46:06 +02:00

273 lines
8.3 KiB
TypeScript

/* ---------------------------------------------------------------------------
* 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<Event> = {
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 };