diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index 1ba08f6..a550dce 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -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 @@
♡ {view.heart_count}
{/if}
{/if}
+
+ {#if session.user}
+
+ {:else if view.visibility !== 'private' && view.done_count > 0}
+ ✓ {view.done_count}
+ {/if}
{#if canEdit && onEdit}
{/if}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c5fde72..523e1ba 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -79,6 +79,10 @@ export const api = {
http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
+ doneActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
+ undoneActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>
diff --git a/server/activities.ts b/server/activities.ts
index 985ba7d..b9d7aa5 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -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;
hearts: Map;
+ done: Map;
bookmarked: Set;
attribution: Map;
}
@@ -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();
+ const done = new Map();
const bookmarked = new Set();
const attribution = new Map();
- 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();
+ 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
diff --git a/server/db.ts b/server/db.ts
index 5fdaac0..614ceb1 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -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.
diff --git a/server/users.ts b/server/users.ts
index 17ac796..a327f87 100644
--- a/server/users.ts
+++ b/server/users.ts
@@ -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()
- : new Map(
- (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 => {
+ 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,
diff --git a/shared/types.ts b/shared/types.ts
index 9d79426..21634d7 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -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. */
diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts
index 150c0ff..fc2a1d1 100644
--- a/tests/engagement.test.ts
+++ b/tests/engagement.test.ts
@@ -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(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(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(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(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([