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.
This commit is contained in:
2026-08-03 18:18:25 +07:00
parent e2c3089c25
commit e342cd9a91
16 changed files with 1913 additions and 2 deletions
+2
View File
@@ -7,6 +7,7 @@ import { requireAuth } from './auth.js';
import authRoutes from './routes/auth.js';
import tripsRoutes from './routes/trips.js';
import entriesRoutes from './routes/entries.js';
import checklistRoutes from './routes/checklist.js';
import geocodeRoutes from './routes/geocode.js';
import airportsRoutes from './routes/airports.js';
import directionsRoutes from './routes/directions.js';
@@ -50,6 +51,7 @@ export function createApp(options = {}) {
app.use('/api/auth', authRoutes(db));
app.use('/api/trips', requireAuth, tripsRoutes(db));
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
app.use('/api', requireAuth, checklistRoutes(db)); // /trips/:id/checklist* + /checklist/:itemId
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
app.use('/api/airports', requireAuth, airportsRoutes());
app.use('/api/directions', requireAuth, directionsRoutes());
+53
View File
@@ -0,0 +1,53 @@
{
"US": { "name": "United States", "plugs": ["A", "B"] },
"CA": { "name": "Canada", "plugs": ["A", "B"] },
"MX": { "name": "Mexico", "plugs": ["A", "B"] },
"GB": { "name": "United Kingdom", "plugs": ["G"] },
"IE": { "name": "Ireland", "plugs": ["G"] },
"FR": { "name": "France", "plugs": ["C", "E"] },
"DE": { "name": "Germany", "plugs": ["C", "F"] },
"IT": { "name": "Italy", "plugs": ["C", "F", "L"] },
"ES": { "name": "Spain", "plugs": ["C", "F"] },
"PT": { "name": "Portugal", "plugs": ["C", "F"] },
"NL": { "name": "Netherlands", "plugs": ["C", "F"] },
"BE": { "name": "Belgium", "plugs": ["C", "E"] },
"CH": { "name": "Switzerland", "plugs": ["C", "J"] },
"AT": { "name": "Austria", "plugs": ["C", "F"] },
"GR": { "name": "Greece", "plugs": ["C", "F"] },
"SE": { "name": "Sweden", "plugs": ["C", "F"] },
"NO": { "name": "Norway", "plugs": ["C", "F"] },
"DK": { "name": "Denmark", "plugs": ["C", "K"] },
"FI": { "name": "Finland", "plugs": ["C", "F"] },
"PL": { "name": "Poland", "plugs": ["C", "E"] },
"CZ": { "name": "Czechia", "plugs": ["C", "E"] },
"HU": { "name": "Hungary", "plugs": ["C", "F"] },
"HR": { "name": "Croatia", "plugs": ["C", "F"] },
"IS": { "name": "Iceland", "plugs": ["C", "F"] },
"RU": { "name": "Russia", "plugs": ["C", "F"] },
"TR": { "name": "Turkey", "plugs": ["C", "F"] },
"TH": { "name": "Thailand", "plugs": ["A", "B", "C"] },
"VN": { "name": "Vietnam", "plugs": ["A", "C"] },
"KH": { "name": "Cambodia", "plugs": ["A", "C", "G"] },
"LA": { "name": "Laos", "plugs": ["A", "B", "C"] },
"JP": { "name": "Japan", "plugs": ["A", "B"] },
"CN": { "name": "China", "plugs": ["A", "C", "I"] },
"KR": { "name": "South Korea", "plugs": ["C", "F"] },
"IN": { "name": "India", "plugs": ["C", "D", "M"] },
"ID": { "name": "Indonesia", "plugs": ["C", "F"] },
"MY": { "name": "Malaysia", "plugs": ["G"] },
"SG": { "name": "Singapore", "plugs": ["G"] },
"PH": { "name": "Philippines", "plugs": ["A", "B", "C"] },
"AU": { "name": "Australia", "plugs": ["I"] },
"NZ": { "name": "New Zealand", "plugs": ["I"] },
"ZA": { "name": "South Africa", "plugs": ["M", "N"] },
"EG": { "name": "Egypt", "plugs": ["C", "F"] },
"AE": { "name": "United Arab Emirates", "plugs": ["G", "C"] },
"IL": { "name": "Israel", "plugs": ["C", "H"] },
"BR": { "name": "Brazil", "plugs": ["N", "C"] },
"AR": { "name": "Argentina", "plugs": ["C", "I"] },
"CL": { "name": "Chile", "plugs": ["C", "L"] },
"PE": { "name": "Peru", "plugs": ["A", "C"] },
"CO": { "name": "Colombia", "plugs": ["A", "B"] },
"MA": { "name": "Morocco", "plugs": ["C", "E"] },
"KE": { "name": "Kenya", "plugs": ["G"] }
}
+15
View File
@@ -58,9 +58,24 @@ CREATE TABLE IF NOT EXISTS entry_participants (
PRIMARY KEY (entry_id, user_id)
);
CREATE TABLE IF NOT EXISTS checklist_items (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL REFERENCES trips(id),
user_id INTEGER REFERENCES users(id),
text TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'General',
qty INTEGER,
checked INTEGER NOT NULL DEFAULT 0,
checked_by INTEGER REFERENCES users(id),
sort_order INTEGER NOT NULL DEFAULT 0,
suggestion_key TEXT,
created_at TEXT DEFAULT current_timestamp
);
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
CREATE INDEX IF NOT EXISTS idx_checklist_trip ON checklist_items(trip_id, category, sort_order, id);
`;
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
+352
View File
@@ -0,0 +1,352 @@
import express from 'express';
import { daysInclusive } from '../util/dates.js';
import { membership } from '../util/access.js';
import { parseSegments } from '../util/entrySerialize.js';
import { buildSuggestions, CATEGORY_ORDER } from '../util/packing.js';
const MAX_TEXT_LEN = 120;
const MAX_CATEGORY_LEN = 40;
const MAX_KEYS = 60;
function categoryRank(category) {
const idx = CATEGORY_ORDER.indexOf(category);
return idx === -1 ? CATEGORY_ORDER.length : idx;
}
// Order: fixed category order (then any other category alphabetically),
// then (sort_order, id) within a category.
function compareItems(a, b) {
const ra = categoryRank(a.category);
const rb = categoryRank(b.category);
if (ra !== rb) return ra - rb;
if (ra === CATEGORY_ORDER.length && a.category !== b.category) {
return a.category < b.category ? -1 : 1;
}
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
return a.id - b.id;
}
function itemJson(row) {
return {
id: row.id,
trip_id: row.trip_id,
text: row.text,
category: row.category,
qty: row.qty ?? null,
checked: !!row.checked,
checked_by: row.checked_by ?? null,
personal: row.user_id !== null,
user_id: row.user_id ?? null,
sort_order: row.sort_order,
suggestion_key: row.suggestion_key ?? null,
};
}
function progressFor(items) {
const total = items.length;
const checked = items.filter((i) => i.checked).length;
const byCategory = [];
const seen = new Set();
for (const item of items) {
if (seen.has(item.category)) continue;
seen.add(item.category);
const inCategory = items.filter((i) => i.category === item.category);
byCategory.push({
category: item.category,
total: inCategory.length,
checked: inCategory.filter((i) => i.checked).length,
});
}
return { total, checked, byCategory };
}
// Validate a checklist item body. `partial` = true for PATCH (only provided
// keys checked). Returns { error } or { fields } (fields is a plain object of
// column -> value to write, using the checklist_items column names).
function validateItem(body, { partial, callerId }) {
const fields = {};
const has = (k) => k in body;
if (!partial || has('text')) {
if (typeof body.text !== 'string' || body.text.trim() === '') {
return { error: 'text is required' };
}
const trimmed = body.text.trim();
if (trimmed.length > MAX_TEXT_LEN) {
return { error: `text must be at most ${MAX_TEXT_LEN} characters` };
}
fields.text = trimmed;
}
if (has('category')) {
if (typeof body.category !== 'string') {
return { error: 'category must be a string' };
}
const trimmed = body.category.trim();
if (trimmed.length > MAX_CATEGORY_LEN) {
return { error: `category must be at most ${MAX_CATEGORY_LEN} characters` };
}
fields.category = trimmed || 'General';
} else if (!partial) {
fields.category = 'General';
}
if (has('qty')) {
const v = body.qty;
if (v !== null && !(Number.isInteger(v) && v >= 1 && v <= 99)) {
return { error: 'qty must be null or an integer from 1 to 99' };
}
fields.qty = v;
} else if (!partial) {
fields.qty = null;
}
if (has('checked')) {
if (typeof body.checked !== 'boolean') {
return { error: 'checked must be a boolean' };
}
fields.checked = body.checked ? 1 : 0;
fields.checked_by = body.checked ? callerId : null;
} else if (!partial) {
fields.checked = 0;
fields.checked_by = null;
}
if (has('personal')) {
if (typeof body.personal !== 'boolean') {
return { error: 'personal must be a boolean' };
}
fields.user_id = body.personal ? callerId : null;
} else if (!partial) {
fields.user_id = null;
}
if (has('sort_order')) {
if (!Number.isInteger(body.sort_order)) {
return { error: 'sort_order must be an integer' };
}
fields.sort_order = body.sort_order;
}
return { fields };
}
export default function checklistRoutes(db) {
const router = express.Router();
const getTrip = db.prepare('SELECT id, start_date, end_date FROM trips WHERE id = ?');
const getTripEntries = db.prepare(
'SELECT id, type, location_name, lat, lng, transport_mode, segments FROM entries WHERE trip_id = ?'
);
const getVisibleItems = db.prepare(
'SELECT * FROM checklist_items WHERE trip_id = ? AND (user_id IS NULL OR user_id = ?)'
);
const getItemById = db.prepare('SELECT * FROM checklist_items WHERE id = ?');
const getMaxSortOrder = db.prepare(
'SELECT MAX(sort_order) AS max FROM checklist_items WHERE trip_id = ?'
);
const insertItem = db.prepare(`
INSERT INTO checklist_items
(trip_id, user_id, text, category, qty, checked, checked_by, sort_order, suggestion_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
function visibleItems(tripId, callerId) {
return getVisibleItems.all(tripId, callerId).sort(compareItems);
}
function nextSortOrder(tripId) {
const row = getMaxSortOrder.get(tripId);
return (row && row.max !== null ? row.max : -1) + 1;
}
function tripContext(tripId) {
const trip = getTrip.get(tripId);
const entries = getTripEntries.all(tripId).map((r) => ({
...r,
segments: parseSegments(r.segments),
}));
const days = daysInclusive(trip.start_date, trip.end_date);
const nights = Math.max(0, days - 1);
return { trip, entries, days, nights };
}
// Resolve an item by id, confirm the caller is a trip member, and that the
// item is visible to them (shared, or their own personal item). Sends 404
// and returns null otherwise (no leaking of other members' personal items).
function requireVisibleItem(req, res) {
const itemId = Number(req.params.itemId);
const row = Number.isInteger(itemId) ? getItemById.get(itemId) : null;
if (!row || !membership(db, row.trip_id, req.session.userId)) {
res.status(404).json({ error: 'not found' });
return null;
}
if (row.user_id !== null && row.user_id !== req.session.userId) {
res.status(404).json({ error: 'not found' });
return null;
}
return row;
}
// GET /trips/:id/checklist
router.get('/trips/:id/checklist', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const items = visibleItems(tripId, req.session.userId).map(itemJson);
res.status(200).json({ items, progress: progressFor(items) });
});
// GET /trips/:id/checklist/suggestions
router.get('/trips/:id/checklist/suggestions', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const { trip, entries, days, nights } = tripContext(tripId);
const { suggestions, context } = buildSuggestions({ trip, entries, days, nights });
const visibleKeys = new Set(
visibleItems(tripId, req.session.userId)
.map((i) => i.suggestion_key)
.filter(Boolean)
);
res.status(200).json({
suggestions: suggestions.map((s) => ({ ...s, added: visibleKeys.has(s.key) })),
context,
});
});
// POST /trips/:id/checklist/suggestions { keys, personal? }
router.post('/trips/:id/checklist/suggestions', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const body = req.body || {};
const keys = body.keys;
if (!Array.isArray(keys) || keys.length === 0 || keys.length > MAX_KEYS) {
return res.status(400).json({ error: `keys must be a non-empty array of at most ${MAX_KEYS} strings` });
}
if (body.personal !== undefined && typeof body.personal !== 'boolean') {
return res.status(400).json({ error: 'personal must be a boolean' });
}
const personal = body.personal === true;
const callerId = req.session.userId;
const { trip, entries, days, nights } = tripContext(tripId);
const { suggestions } = buildSuggestions({ trip, entries, days, nights });
const byKey = new Map(suggestions.map((s) => [s.key, s]));
for (const key of keys) {
if (typeof key !== 'string' || !byKey.has(key)) {
return res.status(400).json({ error: `unknown suggestion key: ${key}` });
}
}
const requested = new Set(keys);
const already = new Set(
visibleItems(tripId, callerId).map((i) => i.suggestion_key).filter(Boolean)
);
const result = db.transaction(() => {
const created = [];
const skipped = [];
let sortOrder = nextSortOrder(tripId);
for (const s of suggestions) {
if (!requested.has(s.key)) continue;
if (already.has(s.key)) {
skipped.push(s.key);
continue;
}
const info = insertItem.run(
tripId,
personal ? callerId : null,
s.text,
s.category,
s.qty ?? null,
0,
null,
sortOrder,
s.key
);
sortOrder += 1;
created.push(itemJson(getItemById.get(Number(info.lastInsertRowid))));
}
return { created, skipped };
})();
res.status(201).json(result);
});
// POST /trips/:id/checklist/reset {} — unticks caller-visible items.
router.post('/trips/:id/checklist/reset', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const callerId = req.session.userId;
const info = db
.prepare(
`UPDATE checklist_items SET checked = 0, checked_by = NULL
WHERE trip_id = ? AND (user_id IS NULL OR user_id = ?) AND checked = 1`
)
.run(tripId, callerId);
res.status(200).json({ unchecked: info.changes });
});
// POST /trips/:id/checklist { text, category?, qty?, personal?, checked?, sort_order? }
router.post('/trips/:id/checklist', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const callerId = req.session.userId;
const check = validateItem(req.body || {}, { partial: false, callerId });
if (check.error) return res.status(400).json({ error: check.error });
const f = check.fields;
const sortOrder = 'sort_order' in f ? f.sort_order : nextSortOrder(tripId);
const item = db.transaction(() => {
const info = insertItem.run(
tripId,
f.user_id,
f.text,
f.category,
f.qty,
f.checked,
f.checked_by,
sortOrder,
null
);
return getItemById.get(Number(info.lastInsertRowid));
})();
res.status(201).json({ item: itemJson(item) });
});
// PATCH /checklist/:itemId
router.patch('/checklist/:itemId', (req, res) => {
const row = requireVisibleItem(req, res);
if (!row) return;
const check = validateItem(req.body || {}, { partial: true, callerId: req.session.userId });
if (check.error) return res.status(400).json({ error: check.error });
const keys = Object.keys(check.fields);
if (keys.length > 0) {
const setClause = keys.map((k) => `${k} = ?`).join(', ');
const values = keys.map((k) => check.fields[k]);
db.prepare(`UPDATE checklist_items SET ${setClause} WHERE id = ?`).run(...values, row.id);
}
res.status(200).json({ item: itemJson(getItemById.get(row.id)) });
});
// DELETE /checklist/:itemId
router.delete('/checklist/:itemId', (req, res) => {
const row = requireVisibleItem(req, res);
if (!row) return;
db.prepare('DELETE FROM checklist_items WHERE id = ?').run(row.id);
res.status(204).end();
});
return router;
}
+295
View File
@@ -0,0 +1,295 @@
// Deterministic, offline packing-advice rule engine — see "Packing advice
// (suggestions)" in docs/API.md. Pure: same input always yields the same
// output (no randomness, no network calls, no dependence on today's date).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { haversineKm } from './distance.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AIRPORTS = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'data', 'airports.json'), 'utf8')
);
const COUNTRY_PLUGS = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'data', 'countryPlugs.json'), 'utf8')
);
// Fixed category order shared with checklist items (see docs/API.md).
export const CATEGORY_ORDER = [
'Documents', 'Clothing', 'Toiletries', 'Health', 'Electronics', 'Extras',
];
const LONG_HAUL_KM = 5000;
const TROPICAL_LAT = 23.5;
const COLD_LAT = 55;
// Poleward of this, a local winter month means genuinely cold (Tokyo 35.7,
// Athens 38, New York 40); equatorward of it, winter is still mild (Delhi 28,
// Bangkok 13.7), so warm layers would be bad advice.
const WINTER_COLD_LAT = 35;
const NORTHERN_WINTER = new Set([12, 1, 2]);
const SOUTHERN_WINTER = new Set([6, 7, 8]);
// Airport code -> ISO country (airports.json field is `country`, not
// `iso_country`). Built once from the bundled OurAirports dataset.
const AIRPORT_COUNTRY = new Map(AIRPORTS.map((a) => [a.code, a.country]));
// Country name (lowercased) -> ISO code, for matching free-text location names.
const NAME_TO_ISO = new Map(
Object.entries(COUNTRY_PLUGS).map(([iso, c]) => [c.name.toLowerCase(), iso])
);
function lastCommaSegment(locationName) {
if (typeof locationName !== 'string' || !locationName.trim()) return null;
const parts = locationName.split(',');
const seg = parts[parts.length - 1].trim();
return seg || null;
}
// Month numbers (1-12) spanned by the trip's date range, in chronological
// order, deduped (a >365-day range can't happen, so at most ~13 entries).
function monthsSpanned(startDate, endDate) {
const [sy, sm] = startDate.split('-').map(Number);
const [ey, em] = endDate.split('-').map(Number);
const months = [];
let y = sy;
let m = sm;
while (y < ey || (y === ey && m <= em)) {
if (!months.includes(m)) months.push(m);
m += 1;
if (m > 12) {
m = 1;
y += 1;
}
}
return months;
}
// { lat, lng } for every located stop: entry lat/lng, or flight-segment
// airport coordinates for flight entries carrying segments.
// Places the traveller will actually BE, for climate purposes. For flights we
// take only each segment's arrival airport: the first segment's `from` is
// where they depart from, not a destination — counting it would put "warm
// layers, hat & gloves" on a December Frankfurt→Bangkok beach trip. Landing
// back in a cold place later is still covered, because that shows up as a
// segment's `to`.
function locatedPoints(entries) {
const points = [];
for (const e of entries) {
if (e.type === 'flight' && Array.isArray(e.segments) && e.segments.length) {
const airports = e.segments.map((s) => s.to);
for (const a of airports) {
if (a && typeof a.lat === 'number' && typeof a.lng === 'number') {
points.push({ lat: a.lat, lng: a.lng });
}
}
} else if (typeof e.lat === 'number' && typeof e.lng === 'number') {
points.push({ lat: e.lat, lng: e.lng });
}
}
return points;
}
// Unique country names (last comma-segment of location_name, plus flight
// segment airport codes resolved via the bundled dataset), in order of
// first appearance.
function collectCountries(entries) {
const names = [];
const push = (name) => {
if (name && !names.includes(name)) names.push(name);
};
for (const e of entries) {
push(lastCommaSegment(e.location_name));
if (e.type === 'flight' && Array.isArray(e.segments)) {
// Arrival airports only — same reasoning as locatedPoints(): the origin
// is home, and you don't pack a travel adapter for your own sockets.
for (const seg of e.segments) {
const iso = seg.to && seg.to.code ? AIRPORT_COUNTRY.get(seg.to.code) : null;
const plug = iso ? COUNTRY_PLUGS[iso] : null;
if (plug) push(plug.name);
}
}
}
return names;
}
// Resolve destination country names to a plug-adapter suggestion.
// Unknown or mixed-and-incompatible plug types fall back to "Universal".
function adapterSuggestion(countries) {
const resolved = countries
.map((name) => NAME_TO_ISO.get(name.toLowerCase()))
.filter(Boolean);
if (resolved.length === 0) {
return { text: 'Universal travel adapter', reason: 'Unknown socket type at destination' };
}
const plugSets = resolved.map((iso) => COUNTRY_PLUGS[iso].plugs);
const shared = plugSets[0].filter((p) => plugSets.every((set) => set.includes(p)));
if (shared.length === 0) {
return { text: 'Universal travel adapter', reason: 'Mixed socket types across destinations' };
}
const names = [...new Set(resolved.map((iso) => COUNTRY_PLUGS[iso].name))];
return {
text: `Plug adapter (type ${shared.join('/')})`,
reason: `${names.join(' & ')} uses type ${shared.join('/')} sockets`,
};
}
// tropical: any stop within the tropics. cold: any stop far enough poleward to
// be cold year-round, or a temperate stop visited during its own hemisphere's
// winter. The WINTER_COLD_LAT floor matters: without it a December trip to
// Bangkok (lat 13.7, northern winter) would be tagged both tropical and cold
// and suggest sun cream alongside hat & gloves.
function climates(entries, months) {
const set = new Set();
for (const { lat } of locatedPoints(entries)) {
if (Math.abs(lat) < TROPICAL_LAT) set.add('tropical');
const winterMonths = lat >= 0 ? NORTHERN_WINTER : SOUTHERN_WINTER;
const localWinter = months.some((m) => winterMonths.has(m));
if (Math.abs(lat) > COLD_LAT || (localWinter && Math.abs(lat) >= WINTER_COLD_LAT)) {
set.add('cold');
}
}
return [...set];
}
// Any flight segment whose great-circle distance exceeds the long-haul threshold.
function isLongHaul(entries) {
for (const e of entries) {
if (e.type !== 'flight' || !Array.isArray(e.segments)) continue;
for (const { from, to } of e.segments) {
if (
from && to &&
typeof from.lat === 'number' && typeof from.lng === 'number' &&
typeof to.lat === 'number' && typeof to.lng === 'number' &&
haversineKm(from.lat, from.lng, to.lat, to.lng) > LONG_HAUL_KM
) {
return true;
}
}
}
return false;
}
function plural(n, word) {
return `${n} ${word}${n === 1 ? '' : 's'}`;
}
// Build the trip-derived context plus the ordered suggestion list. `added`
// is not included here — the route layer stamps that in from the caller's
// visible checklist items.
export function buildSuggestions({ trip, entries, days, nights }) {
const months = monthsSpanned(trip.start_date, trip.end_date);
const flights = entries.filter((e) => e.type === 'flight').length;
const rentals = entries.filter((e) => e.type === 'rental').length;
const stays = entries.filter((e) => e.type === 'stay').length;
const transportModes = [
...new Set(
entries
.filter((e) => e.type === 'transport' && e.transport_mode)
.map((e) => e.transport_mode)
),
];
const countries = collectCountries(entries);
const climate = climates(entries, months);
const longHaul = isLongHaul(entries);
const adapter = adapterSuggestion(countries);
const context = { days, nights, months, countries, climate, flights, rentals, transportModes };
const clothingQty = Math.min(nights + 1, 10);
const hasFerry = transportModes.includes('ferry');
const hasTrain = transportModes.includes('train');
const tripLen = plural(days, 'day') + ' trip';
const flightCount = `You have ${plural(flights, 'flight')}`;
// Ordered rule table: `when` gates inclusion, `qty`/`reason` may be static
// or computed from context above. Grouped here by category for readability;
// final ordering is enforced via CATEGORY_ORDER below regardless.
const rules = [
// Documents
{ key: 'doc-passport', text: 'Passport (valid 6+ months)', category: 'Documents',
when: true, reason: flights > 0 ? flightCount : tripLen },
{ key: 'doc-cards-cash', text: 'Cards & local cash', category: 'Documents',
when: true, reason: tripLen },
{ key: 'doc-checkin', text: 'Complete online check-in', category: 'Documents',
when: flights > 0, reason: flightCount },
{ key: 'doc-driving-licence', text: 'Driving licence', category: 'Documents',
when: rentals > 0, reason: 'Rental car booked' },
{ key: 'doc-idp', text: 'International Driving Permit', category: 'Documents',
when: rentals > 0, reason: 'Rental car booked' },
// Clothing
{ key: 'clothing-tshirts', text: 'T-shirts', category: 'Clothing',
when: true, qty: clothingQty, reason: plural(nights, 'night') },
{ key: 'clothing-underwear', text: 'Underwear', category: 'Clothing',
when: true, qty: clothingQty, reason: plural(nights, 'night') },
{ key: 'clothing-socks', text: 'Socks', category: 'Clothing',
when: true, qty: clothingQty, reason: plural(nights, 'night') },
{ key: 'clothing-laundry-kit', text: 'Travel laundry kit (detergent sheets)', category: 'Clothing',
when: nights > 7, reason: 'Over 7 nights' },
{ key: 'clothing-rain-jacket', text: 'Light rain jacket', category: 'Clothing',
when: climate.includes('tropical'), reason: 'Tropical climate' },
{ key: 'clothing-warm-layers', text: 'Warm layers (fleece/base layer)', category: 'Clothing',
when: climate.includes('cold'), reason: 'Cold climate' },
{ key: 'clothing-hat-gloves', text: 'Hat & gloves', category: 'Clothing',
when: climate.includes('cold'), reason: 'Cold climate' },
// Toiletries
{ key: 'toiletries-toothbrush', text: 'Toothbrush & toothpaste', category: 'Toiletries',
when: true, reason: tripLen },
{ key: 'toiletries-liquids-100ml', text: 'Liquids in containers ≤100 ml (TSA bag)', category: 'Toiletries',
when: flights > 0, reason: flightCount },
{ key: 'toiletries-sun-cream', text: 'Sun cream (SPF 30+)', category: 'Toiletries',
when: climate.includes('tropical'), reason: 'Tropical climate' },
// Health
{ key: 'health-medication', text: 'Personal medication', category: 'Health',
when: true, reason: tripLen },
{ key: 'health-compression-socks', text: 'Compression socks', category: 'Health',
when: longHaul, reason: 'Long-haul flight (>5000 km)' },
{ key: 'health-motion-sickness', text: 'Motion-sickness tablets', category: 'Health',
when: hasFerry, reason: 'Ferry crossing' },
{ key: 'health-insect-repellent', text: 'Insect repellent', category: 'Health',
when: climate.includes('tropical'), reason: 'Tropical climate' },
{ key: 'health-rehydration-salts', text: 'Rehydration salts', category: 'Health',
when: climate.includes('tropical'), reason: 'Tropical climate' },
// Electronics
{ key: 'electronics-phone-charger', text: 'Phone & charger', category: 'Electronics',
when: true, reason: tripLen },
{ key: 'electronics-power-bank', text: 'Power bank (pack in carry-on)', category: 'Electronics',
when: flights > 0, reason: flightCount },
{ key: 'electronics-phone-mount', text: 'Phone mount', category: 'Electronics',
when: rentals > 0, reason: 'Rental car booked' },
{ key: 'electronics-adapter', text: adapter.text, category: 'Electronics',
when: countries.length > 0, reason: adapter.reason },
// Extras
{ key: 'extras-water-bottle', text: 'Reusable water bottle', category: 'Extras',
when: true, reason: tripLen },
{ key: 'extras-day-bag', text: 'Day bag / daypack', category: 'Extras',
when: true, reason: tripLen },
{ key: 'extras-neck-pillow', text: 'Neck pillow', category: 'Extras',
when: longHaul, reason: 'Long-haul flight (>5000 km)' },
{ key: 'extras-snacks', text: 'Snacks for the journey', category: 'Extras',
when: hasTrain, reason: 'Train travel' },
{ key: 'extras-luggage-lock', text: 'Luggage lock', category: 'Extras',
when: hasTrain, reason: 'Train travel' },
{ key: 'extras-packing-cubes', text: 'Packing cubes', category: 'Extras',
when: stays >= 3, reason: `${stays} stays` },
];
const byCategory = new Map(CATEGORY_ORDER.map((c) => [c, []]));
for (const rule of rules) {
if (!rule.when) continue;
byCategory.get(rule.category).push({
key: rule.key,
text: rule.text,
category: rule.category,
qty: rule.qty ?? null,
reason: rule.reason,
});
}
const suggestions = CATEGORY_ORDER.flatMap((c) => byCategory.get(c));
return { suggestions, context };
}