Drag-and-drop unified activity list with per-user sort order
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, /<username>/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.
This commit is contained in:
parent
e64d5450f8
commit
8295a35c94
9 changed files with 361 additions and 102 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue