feat(activity): "Gjort" mark with statistics

Per-user "I've done this" toggle alongside hearts and bookmarks.
Hearts express approval; gjort expresses completion. Both contribute
to public statistics so readers can see what people LIKE versus what
people actually DO.

Backend:
- New activity_done table (composite PK on activity_id + user_id,
  CASCADE on both refs, mirrors activity_hearts).
- POST/DELETE /api/activities/:id/done. Unlike heart/bookmark, "gjort"
  works on every visibility the viewer can see — private (owner-only,
  acts as a personal todo checkbox), friends-only (mutual-friend +
  no-block check, mirrors GET /:id), public, semi. Non-viewers get
  404 to avoid leaking existence.
- buildBulkLookups + serialize extended with done_count + viewer_done
  so the list endpoint stays at constant queries per render.
- Public-list endpoint (server/users.ts) bulk-fetches done counts
  alongside heart counts; viewer_done is always false (unauth view).

Types: Activity{Public,Semi,Private,Friends} all gain done_count +
viewer_done. Private's count is at most 1 (only the owner can write).

UI: new "✓ Gjort" / "☐ Gjort" button in the action row with the same
optimistic-toggle + localOverride pattern as hearts. Anonymous viewers
on public activities see a muted "✓ N" stat. Title hint clarifies
the intent: "Dette har jeg gjort" vs "Du har gjort dette."

Tests: 2 new in engagement.test.ts — toggle + idempotency on public,
owner-only access on private (non-owner gets 404).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 19:00:26 +02:00
commit bbb5ad2bdd
7 changed files with 263 additions and 17 deletions

View file

@ -219,6 +219,10 @@ export interface ActivityPublic {
viewer_hearted: boolean;
/** True when the authenticated viewer has bookmarked this activity. */
viewer_bookmarked: boolean;
/** Total "done" marks (people who have actually completed the activity). */
done_count: number;
/** True when the authenticated viewer has marked this activity done. */
viewer_done: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -244,6 +248,8 @@ export interface ActivitySemi {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
done_count: number;
viewer_done: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -264,6 +270,11 @@ export interface ActivityPrivate {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
// "Done" DOES apply to private rows: the owner can use it as a personal
// todo checkbox. done_count is therefore always 0 or 1 (just the owner)
// for private rows. viewer_done reflects the owner's own state.
done_count: number;
viewer_done: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -294,6 +305,8 @@ export interface ActivityFriends {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
done_count: number;
viewer_done: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */