Hearts on activities, feedback triage by admins, click-to-permalink

Three small features and one UX bug.

1. **Hearts.** New `activity_hearts(activity_id, user_id, created_at)`
   table with composite PK. POST/DELETE /api/activities/:id/heart for
   logged-in users on any non-private activity. Idempotent — re-posting
   is a no-op rather than a 409.

   Activity serialisation now includes `heart_count` and
   `viewer_hearted` on all three visibility variants (private is always
   0/false; hearts make no sense there). ActivityRow renders a toggle
   button with optimistic updates and a local override that snaps back
   on network error, then propagates the server's authoritative state
   to the parent list via a new `onChanged` callback.

2. **Admin can triage feedback.** Added `done_at` + `done_by` columns
   to feedback (migrated via ensureColumn for older scaffold DBs). New
   admin-only endpoints:
     PATCH  /api/feedback/:id    — { done: boolean }; sets/clears done_at
     DELETE /api/feedback/:id    — drops the entry
   The Feedback list view sorts open requests above done ones, and
   admins see "Marker som ferdig" / "Marker som åpen" / "Slett" buttons
   per entry. Open/done badges visible to everyone with read access
   (i.e., moderators).

3. **Clicking an activity opens its permalink.** Activity titles in
   ActivityRow are now anchor links to /a/:id, so clicking the title
   navigates to the permalink view. Action buttons (heart, edit,
   delete, del/copy-link) stay inside the card; the anchor only wraps
   the title text, so taps elsewhere don't navigate.

Bundle gained 1 KB gzipped (30.2 → 31.2 KB). 26 tests still pass,
typecheck clean.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:33:51 +02:00
commit 5c9455c3f3
9 changed files with 305 additions and 31 deletions

View file

@ -123,6 +123,10 @@ export interface FeedbackEntry {
kind: FeedbackKind;
body: string;
created_at: number;
/** Set when an admin has marked the entry as done. */
done_at: number | null;
/** User id of the admin who marked it done; null when done_at is null. */
done_by: string | null;
// Moderator-only fields; included when the caller is a moderator viewing
// the list. (The submit endpoint doesn't return these — a submitter doesn't
// need to see them.)
@ -131,6 +135,10 @@ export interface FeedbackEntry {
user_display?: string | null;
}
export interface FeedbackUpdateRequest {
done: boolean;
}
// --- Activities --------------------------------------------------------------
export interface ActivityPublic {
id: string;
@ -146,6 +154,10 @@ export interface ActivityPublic {
loc_lat: number | null;
loc_lng: number | null;
scheduled_at: number | null;
/** Total hearts on this activity. */
heart_count: number;
/** True when the authenticated viewer has hearted this activity. */
viewer_hearted: boolean;
created_at: number;
updated_at: number;
}
@ -163,6 +175,8 @@ export interface ActivitySemi {
loc_lat: number | null;
loc_lng: number | null;
scheduled_at: number | null;
heart_count: number;
viewer_hearted: boolean;
created_at: number;
updated_at: number;
}
@ -173,6 +187,11 @@ export interface ActivityPrivate {
owner_id: string; // always you — server only returns your private rows
ciphertext: string; // base64
nonce: string; // base64
// Always 0 / false for private rows — hearts don't apply (nobody else sees
// them). Kept in the type so the client doesn't need a discriminator check
// before reading the field.
heart_count: number;
viewer_hearted: boolean;
created_at: number;
updated_at: number;
}