Each trip gets a checklist whose items group under free-text categories (Documents, Clothing, Toiletries, Health, Electronics, Extras first, then any custom ones alphabetically). Items are either shared — every member sees and can tick them, and checked_by records who — or personal to one member, which nobody else can see or touch. Items carry an optional quantity, drag-reorder within their category, and "Uncheck all" resets the list for the trip home. The "Suggestions" modal is deterministic, offline advice derived from the trip itself (src/server/util/packing.js) — no LLM and no external calls, so it stays unit-testable and works on a self-hosted box. Nights scale clothing quantities, flights add liquids/power-bank/check-in, rentals add licence + IDP, ferries add motion-sickness tablets, tropical stops add sun cream and repellent, and the destination country picks the plug type from a bundled ~50-country table. Every suggestion carries a short reason, and already-added ones are keyed by suggestion_key so they can't be duplicated. Two rules deliberately differ from the naive reading, both regression-tested: a latitude floor stops a December trip to Bangkok being tagged cold as well as tropical, and only a flight segment's arrival airport counts, since the first segment's departure airport is home rather than a destination. checklist_items is a new table, so the existing CREATE TABLE IF NOT EXISTS path creates it on upgrade; no MIGRATIONS entry is needed and existing data is untouched. docs/API.md documents the full contract. 113/113 tests pass.
225 lines
9.0 KiB
JavaScript
225 lines
9.0 KiB
JavaScript
// HTML5 drag-and-drop helpers, no libraries. Three flows:
|
|
// • Calendar — drag a day chip (or a stay area band) onto another in-range
|
|
// day to move that entry (shifts end_date by the same delta so multi-day
|
|
// spans / stay lengths stay intact).
|
|
// • Day editor — drag a row's handle to reorder entries within one day.
|
|
// • 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';
|
|
import { parseYMD, ymd, addDays } from '../format.js';
|
|
|
|
const DND_MIME = 'text/plain';
|
|
|
|
function dayDelta(fromYmd, toYmd) {
|
|
return Math.round((parseYMD(toYmd) - parseYMD(fromYmd)) / 86400000);
|
|
}
|
|
|
|
// ---------- Calendar: move an entry to another day ----------
|
|
|
|
// Mark a calendar chip as a drag source carrying its entry id.
|
|
export function makeChipDraggable(node, entry) {
|
|
node.setAttribute('draggable', 'true');
|
|
node.classList.add('cal-chip-draggable');
|
|
node.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData(DND_MIME, String(entry.id));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
node.classList.add('dragging');
|
|
});
|
|
node.addEventListener('dragend', () => node.classList.remove('dragging'));
|
|
}
|
|
|
|
// Mark a stay area band segment as a drag source carrying its entry id. Same
|
|
// mechanics as makeChipDraggable — dropping on an in-range day moves the
|
|
// whole stay to start there, preserving its length (end_date shifts by the
|
|
// same delta in makeDayDropTarget below).
|
|
export function makeBandDraggable(node, entry) {
|
|
node.setAttribute('draggable', 'true');
|
|
node.classList.add('cal-band-draggable');
|
|
node.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData(DND_MIME, String(entry.id));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
node.classList.add('dragging');
|
|
});
|
|
node.addEventListener('dragend', () => node.classList.remove('dragging'));
|
|
}
|
|
|
|
// Shared move-commit: PATCHes the dragged entry to `targetDate` with the given
|
|
// `sortOrder`, shifting `end_date` by the same day-delta in the SAME patch (the
|
|
// backend rejects date > stored end_date otherwise). No-ops when dropped back
|
|
// on its own day. Used by both the day-cell and week-bands drop targets below.
|
|
async function commitEntryMove(id, targetDate, sortOrder, entriesById, tctx) {
|
|
const entry = entriesById.get(id);
|
|
if (!entry || entry.date === targetDate) return; // dropped on its own day
|
|
const patch = { date: targetDate, sort_order: sortOrder };
|
|
if (entry.end_date) {
|
|
const delta = dayDelta(entry.date, targetDate);
|
|
patch.end_date = ymd(addDays(parseYMD(entry.end_date), delta));
|
|
}
|
|
try {
|
|
await api.entries.update(id, patch);
|
|
await tctx.refreshTrip();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
}
|
|
|
|
// Wire an in-range day cell as a drop target. On drop the dragged entry moves
|
|
// to `targetDate` with sort_order = `targetCount` (appended after that day's
|
|
// entries).
|
|
export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) {
|
|
cell.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
cell.classList.add('cal-day-drop');
|
|
});
|
|
cell.addEventListener('dragleave', (e) => {
|
|
if (!cell.contains(e.relatedTarget)) cell.classList.remove('cal-day-drop');
|
|
});
|
|
cell.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
cell.classList.remove('cal-day-drop');
|
|
const id = Number(e.dataTransfer.getData(DND_MIME));
|
|
if (!id) return;
|
|
await commitEntryMove(id, targetDate, targetCount, entriesById, tctx);
|
|
});
|
|
}
|
|
|
|
// Wire a week's stay-bands strip as a drop target, so dragging a chip/band
|
|
// onto a spot already covered by another stay's band still moves the entry
|
|
// (day cells sit below the bands strip and are otherwise unreachable there).
|
|
// The target day is derived from the pointer's x-position across the 7-column
|
|
// strip; the moved entry is appended (sort_order 0 — bands don't track a
|
|
// day's chip order).
|
|
export function makeWeekBandsDropTarget(strip, weekYmd, entriesById, tctx) {
|
|
strip.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
strip.classList.add('cal-bands-drop');
|
|
});
|
|
strip.addEventListener('dragleave', (e) => {
|
|
if (!strip.contains(e.relatedTarget)) strip.classList.remove('cal-bands-drop');
|
|
});
|
|
strip.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
strip.classList.remove('cal-bands-drop');
|
|
const id = Number(e.dataTransfer.getData(DND_MIME));
|
|
if (!id) return;
|
|
const rect = strip.getBoundingClientRect();
|
|
const idx = Math.min(6, Math.max(0, Math.floor((e.clientX - rect.left) / (rect.width / 7))));
|
|
await commitEntryMove(id, weekYmd[idx], 0, entriesById, tctx);
|
|
});
|
|
}
|
|
|
|
// ---------- Shared reorder machinery ----------
|
|
|
|
// `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 `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, item } of rows) {
|
|
handle.setAttribute('draggable', 'true');
|
|
handle.addEventListener('dragstart', (e) => {
|
|
draggingId = getId(item);
|
|
e.dataTransfer.setData(DND_MIME, String(draggingId));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setDragImage(node, 0, 0);
|
|
node.classList.add('dragging');
|
|
});
|
|
handle.addEventListener('dragend', () => { draggingId = null; clearAll(); });
|
|
|
|
node.addEventListener('dragover', (e) => {
|
|
if (draggingId == null || getId(item) === draggingId) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
const rect = node.getBoundingClientRect();
|
|
const after = e.clientY - rect.top > rect.height / 2;
|
|
node.classList.toggle('drop-after', after);
|
|
node.classList.toggle('drop-before', !after);
|
|
});
|
|
node.addEventListener('dragleave', (e) => {
|
|
if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
node.addEventListener('drop', async (e) => {
|
|
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, getId(item), after, getId, getSort, commitPatch, refresh);
|
|
});
|
|
}
|
|
}
|
|
|
|
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 (getSort(ordered[i]) !== i) {
|
|
await commitPatch(getId(ordered[i]), i);
|
|
}
|
|
}
|
|
await refresh();
|
|
} catch (err) {
|
|
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,
|
|
);
|
|
}
|
|
|
|
// ---------- Checklist: reorder items within a category ----------
|
|
|
|
// `rows`: [{ node, handle, item }] — same mechanics as enableRowReorder,
|
|
// scoped to one category's rows (checklist.js calls this once per category,
|
|
// so the resulting sort_order values only need to rank correctly within that
|
|
// category — category is always the primary sort key server-side).
|
|
export function enableChecklistReorder(rows, refresh) {
|
|
enableReorder(
|
|
rows,
|
|
(item) => item.id,
|
|
(item) => item.sort_order,
|
|
(id, sort_order) => api.checklist.update(id, { sort_order }),
|
|
refresh,
|
|
);
|
|
}
|