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

@ -152,6 +152,33 @@
}
}
// --- "Gjort" (completion mark) ------------------------------------------
// Works on EVERY visibility the viewer can see. For private rows it acts
// as a personal checkbox; for public/semi/friends it contributes to the
// visible done_count statistic.
let doneBusy = $state(false);
async function toggleDone() {
if (!session.user || doneBusy) return;
doneBusy = true;
const wasDone = view.viewer_done;
localOverride = {
...view,
viewer_done: !wasDone,
done_count: view.done_count + (wasDone ? -1 : 1),
};
try {
const updated = wasDone
? await api.undoneActivity(view.id)
: await api.doneActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
doneBusy = false;
}
}
// --- Bookmarks -----------------------------------------------------------
let bookmarkBusy = $state(false);
async function toggleBookmark() {
@ -327,6 +354,23 @@
<span class="muted" aria-label="Antall hjerter">{view.heart_count}</span>
{/if}
{/if}
<!-- "Gjort" works on every visibility — including the owner's private
rows where it's a personal todo checkbox. -->
{#if session.user}
<button
type="button"
onclick={toggleDone}
disabled={doneBusy}
aria-pressed={view.viewer_done}
aria-label={view.viewer_done ? 'Marker som ikke gjort' : 'Marker som gjort'}
title={view.viewer_done ? 'Du har gjort dette' : 'Dette har jeg gjort'}
class={view.viewer_done ? 'primary' : ''}
>
{view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
</button>
{:else if view.visibility !== 'private' && view.done_count > 0}
<span class="muted" aria-label="Antall som har gjort dette">{view.done_count}</span>
{/if}
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}

View file

@ -79,6 +79,10 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
doneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>