vinterliste/server
Ole-Morten Duesund 8295a35c94 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.
2026-05-25 16:47:55 +02:00
..
activities.ts Drag-and-drop unified activity list with per-user sort order 2026-05-25 16:47:55 +02:00
admin.ts Admin role, root/home URL split, activity permalinks 2026-05-25 13:23:13 +02:00
auth.ts External profile links (max 5 per user) 2026-05-25 16:20:04 +02:00
db.ts Drag-and-drop unified activity list with per-user sort order 2026-05-25 16:47:55 +02:00
feedback.ts fix(feedback): stop exposing done_by user id in API responses 2026-05-25 13:54:07 +02:00
friends.ts Friends + friends-only visibility + blocking 2026-05-25 14:47:20 +02:00
index.ts OpenGraph meta on the SPA's shareable routes 2026-05-25 16:05:43 +02:00
invites.ts fix(invites): build share URL on the client, not the server 2026-05-25 16:25:55 +02:00
og.ts OpenGraph meta on the SPA's shareable routes 2026-05-25 16:05:43 +02:00
roles.ts Admin role, root/home URL split, activity permalinks 2026-05-25 13:23:13 +02:00
session.ts Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
settings.ts Self-registry toggle, invite links with attribution, first-user-admin 2026-05-25 13:45:32 +02:00
tags.ts Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
users.ts Drag-and-drop unified activity list with per-user sort order 2026-05-25 16:47:55 +02:00