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) =>

View file

@ -79,6 +79,20 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
.get(activityId, viewerId);
}
/** Done-count + viewer-done lookup for a single activity. */
function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } {
const db = getDb();
const count = (db
.prepare('SELECT COUNT(*) AS n FROM activity_done WHERE activity_id = ?')
.get(activityId) as { n: number }).n;
const done = viewerId
? !!db
.prepare('SELECT 1 FROM activity_done WHERE activity_id = ? AND user_id = ?')
.get(activityId, viewerId)
: false;
return { count, done };
}
/**
* Build the public-facing attribution for an owner. Prefer the user's chosen
* `display_name`; fall back to their `username` slug if set (also user-chosen);
@ -125,6 +139,7 @@ function b64ToBuf(s: string): Buffer {
interface BulkLookups {
tags: Map<string, string[]>;
hearts: Map<string, { count: number; hearted: boolean }>;
done: Map<string, { count: number; done: boolean }>;
bookmarked: Set<string>;
attribution: Map<string, { display: string | null; username: string | null }>;
}
@ -134,10 +149,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
const hearts = new Map<string, { count: number; hearted: boolean }>();
const done = new Map<string, { count: number; done: boolean }>();
const bookmarked = new Set<string>();
const attribution = new Map<string, { display: string | null; username: string | null }>();
if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution };
const ph = ids.map(() => '?').join(',');
const heartCounts = db
@ -161,6 +177,27 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
hearts.set(r.activity_id, { count: r.n, hearted: viewerHeartSet.has(r.activity_id) });
}
const doneCounts = db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM activity_done
WHERE activity_id IN (${ph})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[];
const viewerDoneSet = new Set<string>();
if (viewerId) {
const vd = db
.prepare(`
SELECT activity_id FROM activity_done
WHERE activity_id IN (${ph}) AND user_id = ?
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of vd) viewerDoneSet.add(r.activity_id);
}
for (const r of doneCounts) {
done.set(r.activity_id, { count: r.n, done: viewerDoneSet.has(r.activity_id) });
}
if (viewerId) {
const bm = db
.prepare(`
@ -194,7 +231,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
}
}
return { tags, hearts, bookmarked, attribution };
return { tags, hearts, done, bookmarked, attribution };
}
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
@ -202,6 +239,11 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
// otherwise -created_at so unsorted rows float to the top by recency.
// Matches the SQL ORDER BY in the list query.
const sortPos = row.sort_position ?? -row.created_at;
// Done state applies to all visibilities. For private rows the count is
// at most 1 (only the owner can mark it) and acts as a personal checkbox.
const done = bulk
? (bulk.done.get(row.id) ?? { count: 0, done: false })
: doneFor(row.id, viewerId);
if (row.visibility === 'private') {
const a: ActivityPrivate = {
id: row.id,
@ -213,6 +255,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: 0,
viewer_hearted: false,
viewer_bookmarked: false,
done_count: done.count,
viewer_done: done.done,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -243,6 +287,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -274,6 +320,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -295,6 +343,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -602,6 +652,61 @@ activitiesRoutes.delete('/:id/heart', requireAuth, (c) => toggleMark(c, 'heart'
activitiesRoutes.post('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'add'));
activitiesRoutes.delete('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'remove'));
/**
* "Gjort" / done toggle. Differs from heart/bookmark:
* - Private rows are allowed (owner-only, acts as a personal checkbox).
* - Friends-only rows require the viewer to be the owner OR a mutual
* friend with no block in either direction.
* Same visibility rules as GET /api/activities/:id we treat
* "can't see it → 404" the same way.
*/
function toggleDone(c: AppContext, op: 'add' | 'remove') {
const userId = c.get('userId');
const id = c.req.param('id');
if (!id) return c.json({ error: 'not_found' }, 404);
const db = getDb();
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility filter as the list and single-fetch endpoints.
// Hidden rows return 404 (not 403) so the endpoint doesn't double as an
// existence oracle.
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
if (op === 'add') {
db.prepare(
'INSERT OR IGNORE INTO activity_done (activity_id, user_id, created_at) VALUES (?, ?, ?)',
).run(id, userId, Date.now());
} else {
db.prepare('DELETE FROM activity_done WHERE activity_id = ? AND user_id = ?').run(id, userId);
}
const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add'));
activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove'));
// --- DELETE /api/activities/:id ---------------------------------------------
// Authz:
// - private: owner only. Other users can't even see private rows, so

View file

@ -119,6 +119,18 @@ const SCHEMA_STATEMENTS: readonly string[] = [
PRIMARY KEY (activity_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
// "Gjort": per-user completion mark. Same shape as hearts but a different
// meaning — hearts express approval, gjort expresses "I actually did
// this." Unlike hearts/bookmarks, gjort applies to ANY visibility the
// viewer can see, including their own private activities (where it
// doubles as a todo-list checkbox).
`CREATE TABLE IF NOT EXISTS activity_done (
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (activity_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`,
// Bookmarks: logged-in users can save public/semi activities to their own
// dashboard. Same shape as hearts: composite PK on (user, activity), one
// row per bookmark. CASCADE on the activity so deletes clean up.

View file

@ -51,22 +51,24 @@ usersRoutes.get('/:username/list', (c) => {
`)
.all(user.id) as ActivityRow[];
// Bulk lookups so we make 2 queries instead of 2N. The endpoint is public,
// so viewer_hearted/bookmarked are always false — no per-viewer queries.
// Bulk lookups so we make 3 queries instead of 3N. The endpoint is public,
// so viewer_hearted/bookmarked/done are always false — no per-viewer queries.
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
const heartCounts = ids.length === 0
? new Map<string, number>()
: new Map<string, number>(
(db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM activity_hearts
WHERE activity_id IN (${ids.map(() => '?').join(',')})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[]
).map((r) => [r.activity_id, r.n]),
);
const bulkCounts = (table: string): Map<string, number> => {
if (ids.length === 0) return new Map();
const ph = ids.map(() => '?').join(',');
const rows = db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM ${table}
WHERE activity_id IN (${ph})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[];
return new Map(rows.map((r) => [r.activity_id, r.n]));
};
const heartCounts = bulkCounts('activity_hearts');
const doneCounts = bulkCounts('activity_done');
const activities: ActivityPublic[] = rows.map((r) => ({
id: r.id,
@ -87,9 +89,11 @@ usersRoutes.get('/:username/list', (c) => {
scheduled_at: r.scheduled_at,
heart_count: heartCounts.get(r.id) ?? 0,
// The public-list endpoint is unauthenticated; we don't know who the
// viewer is to fill viewer_hearted/bookmarked truthfully. Always false.
// viewer is to fill viewer_hearted/bookmarked/done truthfully. Always false.
viewer_hearted: false,
viewer_bookmarked: false,
done_count: doneCounts.get(r.id) ?? 0,
viewer_done: false,
// No personal sort here — anonymous view always sorts by recency.
sort_position: -r.created_at,
created_at: r.created_at,

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. */

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([