feat(admin): activity resource (read-only debug feed)
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 <div> 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) <noreply@anthropic.com>
This commit is contained in:
parent
e9a986d484
commit
18d371b368
3 changed files with 121 additions and 8 deletions
|
|
@ -95,6 +95,10 @@ function withParams(overrides: Record<string, string | number | null>): string {
|
||||||
const showNewButton = resource.form !== null && resource.ops.create !== undefined;
|
const showNewButton = resource.form !== null && resource.ops.create !== undefined;
|
||||||
const hasItems = allItems.length > 0;
|
const hasItems = allItems.length > 0;
|
||||||
const hasMatches = pageItems.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;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="bs-list">
|
<section class="bs-list">
|
||||||
|
|
@ -162,19 +166,21 @@ const hasMatches = pageItems.length > 0;
|
||||||
</div>
|
</div>
|
||||||
{pageItems.map((item) => {
|
{pageItems.map((item) => {
|
||||||
const id = Number(item.id);
|
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 (
|
return (
|
||||||
<a
|
<Tag class="bs-grid-row" role="row" {...linkProps}>
|
||||||
href={withParams({ edit: id, new: null })}
|
|
||||||
class="bs-grid-row"
|
|
||||||
role="row"
|
|
||||||
aria-label={`Edit ${resource.singularLabel.toLowerCase()} ${id}`}
|
|
||||||
>
|
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<div class="bs-grid-td" role="cell">
|
<div class="bs-grid-td" role="cell">
|
||||||
<ListCell column={col} item={item} />
|
<ListCell column={col} item={item} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</a>
|
</Tag>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
102
src/admin/resources/activity.ts
Normal file
102
src/admin/resources/activity.ts
Normal file
|
|
@ -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<ActivityKind, string> = {
|
||||||
|
voted: 'Voted',
|
||||||
|
rsvped: 'RSVPed',
|
||||||
|
booked_office_hours: 'Booked office hours',
|
||||||
|
roadmap_shipped: 'Roadmap shipped',
|
||||||
|
pulse_opened: 'Pulse opened',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_PILL_CLASS: Record<ActivityKind, string> = {
|
||||||
|
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<ActivityRow> = {
|
||||||
|
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: {},
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,7 @@ import { eventsResource } from './events';
|
||||||
import { usersResource } from './users';
|
import { usersResource } from './users';
|
||||||
import { invitationsResource } from './invitations';
|
import { invitationsResource } from './invitations';
|
||||||
import { joinRequestsResource } from './join-requests';
|
import { joinRequestsResource } from './join-requests';
|
||||||
|
import { activityResource } from './activity';
|
||||||
|
|
||||||
export const groups: ResourceGroup[] = [
|
export const groups: ResourceGroup[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -24,5 +25,9 @@ export const groups: ResourceGroup[] = [
|
||||||
label: 'The council',
|
label: 'The council',
|
||||||
resources: [usersResource, invitationsResource, joinRequestsResource],
|
resources: [usersResource, invitationsResource, joinRequestsResource],
|
||||||
},
|
},
|
||||||
{ key: 'system', label: 'System', resources: [] },
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: 'System',
|
||||||
|
resources: [activityResource],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue