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.