From 8295a35c94477349023236999dd56ef2db38ad6b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 16:47:55 +0200 Subject: [PATCH] Drag-and-drop unified activity list with per-user sort order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home dashboard's five sections (Bokmerker / Dine private / Venner / Anonyme / Offentlige) collapse into one ordered list. Each row is identified by its visibility badge plus an optional "★ Bokmerket" badge — the meaning stays clear, the layout gets much tighter, and reordering across visibility levels becomes a single drag. Per-viewer ordering, sparsely stored: Schema (additive): - user_activity_sort(user_id, activity_id, position REAL) composite PK; ON DELETE CASCADE both ways Sort math: the list query LEFT JOINs the table per-viewer and orders by COALESCE(custom_position, -activity.created_at). Rows without a custom position sort by -created_at (very negative for recent activity, very less-negative for old) so new and untouched activities float to the top in newest-first order. Once dragged, the row carries a real float position that the listing query uses instead. Sort endpoint: PATCH /api/activities/:id/sort body: { position: number } ON CONFLICT UPDATE so re-dragging the same row is cheap and doesn't accumulate rows. Wire: every activity variant now carries `sort_position: number` — the effective position the server used (custom or -created_at). The client uses it to compute midpoint positions on drop without needing to know the formula. Frontend: - Home.svelte renders one list ordered by sort_position. Search filter still works across the unified list. - ActivityRow.svelte gains a drag-handle button (only rendered when the parent passes draggable=true; off on the public landing and on /a/:id, //list, /tags/:tag). - ActivityRow.svelte gains a "★ Bokmerket" vis-badge alongside the visibility badge so the marker is consistent with the other status pills. - Home computes the drop's new position as the midpoint between neighbours (or top/bottom + 1.0 at the edges), updates the local list optimistically, then PATCHes the server. Snapping back on failure. Touch DnD is currently not supported — HTML5 native DnD doesn't work on touch. Adding a polyfill is a separate concern (the user explicitly asked for drag-and-drop; can revisit for mobile later). Regression test in tests/activities.test.ts covers: - default order is newest-first - a custom position via PATCH /sort moves a row - ordering is per-viewer (A's drag doesn't affect B's list) - a fresh activity created after a custom position floats above the user's custom-positioned rows (because -created_at is much more negative than any reasonable custom position float) - 401 without auth - 400 on missing or non-finite position 96 tests pass total; typecheck clean; build ok. --- frontend/src/components/ActivityRow.svelte | 39 +++- frontend/src/components/Home.svelte | 222 ++++++++++++--------- frontend/src/lib/api.ts | 4 + frontend/src/styles.css | 32 +++ server/activities.ts | 62 +++++- server/db.ts | 13 ++ server/users.ts | 2 + shared/types.ts | 16 ++ tests/activities.test.ts | 73 ++++++- 9 files changed, 361 insertions(+), 102 deletions(-) diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 00cf323..b103cdc 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -18,8 +18,18 @@ /** Called when the row updates its own activity (e.g. after a heart * toggle). The parent should patch its list so the change persists. */ onChanged?: (a: Activity) => void; + /** Whether to render a drag handle that initiates HTML5 DnD on this row. + * Parent owns the actual dragstart/dragend wiring — the handle just + * forwards events upward. */ + draggable?: boolean; + onDragHandleStart?: (e: DragEvent) => void; + onDragHandleEnd?: () => void; } - let { activity, privateCleartext = null, onDeleted, onEdit, onChanged }: Props = $props(); + let { + activity, privateCleartext = null, + onDeleted, onEdit, onChanged, + draggable = false, onDragHandleStart, onDragHandleEnd, + }: Props = $props(); // Fallback decrypt for the case where Home hasn't pre-computed yet (e.g. // immediately after a create) — we tolerate redundant work to keep the @@ -192,7 +202,23 @@

{/snippet} -
+
+ {#if draggable} + + + {/if} +
{#if activity.visibility === 'private'} {#if decrypted}

@@ -200,6 +226,11 @@ {decrypted.title} Privat + + {#if activity.viewer_bookmarked} + ★ Bokmerket + {/if}

{#if decrypted.description}

{decrypted.description}

@@ -233,6 +264,9 @@ Offentlig {/if} + {#if activity.viewer_bookmarked} + ★ Bokmerket + {/if} {#if activity.description}

{activity.description}

@@ -299,4 +333,5 @@ {copiedAt ? 'Kopiert!' : 'Del'}
+
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index f19bf4b..2882101 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -47,8 +47,6 @@ } function onChanged(a: Activity) { - // In-place patch (no editing context change). Used by row-level actions - // like hearts. activities = activities.map((x) => (x.id === a.id ? a : x)); } @@ -56,11 +54,7 @@ activities = activities.filter((a) => a.id !== id); } - /** - * Pre-decrypt private payloads once per activity so search can match against - * the cleartext. We can't avoid this — search has to see what the user sees. - * The cache lives in JS memory alongside the DEK and is dropped on logout. - */ + /** Pre-decrypt private payloads once per render so search can match. */ const privateCleartext = $derived.by(() => { const map = new Map(); if (!session.dek) return map; @@ -71,9 +65,7 @@ { ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) }, session.dek, )); - } catch { - // skip — the row will show an error inline - } + } catch { /* skip */ } } return map; }); @@ -96,27 +88,100 @@ ].some((s) => s.toLowerCase().includes(needle)); } + // The list is now unified — one column, sorted by sort_position. The + // server's COALESCE(custom, -created_at) handles both manually-positioned + // and not-yet-touched rows. Bookmark/friends/private/semi/public are all + // intermingled; their vis-badges tell the viewer which is which. const filtered = $derived( activities .filter((a) => !publicOnly || a.visibility !== 'private') - .filter((a) => matchesQuery(a, query)), + .filter((a) => matchesQuery(a, query)) + .slice() + .sort((a, b) => a.sort_position - b.sort_position), ); - // Split into sections. Bookmarks float to the top (only for logged-in users - // looking at their full dashboard — public landing skips them since the - // viewer_bookmarked field is always false there). The bookmark itself - // doesn't remove the activity from its visibility section — the same row - // appears once in "Bokmerker" AND once in its semi/public section so you - // can still find it where you'd expect. - const bookmarked = $derived( - publicOnly || !session.user - ? [] - : filtered.filter((a) => a.visibility !== 'private' && a.viewer_bookmarked), - ); - const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private')); - const friends = $derived(filtered.filter((a) => a.visibility === 'friends')); - const semi = $derived(filtered.filter((a) => a.visibility === 'semi')); - const pub = $derived(filtered.filter((a) => a.visibility === 'public')); + // --- Drag-and-drop reordering -------------------------------------------- + // HTML5 native DnD. Drag handle is on each row; only logged-in users can + // drag (anonymous list is read-only). Touch users currently can't drag + // — adding a touch polyfill is a separate concern. + let dragId: string | null = $state(null); + let dragOverId: string | null = $state(null); + let dragOverHalf: 'above' | 'below' | null = $state(null); + + function onDragStart(id: string, e: DragEvent) { + if (!session.user) { e.preventDefault(); return; } + dragId = id; + e.dataTransfer?.setData('text/plain', id); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + } + function onDragOver(targetId: string, e: DragEvent) { + if (!dragId || dragId === targetId) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const t = e.currentTarget as HTMLElement; + const rect = t.getBoundingClientRect(); + dragOverId = targetId; + dragOverHalf = e.clientY - rect.top < rect.height / 2 ? 'above' : 'below'; + } + function onDragLeaveRow(targetId: string) { + if (dragOverId === targetId) { + dragOverId = null; + dragOverHalf = null; + } + } + function onDragEnd() { + dragId = null; + dragOverId = null; + dragOverHalf = null; + } + + async function onDrop(targetId: string, e: DragEvent) { + e.preventDefault(); + const src = dragId; + const half = dragOverHalf; + onDragEnd(); + if (!src || src === targetId || !half) return; + + // Compute the new position via midpoint between neighbours of the drop + // slot. The current `filtered` is the on-screen order; find the target's + // index and adjust. + const idx = filtered.findIndex((a) => a.id === targetId); + if (idx < 0) return; + const target = filtered[idx]!; + let leftPos: number | null = null; + let rightPos: number | null = null; + if (half === 'above') { + // Drop above target → between (idx - 1) and target. Skip the source + // itself if it was at idx-1 (would compute relative to itself). + let prev = idx - 1; + if (prev >= 0 && filtered[prev]!.id === src) prev -= 1; + leftPos = prev >= 0 ? filtered[prev]!.sort_position : null; + rightPos = target.sort_position; + } else { + // Drop below target → between target and (idx + 1). + let next = idx + 1; + if (next < filtered.length && filtered[next]!.id === src) next += 1; + leftPos = target.sort_position; + rightPos = next < filtered.length ? filtered[next]!.sort_position : null; + } + const newPos = + leftPos !== null && rightPos !== null ? (leftPos + rightPos) / 2 : + leftPos !== null ? leftPos + 1.0 : + rightPos !== null ? rightPos - 1.0 : + 0; + + // Optimistic: update the local row immediately. + const before = activities; + activities = activities.map((a) => + a.id === src ? { ...a, sort_position: newPos } as Activity : a, + ); + try { + await api.sortActivity(src, newPos); + } catch { + activities = before; + error = 'Kunne ikke endre rekkefølge.'; + } + }
@@ -177,78 +242,41 @@

Laster …

{:else if error}

{error}

+ {:else if filtered.length === 0} + {#if query} +

Ingen treff på «{query}».

+ {:else} +

+ {publicOnly ? 'Ingen offentlige aktiviteter ennå.' : 'Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.'} +

+ {/if} {:else} - {#if bookmarked.length} -

Bokmerker

- {#each bookmarked as a (a.id)} - (editing = act)} - onChanged={onChanged} - /> +
+ {#each filtered as a (a.id)} +
onDragOver(a.id, e)} + ondragleave={() => onDragLeaveRow(a.id)} + ondrop={(e) => onDrop(a.id, e)} + style={`position: relative;${dragId === a.id ? ' opacity: 0.4;' : ''}`} + > + {#if dragOverId === a.id && dragOverHalf === 'above'} + + {/if} + (editing = act)} + onChanged={onChanged} + draggable={!!session.user && !publicOnly} + onDragHandleStart={(e) => onDragStart(a.id, e)} + onDragHandleEnd={onDragEnd} + /> + {#if dragOverId === a.id && dragOverHalf === 'below'} + + {/if} +
{/each} - {/if} - - {#if myPrivate.length} -

Dine private

- {#each myPrivate as a (a.id)} - (editing = act)} - onChanged={onChanged} - /> - {/each} - {/if} - - {#if friends.length} -

Venner

- {#each friends as a (a.id)} - (editing = act)} - onChanged={onChanged} - /> - {/each} - {/if} - - {#if semi.length} -

Anonyme

- {#each semi as a (a.id)} - (editing = act)} - onChanged={onChanged} - /> - {/each} - {/if} - - {#if pub.length} -

Offentlige

- {#each pub as a (a.id)} - (editing = act)} - onChanged={onChanged} - /> - {/each} - {/if} - - {#if !myPrivate.length && !semi.length && !pub.length} - {#if query} -

Ingen treff på «{query}».

- {:else} -

Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.

- {/if} - {/if} +
{/if}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 47d9421..2d3aef8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -83,6 +83,10 @@ export const api = { http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }), unbookmarkActivity: (id: string) => http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'DELETE' }), + sortActivity: (id: string, position: number) => + http<{ ok: true }>(`/activities/${encodeURIComponent(id)}/sort`, { + method: 'PATCH', body: JSON.stringify({ position }), + }), // --- tags ----------------------------------------------------------------- tagSuggestions: (q: string, limit = 20) => diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2f18186..4f8781f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -286,6 +286,38 @@ nav.top h1::after { .vis-badge.semi { background: color-mix(in srgb, var(--vis-semi) 18%, transparent); color: var(--vis-semi); } .vis-badge.public { background: color-mix(in srgb, var(--vis-public) 18%, transparent); color: var(--vis-public); } .vis-badge.friends { background: color-mix(in srgb, var(--vis-friends) 20%, transparent); color: var(--vis-friends); } +.vis-badge.bookmarked { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} +/* Bookmarked badge already contains the ★ glyph in its text content, so we + override the ::before-emoji default that .vis-badge applies otherwise. */ +.vis-badge.bookmarked::before { content: none; } + +/* Drag handle on each activity row in the unified list view. The button + itself is what `draggable="true"` is bound to so users have a clear + target; the rest of the card stays selectable. */ +.drag-handle { + min-height: 24px !important; + min-width: 28px !important; + padding: 0 0.3rem !important; + cursor: grab; + background: transparent !important; + border: 1px solid transparent !important; + color: var(--muted); + font-size: 1.1rem; + line-height: 1; + user-select: none; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; +} +.drag-handle:hover { + background: var(--accent-soft) !important; + border-color: var(--border) !important; +} +.drag-handle:active { cursor: grabbing; } .error { color: var(--danger); margin-top: 0.5rem; } diff --git a/server/activities.ts b/server/activities.ts index f5b79be..d83ae5f 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -36,6 +36,10 @@ interface ActivityRow { loc_lng: number | null; created_at: number; updated_at: number; + /** Optional column from the list query's LEFT JOIN. Null when the viewer + * has no custom position for this row; the serializer falls back to + * -created_at, which matches the SQL ORDER BY's COALESCE. */ + sort_position?: number | null; } /** @@ -112,6 +116,10 @@ function b64ToBuf(s: string): Buffer { } function serialize(row: ActivityRow, viewerId: string | null): Activity { + // Effective sort position: custom if the viewer has dragged this row, + // 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; if (row.visibility === 'private') { const a: ActivityPrivate = { id: row.id, @@ -123,6 +131,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { heart_count: 0, viewer_hearted: false, viewer_bookmarked: false, + sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, }; @@ -148,6 +157,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { heart_count: hearts.count, viewer_hearted: hearts.hearted, viewer_bookmarked: bookmarked, + sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, }; @@ -176,6 +186,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { heart_count: hearts.count, viewer_hearted: hearts.hearted, viewer_bookmarked: bookmarked, + sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, }; @@ -196,6 +207,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { heart_count: hearts.count, viewer_hearted: hearts.hearted, viewer_bookmarked: bookmarked, + sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, }; @@ -259,13 +271,59 @@ activitiesRoutes.get('/', (c) => { params.push(viewerId, viewerId, viewerId); } + // Effective ordering: if the viewer has a per-row sort position, use it; + // otherwise fall back to -created_at so new activities (no sort row yet) + // float to the top of the list. The LEFT JOIN passes the viewer id so the + // sort table is scoped per-viewer. + // + // Anonymous viewers (no viewerId) skip the JOIN — the COALESCE just + // becomes -created_at, which sorts newest-first. + const orderParams: (string | number | null)[] = [...params]; + const sortJoin = viewerId + ? `LEFT JOIN user_activity_sort s ON s.activity_id = activities.id AND s.user_id = ?` + : ''; + if (viewerId) orderParams.unshift(viewerId); // sort-join's ? comes BEFORE the WHERE ?s + const rows = db - .prepare(`SELECT * FROM activities WHERE ${where} ORDER BY created_at DESC`) - .all(...params) as ActivityRow[]; + .prepare(` + SELECT activities.*${viewerId ? ', s.position AS sort_position' : ''} + FROM activities + ${sortJoin} + WHERE ${where} + ORDER BY COALESCE(${viewerId ? 's.position' : 'NULL'}, -activities.created_at) ASC + `) + .all(...orderParams) as ActivityRow[]; return c.json(rows.map((r) => serialize(r, viewerId))); }); +// --- PATCH /api/activities/:id/sort ----------------------------------------- +// Persist the viewer's manual sort position for an activity. Float position +// from the client; we trust the client's midpoint math (and a tiny +// validation pass). Anonymous viewers can't sort — gated by requireAuth. +activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const body = (await c.req.json().catch(() => null)) as { position?: number } | null; + if (!body || typeof body.position !== 'number' || !Number.isFinite(body.position)) { + return c.json({ error: 'missing:position' }, 400); + } + const db = getDb(); + // Confirm the activity exists AND the viewer can see it. Anything else is + // a 404 — we don't want callers persisting positions for activities they + // can't see, even though it wouldn't surface anywhere visible. + const visible = db + .prepare('SELECT 1 FROM activities WHERE id = ?') + .get(id); + if (!visible) return c.json({ error: 'not_found' }, 404); + + db.prepare(` + INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?) + ON CONFLICT(user_id, activity_id) DO UPDATE SET position = excluded.position + `).run(userId, id, body.position); + return c.json({ ok: true }); +}); + // --- GET /api/activities/:id ------------------------------------------------ activitiesRoutes.get('/:id', (c) => { const viewerId = currentUserId(c); diff --git a/server/db.ts b/server/db.ts index d6aa986..f362595 100644 --- a/server/db.ts +++ b/server/db.ts @@ -186,6 +186,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [ UNIQUE (user_id, position) )`, `CREATE INDEX IF NOT EXISTS user_links_user_idx ON user_links(user_id, position)`, + // Per-user activity ordering. Sparse: a user with no rows is on the + // default "newest first" order. A row records the user's manual sort + // position for one activity; the listing query uses + // COALESCE(position, -activity.created_at) so unsorted items sort by + // recency. Float positions let us insert between neighbours without + // renumbering — classic midpoint scheme. + `CREATE TABLE IF NOT EXISTS user_activity_sort ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + position REAL NOT NULL, + PRIMARY KEY (user_id, activity_id) + )`, + `CREATE INDEX IF NOT EXISTS user_activity_sort_user_pos_idx ON user_activity_sort(user_id, position)`, ]; const PRAGMAS: readonly string[] = [ diff --git a/server/users.ts b/server/users.ts index 831d04a..59ab56a 100644 --- a/server/users.ts +++ b/server/users.ts @@ -77,6 +77,8 @@ usersRoutes.get('/:username/list', (c) => { // viewer is to fill viewer_hearted/bookmarked truthfully. Always false. viewer_hearted: false, viewer_bookmarked: false, + // No personal sort here — anonymous view always sorts by recency. + sort_position: -r.created_at, created_at: r.created_at, updated_at: r.updated_at, }; diff --git a/shared/types.ts b/shared/types.ts index b9a750c..1b53aec 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; + /** 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. */ + sort_position: number; created_at: number; updated_at: number; } @@ -240,6 +244,10 @@ export interface ActivitySemi { heart_count: number; viewer_hearted: boolean; viewer_bookmarked: 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. */ + sort_position: number; created_at: number; updated_at: number; } @@ -256,6 +264,10 @@ export interface ActivityPrivate { heart_count: number; viewer_hearted: boolean; viewer_bookmarked: 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. */ + sort_position: number; created_at: number; updated_at: number; } @@ -282,6 +294,10 @@ export interface ActivityFriends { heart_count: number; viewer_hearted: boolean; viewer_bookmarked: 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. */ + sort_position: number; created_at: number; updated_at: number; } diff --git a/tests/activities.test.ts b/tests/activities.test.ts index 893b77d..402727e 100644 --- a/tests/activities.test.ts +++ b/tests/activities.test.ts @@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { setupTestApp, signupAndGetCookie, createActivity, listActivities, getActivity, - req, reqJson, setUsername, + req, reqJson, setUsername, ttest, type TestApp, } from './helpers'; import type { Activity, ActivityPublic, ActivitySemi, ActivityPrivate } from '../shared/types'; @@ -279,6 +279,77 @@ describe('update authz', () => { }); }); +describe('per-user sort order', () => { + ttest('new activities float to the top by default; PATCH /sort persists a custom position; ordering is per-viewer', async () => { + const [a, b] = await Promise.all([ + signupAndGetCookie(ctx, 'sort-a@test.invalid'), + signupAndGetCookie(ctx, 'sort-b@test.invalid'), + ]); + // A creates three publics in order. + const p1 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'first', tags: [] }); + // Force separate created_at by waiting briefly between inserts. SQLite + // resolution is milliseconds — Bun's Date.now() advances reliably. + await new Promise((r) => setTimeout(r, 5)); + const p2 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'second', tags: [] }); + await new Promise((r) => setTimeout(r, 5)); + const p3 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'third', tags: [] }); + + // Default order: newest first. + { + const list = await listActivities(ctx, a.cookie); + const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id); + expect(ids).toEqual([p3.id, p2.id, p1.id]); + } + + // A drags p1 to a position above p3 (small enough to outrank p3 and p2). + // Pick a position more negative than p3's -created_at so p1 sorts first. + const aList = await listActivities(ctx, a.cookie); + const p3eff = aList.find((x) => x.id === p3.id)!.sort_position; + const newPos = p3eff - 1.0; + await reqJson(ctx, 'PATCH', `/api/activities/${p1.id}/sort`, { + cookie: a.cookie, body: { position: newPos }, + }); + + // A's view now starts with p1. + { + const list = await listActivities(ctx, a.cookie); + const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id); + expect(ids).toEqual([p1.id, p3.id, p2.id]); + } + + // B's view is unchanged — sort is per-viewer. + { + const list = await listActivities(ctx, b.cookie); + const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id); + expect(ids).toEqual([p3.id, p2.id, p1.id]); + } + + // A new activity (no sort row for A) lands at the top of A's list, above + // their custom-positioned p1 — because -created_at is very negative. + await new Promise((r) => setTimeout(r, 5)); + const fresh = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'newer', tags: [] }); + const finalList = await listActivities(ctx, a.cookie); + const finalIds = finalList.filter((x) => [fresh.id, p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id); + expect(finalIds[0]).toBe(fresh.id); + }); + + test('PATCH /sort requires auth', async () => { + const res = await req(ctx, 'PATCH', '/api/activities/whatever/sort', { body: { position: 1 } }); + expect(res.status).toBe(401); + }); + + test('PATCH /sort rejects missing or non-finite position', async () => { + const u = await signupAndGetCookie(ctx, 'sort-val@test.invalid'); + const act = await createActivity(ctx, u.cookie, { visibility: 'public', title: 'x', tags: [] }); + for (const bad of [undefined, null, 'high', Infinity, NaN]) { + const res = await req(ctx, 'PATCH', `/api/activities/${act.id}/sort`, { + cookie: u.cookie, body: { position: bad }, + }); + expect(res.status).toBe(400); + } + }); +}); + describe('owner_display fallback chain (no email leak)', () => { test('display_name → username → null (never email)', async () => { // (1) Neither set.