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

@ -124,6 +124,70 @@ describe('hearts', () => {
});
});
describe('"gjort" (done) marks', () => {
ttest('toggle on a public activity, idempotent both ways', async () => {
const [owner, viewer] = await Promise.all([
signupAndGetCookie(ctx, 'done-owner@test.invalid'),
signupAndGetCookie(ctx, 'done-viewer@test.invalid'),
]);
const pub = await createActivity(ctx, owner.cookie, {
visibility: 'public', title: 'Gjort-test', tags: [],
});
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(after.done_count).toBe(1);
expect(after.viewer_done).toBe(true);
// Re-mark is idempotent (INSERT OR IGNORE).
const again = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(again.done_count).toBe(1);
// Owner's own view shows the count too but their viewer_done is still false.
const list = await listActivities(ctx, owner.cookie);
const ownerView = list.find((a) => a.id === pub.id);
expect(ownerView?.done_count).toBe(1);
expect(ownerView?.viewer_done).toBe(false);
// Undo.
const undone = await reqJson<Activity>(ctx, 'DELETE', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(undone.done_count).toBe(0);
expect(undone.viewer_done).toBe(false);
});
ttest('owner can mark their own private activity done; non-owners get 404', async () => {
const [owner, other] = await Promise.all([
signupAndGetCookie(ctx, 'priv-done-owner@test.invalid'),
signupAndGetCookie(ctx, 'priv-done-other@test.invalid'),
]);
// Private rows need a ciphertext/nonce payload — we don't actually
// decrypt in this test, just need the row to exist.
const priv = await createActivity(ctx, owner.cookie, {
visibility: 'private',
ciphertext: 'AAAA',
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 24 bytes base64
} as never);
// Owner can mark it done.
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${priv.id}/done`, {
cookie: owner.cookie,
});
expect(after.done_count).toBe(1);
expect(after.viewer_done).toBe(true);
// Non-owner gets 404 (same as GET /:id behaviour — doesn't leak existence).
const denied = await req(ctx, 'POST', `/api/activities/${priv.id}/done`, {
cookie: other.cookie,
});
expect(denied.status).toBe(404);
});
});
describe('bookmarks', () => {
ttest('toggle, idempotent, refused on private', async () => {
const [owner, viewer, otherViewer] = await Promise.all([