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:
@@ -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());
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user