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>
273 lines
8.3 KiB
TypeScript
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 };
|