Files
trip-plan/public/js/api.js
T
grabowski e342cd9a91 Add trip checklists with rule-based packing advice
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.
2026-08-03 18:18:25 +07:00

100 lines
3.5 KiB
JavaScript

// Thin fetch wrapper for the Trip Plan REST API (see docs/API.md).
// Same-origin, cookies included. Server errors ({error}) become thrown Errors
// whose message is the server's human-readable text and `.status` the code.
async function request(method, path, body) {
const opts = {
method,
credentials: 'same-origin',
headers: {},
};
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(path, opts);
} catch (networkErr) {
const err = new Error('Network error — is the server running?');
err.cause = networkErr;
err.status = 0;
throw err;
}
if (res.status === 204) return null;
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!res.ok) {
const message = (data && data.error) || `Request failed (${res.status})`;
const err = new Error(message);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
const get = (p) => request('GET', p);
const post = (p, b) => request('POST', p, b);
const patch = (p, b) => request('PATCH', p, b);
const del = (p) => request('DELETE', p);
export const api = {
auth: {
// Mullvad-style: create an account (server returns the one-time token).
createAccount: () => post('/api/auth/account', {}),
login: (token) => post('/api/auth/login', { token }),
logout: () => post('/api/auth/logout'),
me: () => get('/api/auth/me'),
updateMe: (patchBody) => patch('/api/auth/me', patchBody),
},
trips: {
list: () => get('/api/trips'),
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`, {}),
removeMember: (id, userId) => del(`/api/trips/${id}/members/${userId}`),
route: (id) => get(`/api/trips/${id}/route`),
costs: (id) => get(`/api/trips/${id}/costs`),
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
},
directions: (from, to, via = []) => get(
`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}` +
(via.length ? `&via=${via.map((w) => `${w.lat},${w.lng}`).join('|')}` : ''),
),
entries: {
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
remove: (id) => del(`/api/entries/${id}`),
},
geocode: (q) => get(`/api/geocode?q=${encodeURIComponent(q)}`),
airports: (q) => get(`/api/airports?q=${encodeURIComponent(q)}`),
checklist: {
list: (tripId) => get(`/api/trips/${tripId}/checklist`),
create: (tripId, payload) => post(`/api/trips/${tripId}/checklist`, payload),
update: (itemId, patchBody) => patch(`/api/checklist/${itemId}`, patchBody),
remove: (itemId) => del(`/api/checklist/${itemId}`),
reset: (tripId) => post(`/api/trips/${tripId}/checklist/reset`, {}),
suggestions: (tripId) => get(`/api/trips/${tripId}/checklist/suggestions`),
addSuggestions: (tripId, keys, personal) =>
post(`/api/trips/${tripId}/checklist/suggestions`, personal === undefined ? { keys } : { keys, personal }),
},
};
export default api;