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