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:
parent
38db772b4f
commit
bbb5ad2bdd
7 changed files with 263 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
12
server/db.ts
12
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue