From 18d371b36805013bc18635d6115f384d8cc1a463 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 16:38:11 +0200 Subject: [PATCH] feat(admin): activity resource (read-only debug feed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last resource lands. /admin/activity tails the activity table — votes, RSVPs, office-hour bookings, roadmap ships, pulse opens — with last-7-days / last-30-days filters. Pure read view: no form, no summary, no ops. src/admin/components/ResourceListView.astro: rows fall back to
when the resource has no panel pathway (form: null AND no summary). Activity rows aren't clickable now — previously they'd dirty the URL with a ?edit= that resolved to nothing. The registry is complete: 7 resources across 3 groups, matching the sidebar layout described in the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/admin/components/ResourceListView.astro | 20 ++-- src/admin/resources/activity.ts | 102 ++++++++++++++++++++ src/admin/resources/index.ts | 7 +- 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 src/admin/resources/activity.ts diff --git a/src/admin/components/ResourceListView.astro b/src/admin/components/ResourceListView.astro index feae96f..895b69b 100644 --- a/src/admin/components/ResourceListView.astro +++ b/src/admin/components/ResourceListView.astro @@ -95,6 +95,10 @@ function withParams(overrides: Record): string { const showNewButton = resource.form !== null && resource.ops.create !== undefined; const hasItems = allItems.length > 0; const hasMatches = pageItems.length > 0; +// A row is only clickable when the panel has something to render — either an +// editable form or a review summary. Pure debug resources (activity) skip the +// anchor wrapper so clicks don't dirty the URL with a ?edit= that goes nowhere. +const rowsClickable = resource.form !== null || resource.summary !== undefined; ---
@@ -162,19 +166,21 @@ const hasMatches = pageItems.length > 0;
{pageItems.map((item) => { const id = Number(item.id); + const Tag = rowsClickable ? 'a' : 'div'; + const linkProps = rowsClickable + ? { + href: withParams({ edit: id, new: null }), + 'aria-label': `Open ${resource.singularLabel.toLowerCase()} ${id}`, + } + : {}; return ( - + {columns.map((col) => (
))} -
+ ); })} diff --git a/src/admin/resources/activity.ts b/src/admin/resources/activity.ts new file mode 100644 index 0000000..2250737 --- /dev/null +++ b/src/admin/resources/activity.ts @@ -0,0 +1,102 @@ +/* --------------------------------------------------------------------------- + * Activity resource — read-only debug feed. + * + * Activity rows are emitted by side effects elsewhere in the app (voting, + * RSVPs, roadmap-ship transitions, pulse opens). The admin view is a tail + * of the table for monitoring; no create, no edit, no delete. + * ------------------------------------------------------------------------- */ + +import { + getAllActivityForAdmin, + type ActivityKind, + type ActivityRow, +} from '../../lib/db'; +import type { Resource } from '../resource-types'; + +const KIND_LABEL: Record = { + voted: 'Voted', + rsvped: 'RSVPed', + booked_office_hours: 'Booked office hours', + roadmap_shipped: 'Roadmap shipped', + pulse_opened: 'Pulse opened', +}; + +const KIND_PILL_CLASS: Record = { + voted: 'pill-update', + rsvped: 'pill-published', + booked_office_hours: 'pill-bts', + roadmap_shipped: 'pill-shipping', + pulse_opened: 'pill-pending', +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const activityResource: Resource = { + key: 'activity', + label: 'Activity', + pluralLabel: 'Activity', + singularLabel: 'Event', + groupKey: 'system', + description: 'Recent member actions: votes, RSVPs, office-hour bookings, pulse opens, roadmap ships.', + + list: { + queryFn: () => getAllActivityForAdmin(200), + columns: [ + { + key: 'actor_name', + label: 'Actor', + primary: true, + width: '1.5fr', + render: (item) => ({ + title: item.actor_name, + subtitle: item.actor_role, + }), + }, + { + key: 'kind', + label: 'Kind', + kind: 'pill', + width: '160px', + pillVariants: Object.fromEntries( + (Object.keys(KIND_LABEL) as ActivityKind[]).map((k) => [ + k, + { label: KIND_LABEL[k], class: KIND_PILL_CLASS[k] }, + ]), + ), + }, + { + key: 'subject', + label: 'Subject', + width: '1fr', + render: (item) => ({ + title: `${item.subject_type} #${item.subject_id}`, + }), + }, + { + key: 'created_at', + label: 'When', + kind: 'relative-date', + width: '120px', + }, + ], + filters: [ + { key: 'all', label: 'All', predicate: () => true, isDefault: true }, + { + key: 'last_7_days', + label: 'Last 7 days', + predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 7 * DAY_MS, + }, + { + key: 'last_30_days', + label: 'Last 30 days', + predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 30 * DAY_MS, + }, + ], + defaultSort: { key: 'created_at', direction: 'desc' }, + pageSize: 100, + }, + + // Pure read view — no form, no summary, no ops, no actions. + form: null, + ops: {}, +}; diff --git a/src/admin/resources/index.ts b/src/admin/resources/index.ts index 8522c20..8034f25 100644 --- a/src/admin/resources/index.ts +++ b/src/admin/resources/index.ts @@ -12,6 +12,7 @@ import { eventsResource } from './events'; import { usersResource } from './users'; import { invitationsResource } from './invitations'; import { joinRequestsResource } from './join-requests'; +import { activityResource } from './activity'; export const groups: ResourceGroup[] = [ { @@ -24,5 +25,9 @@ export const groups: ResourceGroup[] = [ label: 'The council', resources: [usersResource, invitationsResource, joinRequestsResource], }, - { key: 'system', label: 'System', resources: [] }, + { + key: 'system', + label: 'System', + resources: [activityResource], + }, ];