diff --git a/docs/API.md b/docs/API.md index cc621d6..57caa3a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -32,6 +32,7 @@ trips (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_d created_at TEXT DEFAULT current_timestamp) trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id), role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor' + sort_order INTEGER NOT NULL DEFAULT 0, -- per-user dashboard order (drag & drop) PRIMARY KEY (trip_id, user_id)) entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id), date TEXT NOT NULL, -- start date @@ -150,10 +151,11 @@ User JSON shape everywhere: `{id, display_name}`. | Method & path | Body | Response | |---|---|---| -| `GET /api/trips` | — | `200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, member_count, entry_count}]}` (trips where user is member, newest first) | +| `GET /api/trips` | — | `200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, sort_order, member_count, entry_count}]}` (trips where user is member, ordered by the caller's `trip_members.sort_order`, then newest first for ties) | | `POST /api/trips` | `{name, start_date, end_date, currency?}` | `201 {trip}`; validates: name non-empty ≤120 chars, valid dates, `end_date >= start_date`, range ≤ 365 days, currency (if given) matches `^[A-Z]{3}$` (default `USD`). Creator becomes member with role `owner`. | | `GET /api/trips/:id` | — | `200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]}` entries ordered by `(date, sort_order, id)` | | `PATCH /api/trips/:id` | any of `{name, start_date, end_date, currency}` | `200 {trip}` (same validation; entries outside new range are kept) | +| `PATCH /api/trips/:id/order` | `{sort_order}` | `200 {sort_order}` — updates the CALLER's own `trip_members.sort_order` for this trip (any member; integer required, else 400). Per-user: does not affect other members' dashboard order. | | `DELETE /api/trips/:id` | — | `204` — owner only, else `403`. Deletes members + entries too. | | `POST /api/trips/join` | `{code}` | `200 {trip}` — joins the trip with that join_code as `editor` (idempotent: already a member → still `200 {trip}`); `404 {"error":"not found"}` on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase). | | `POST /api/trips/:id/join-code` | — | `200 {trip}` — regenerates join_code, owner only (`403` otherwise) | diff --git a/public/css/styles.css b/public/css/styles.css index 19f636a..25baca5 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -183,10 +183,19 @@ textarea.input { resize: vertical; } .trip-card { display: flex; flex-direction: column; gap: 0.6rem; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; } .trip-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-strong); } .trip-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; } +.trip-card-top-right { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .trip-card-name { font-size: 1.1rem; } .trip-card-dates { color: var(--text-muted); font-size: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } .trip-card-countdown { font-size: 0.72rem; font-weight: 700; color: var(--brand-dark); background: var(--brand-soft); padding: 0.1rem 0.45rem; border-radius: 999px; } .trip-card-meta { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); margin-top: auto; } + +/* Drag-to-reorder trip cards: same grip + insertion-indicator convention as + the day editor's .entry-drag-handle / .entry-row (see dragdrop.js). */ +.trip-drag-handle { flex-shrink: 0; cursor: grab; color: var(--text-faint); font-size: 0.95rem; line-height: 1; letter-spacing: -1px; user-select: none; } +.trip-drag-handle:active { cursor: grabbing; } +.trip-card.dragging { opacity: 0.4; } +.trip-card.drop-before { box-shadow: inset 0 3px 0 var(--brand); } +.trip-card.drop-after { box-shadow: inset 0 -3px 0 var(--brand); } .role-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; } .role-owner { background: #fef3c7; color: #92400e; } .role-editor { background: var(--brand-soft); color: var(--brand-dark); } diff --git a/public/js/api.js b/public/js/api.js index 60eb225..badf1b9 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -64,6 +64,7 @@ export const api = { create: (payload) => post('/api/trips', payload), get: (id) => get(`/api/trips/${id}`), update: (id, patchBody) => patch(`/api/trips/${id}`, patchBody), + reorder: (id, sort_order) => patch(`/api/trips/${id}/order`, { sort_order }), remove: (id) => del(`/api/trips/${id}`), join: (code) => post('/api/trips/join', { code }), regenerateJoinCode: (id) => post(`/api/trips/${id}/join-code`, {}), diff --git a/public/js/views/dragdrop.js b/public/js/views/dragdrop.js index 87a105c..439ee52 100644 --- a/public/js/views/dragdrop.js +++ b/public/js/views/dragdrop.js @@ -1,8 +1,10 @@ -// HTML5 drag-and-drop helpers, no libraries. Two flows: +// HTML5 drag-and-drop helpers, no libraries. Three flows: // • Calendar — drag a day chip onto another in-range day to move that entry // (shifts end_date by the same delta so multi-day spans stay intact). // • Day editor — drag a row's handle to reorder entries within one day. -// Touch devices don't fire HTML5 DnD events, so both flows are desktop-only and +// • Trip dashboard — drag a card's handle to reorder trips (shares the day +// editor's row-reorder machinery, see enableReorder below). +// Touch devices don't fire HTML5 DnD events, so all flows are desktop-only and // degrade to the existing click/edit behaviour with no polyfill. import { api } from '../api.js'; import { toast } from '../dom.js'; @@ -62,24 +64,26 @@ export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tc }); } -// ---------- Day editor: reorder entries within a day ---------- +// ---------- Shared reorder machinery ---------- -// `rows`: [{ node, handle, entry }] in current display order. Dragging a row's +// `rows`: [{ node, handle, item }] in current display order. Dragging a row's // handle over another row shows a top/bottom insertion indicator; on drop the -// new order is computed and sort_order PATCHed for ONLY the entries whose index -// changed, then `refresh()` re-renders (the editor stays open on the same day). -export function enableRowReorder(rows, refresh) { +// new order is computed and `commitPatch(id, newSortOrder)` is awaited for ONLY +// the items whose index changed, then `refresh()` re-renders. `getId`/`getSort` +// read the identifying fields off `item` (entries use id/sort_order, trips too, +// but kept generic so callers don't need matching shapes). +function enableReorder(rows, getId, getSort, commitPatch, refresh) { let draggingId = null; const clearAll = () => { for (const r of rows) r.node.classList.remove('drop-before', 'drop-after', 'dragging'); }; - for (const { node, handle, entry } of rows) { + for (const { node, handle, item } of rows) { handle.setAttribute('draggable', 'true'); handle.addEventListener('dragstart', (e) => { - draggingId = entry.id; - e.dataTransfer.setData(DND_MIME, String(entry.id)); + draggingId = getId(item); + e.dataTransfer.setData(DND_MIME, String(draggingId)); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setDragImage(node, 0, 0); node.classList.add('dragging'); @@ -87,7 +91,7 @@ export function enableRowReorder(rows, refresh) { handle.addEventListener('dragend', () => { draggingId = null; clearAll(); }); node.addEventListener('dragover', (e) => { - if (draggingId == null || entry.id === draggingId) return; + if (draggingId == null || getId(item) === draggingId) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = node.getBoundingClientRect(); @@ -99,26 +103,26 @@ export function enableRowReorder(rows, refresh) { if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after'); }); node.addEventListener('drop', async (e) => { - if (draggingId == null || entry.id === draggingId) return; + if (draggingId == null || getId(item) === draggingId) return; e.preventDefault(); const after = node.classList.contains('drop-after'); const movedId = draggingId; clearAll(); draggingId = null; - await commitReorder(rows, movedId, entry.id, after, refresh); + await commitReorder(rows, movedId, getId(item), after, getId, getSort, commitPatch, refresh); }); } } -async function commitReorder(rows, movedId, targetId, after, refresh) { - const moved = rows.find((r) => r.entry.id === movedId).entry; - const ordered = rows.map((r) => r.entry).filter((e) => e.id !== movedId); - const targetIndex = ordered.findIndex((e) => e.id === targetId); +async function commitReorder(rows, movedId, targetId, after, getId, getSort, commitPatch, refresh) { + const moved = rows.find((r) => getId(r.item) === movedId).item; + const ordered = rows.map((r) => r.item).filter((it) => getId(it) !== movedId); + const targetIndex = ordered.findIndex((it) => getId(it) === targetId); ordered.splice(after ? targetIndex + 1 : targetIndex, 0, moved); try { for (let i = 0; i < ordered.length; i++) { - if (ordered[i].sort_order !== i) { - await api.entries.update(ordered[i].id, { sort_order: i }); + if (getSort(ordered[i]) !== i) { + await commitPatch(getId(ordered[i]), i); } } await refresh(); @@ -126,3 +130,30 @@ async function commitReorder(rows, movedId, targetId, after, refresh) { toast(err.message); } } + +// ---------- Day editor: reorder entries within a day ---------- + +// `rows`: [{ node, handle, entry }] — see enableReorder above for the mechanics. +export function enableRowReorder(rows, refresh) { + enableReorder( + rows.map((r) => ({ node: r.node, handle: r.handle, item: r.entry })), + (entry) => entry.id, + (entry) => entry.sort_order, + (id, sort_order) => api.entries.update(id, { sort_order }), + refresh, + ); +} + +// ---------- Trip dashboard: reorder trip cards ---------- + +// `rows`: [{ node, handle, trip }] — same mechanics as enableRowReorder, but +// PATCHes the caller's per-user `trip_members.sort_order` via api.trips.reorder. +export function enableTripReorder(rows, refresh) { + enableReorder( + rows.map((r) => ({ node: r.node, handle: r.handle, item: r.trip })), + (trip) => trip.id, + (trip) => trip.sort_order, + (id, sort_order) => api.trips.reorder(id, sort_order), + refresh, + ); +} diff --git a/public/js/views/trips.js b/public/js/views/trips.js index 84033a3..cbe035e 100644 --- a/public/js/views/trips.js +++ b/public/js/views/trips.js @@ -2,6 +2,7 @@ import { api } from '../api.js'; import { el, mount, clear, loading, errorBox, emptyState, toast } from '../dom.js'; import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js'; +import { enableTripReorder } from './dragdrop.js'; export function renderTrips(container, ctx) { mount(container, loading('Loading your trips…')); @@ -57,27 +58,46 @@ export function renderTrips(container, ctx) { ); } else { const grid = el('div', { class: 'trip-grid' }); - for (const trip of trips) grid.appendChild(tripCard(trip)); + // Reordering only makes sense with 2+ trips (mirrors the day editor's + // entry-row reorder — see dragdrop.js). + const reorderable = trips.length > 1; + const rows = []; + for (const trip of trips) { + const { node, handle } = tripCard(trip, reorderable); + if (reorderable) rows.push({ node, handle, trip }); + grid.appendChild(node); + } page.appendChild(grid); + if (reorderable) enableTripReorder(rows, load); } mount(container, page); } - function tripCard(trip) { + function tripCard(trip, reorderable) { const today = ymd(new Date()); const future = trip.start_date > today; const daysUntil = future ? Math.round((parseYMD(trip.start_date) - parseYMD(today)) / 86400000) : 0; - return el( + const handle = reorderable + ? el('span', { + class: 'trip-drag-handle', + title: 'Drag to reorder', + 'aria-hidden': 'true', + onClick: (e) => e.preventDefault(), + }, '⋮⋮') + : null; + const node = el( 'a', - { class: 'trip-card card', href: `#/trip/${trip.id}` }, + { class: 'trip-card card', href: `#/trip/${trip.id}`, draggable: 'false' }, el( 'div', { class: 'trip-card-top' }, el('h3', { class: 'trip-card-name' }, trip.name), - el('span', { class: `role-badge role-${trip.role}` }, trip.role), + el('div', { class: 'trip-card-top-right' }, + handle, + el('span', { class: `role-badge role-${trip.role}` }, trip.role)), ), el( 'div', @@ -93,6 +113,7 @@ export function renderTrips(container, ctx) { el('span', { class: 'trip-card-currency' }, trip.currency || 'USD'), ), ); + return { node, handle }; } // Render `build()` into the slot, or close it if the same panel is open. diff --git a/src/server/db.js b/src/server/db.js index b947d09..793cb06 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS trip_members ( trip_id INTEGER NOT NULL REFERENCES trips(id), user_id INTEGER NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'editor', + sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (trip_id, user_id) ); @@ -72,6 +73,7 @@ const MIGRATIONS = [ { table: 'entries', column: 'rental', ddl: 'ALTER TABLE entries ADD COLUMN rental TEXT' }, { table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' }, { table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' }, + { table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' }, ]; function applyMigrations(db) { diff --git a/src/server/routes/trips.js b/src/server/routes/trips.js index a4fcfaa..0929fff 100644 --- a/src/server/routes/trips.js +++ b/src/server/routes/trips.js @@ -116,13 +116,16 @@ export default function tripsRoutes(db) { const router = express.Router(); const listForUser = db.prepare(` - SELECT t.id, t.name, t.start_date, t.end_date, t.owner_id, t.currency, tm.role, + SELECT t.id, t.name, t.start_date, t.end_date, t.owner_id, t.currency, tm.role, tm.sort_order, (SELECT COUNT(*) FROM trip_members WHERE trip_id = t.id) AS member_count, (SELECT COUNT(*) FROM entries WHERE trip_id = t.id) AS entry_count FROM trips t JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? - ORDER BY t.created_at DESC, t.id DESC + ORDER BY tm.sort_order, t.created_at DESC, t.id DESC `); + const updateMemberOrder = db.prepare( + 'UPDATE trip_members SET sort_order = ? WHERE trip_id = ? AND user_id = ?' + ); const insertTrip = db.prepare( 'INSERT INTO trips (name, start_date, end_date, owner_id, currency, join_code) VALUES (?, ?, ?, ?, ?, ?)' ); @@ -233,6 +236,18 @@ export default function tripsRoutes(db) { res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) }); }); + // PATCH /api/trips/:id/order { sort_order } — caller's own dashboard order. + router.patch('/:id/order', (req, res) => { + const ctx = requireMember(req, res); + if (!ctx) return; + const { sort_order } = req.body || {}; + if (!Number.isInteger(sort_order)) { + return res.status(400).json({ error: 'sort_order must be an integer' }); + } + updateMemberOrder.run(sort_order, ctx.tripId, req.session.userId); + res.status(200).json({ sort_order }); + }); + // DELETE /api/trips/:id (owner only) router.delete('/:id', (req, res) => { const ctx = requireMember(req, res); diff --git a/tests/api.test.js b/tests/api.test.js index 09714e6..e42b89f 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -172,6 +172,61 @@ test('unknown trip returns 404', async () => { assert.equal(res.body.error, 'not found'); }); +test('PATCH /api/trips/:id/order reorders the dashboard list for the caller', async () => { + const { agent } = await createAccount(); + const a = (await agent.post('/api/trips').send({ name: 'A', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + const b = (await agent.post('/api/trips').send({ name: 'B', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + const c = (await agent.post('/api/trips').send({ name: 'C', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + + // Default order is newest first: C, B, A. + const initial = await agent.get('/api/trips'); + assert.deepEqual(initial.body.trips.map((t) => t.id), [c.id, b.id, a.id]); + assert.ok(initial.body.trips.every((t) => t.sort_order === 0)); + + const setA = await agent.patch(`/api/trips/${a.id}/order`).send({ sort_order: 0 }); + assert.equal(setA.status, 200); + assert.deepEqual(setA.body, { sort_order: 0 }); + await agent.patch(`/api/trips/${b.id}/order`).send({ sort_order: 2 }); + await agent.patch(`/api/trips/${c.id}/order`).send({ sort_order: 1 }); + + const reordered = await agent.get('/api/trips'); + assert.deepEqual(reordered.body.trips.map((t) => t.id), [a.id, c.id, b.id]); +}); + +test('trip order is per-user', async () => { + const owner = await createAccount(); + const guest = await createAccount(); + const t1 = (await owner.agent.post('/api/trips').send({ name: 'T1', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + const t2 = (await owner.agent.post('/api/trips').send({ name: 'T2', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + await guest.agent.post('/api/trips/join').send({ code: t1.join_code }); + await guest.agent.post('/api/trips/join').send({ code: t2.join_code }); + + // Owner reorders their own list; guest's list is unaffected. + const guestBefore = (await guest.agent.get('/api/trips')).body.trips.map((t) => t.id); + await owner.agent.patch(`/api/trips/${t1.id}/order`).send({ sort_order: 5 }); + const guestAfter = (await guest.agent.get('/api/trips')).body.trips.map((t) => t.id); + assert.deepEqual(guestAfter, guestBefore); + + const ownerList = (await owner.agent.get('/api/trips')).body.trips; + assert.equal(ownerList.find((t) => t.id === t1.id).sort_order, 5); +}); + +test('order endpoint validation: non-integer 400, non-member 404', async () => { + const owner = await createAccount(); + const outsider = await createAccount(); + const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; + + const badBody = await owner.agent.patch(`/api/trips/${trip.id}/order`).send({ sort_order: 'first' }); + assert.equal(badBody.status, 400); + assert.equal(badBody.body.error, 'sort_order must be an integer'); + + const missing = await owner.agent.patch(`/api/trips/${trip.id}/order`).send({}); + assert.equal(missing.status, 400); + + const notMember = await outsider.agent.patch(`/api/trips/${trip.id}/order`).send({ sort_order: 1 }); + assert.equal(notMember.status, 404); +}); + // --------------------------------------------------------------------------- // Membership via join code // ---------------------------------------------------------------------------