Initial release: collaborative trip planner

Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
// 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),
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`),
},
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)}`),
};
export default api;
+171
View File
@@ -0,0 +1,171 @@
// App bootstrap: auth check, top nav, and a tiny hash router.
// Routes: #/login, #/trips, #/trip/:id
import { api } from './api.js';
import { el, clear, mount, toast, loading } from './dom.js';
import { renderAuth } from './views/auth.js';
import { renderTrips } from './views/trips.js';
import { renderTripDetail } from './views/tripDetail.js';
const state = { user: null };
function root() {
return document.getElementById('app');
}
function navigate(hash) {
if (location.hash === hash) route();
else location.hash = hash;
}
// Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } }
function parseHash() {
const raw = (location.hash || '').replace(/^#/, '');
const parts = raw.split('/').filter(Boolean);
if (parts.length === 0) return { name: 'trips', params: {} };
if (parts[0] === 'login') return { name: 'login', params: {} };
if (parts[0] === 'trips') return { name: 'trips', params: {} };
if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1] } };
return { name: 'trips', params: {} };
}
function renderNav() {
const nav = el(
'header',
{ class: 'topnav' },
el(
'a',
{ class: 'brand', href: '#/trips' },
el('span', { class: 'brand-mark' }, '🧭'),
el('span', { class: 'brand-name' }, 'Trip Plan'),
),
el(
'div',
{ class: 'nav-right' },
state.user ? renderUserArea() : null,
state.user ? el('button', { class: 'btn btn-ghost', onClick: onLogout }, 'Log out') : null,
),
);
return nav;
}
// Current user's display name with an inline pencil-edit (PATCH /api/auth/me).
function renderUserArea() {
const area = el('span', { class: 'nav-user' });
function initial() {
return (state.user.display_name || '?').charAt(0).toUpperCase();
}
function showDisplay() {
mount(
area,
el('span', { class: 'nav-avatar' }, initial()),
el('span', { class: 'nav-name' }, state.user.display_name || 'me'),
el('button', { class: 'nav-edit', title: 'Edit name', type: 'button', onClick: showEdit }, '✎'),
);
}
function showEdit() {
const input = el('input', {
class: 'input input-sm nav-name-input',
type: 'text',
maxlength: '40',
value: state.user.display_name || '',
'aria-label': 'Display name',
});
async function save() {
const dn = input.value.trim();
if (!dn) return showDisplay();
try {
const data = await api.auth.updateMe({ display_name: dn });
state.user = data.user;
showDisplay();
} catch (err) {
toast(err.message);
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
else if (e.key === 'Escape') showDisplay();
});
mount(
area,
input,
el('button', { class: 'btn btn-sm btn-primary', type: 'button', onClick: save }, 'Save'),
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: showDisplay }, 'Cancel'),
);
input.focus();
input.select();
}
showDisplay();
return area;
}
async function onLogout() {
try {
await api.auth.logout();
} catch (e) {
// Even if the request fails, drop local state.
}
state.user = null;
navigate('#/login');
}
function render() {
const view = parseHash();
// Defensively remove any body-level overlay (e.g. an open day editor) so it
// can never orphan on top of a freshly rendered view.
document.querySelectorAll('.overlay').forEach((n) => n.remove());
document.body.classList.remove('no-scroll');
// Auth guards.
if (!state.user && view.name !== 'login') {
navigate('#/login');
return;
}
if (state.user && view.name === 'login') {
navigate('#/trips');
return;
}
const container = root();
clear(container);
if (state.user) container.appendChild(renderNav());
const viewEl = el('main', { class: 'view', id: 'view' });
container.appendChild(viewEl);
const ctx = { state, navigate, refresh: render };
if (view.name === 'login') renderAuth(viewEl, ctx);
else if (view.name === 'trips') renderTrips(viewEl, ctx);
else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id);
}
// Exposed so views can update the current user after login/register.
function route() {
render();
}
async function bootstrap() {
const container = root();
clear(container);
container.appendChild(loading('Starting Trip Plan…'));
try {
const data = await api.auth.me();
state.user = data && data.user ? data.user : null;
} catch (e) {
state.user = null; // 401 is expected when logged out.
}
window.addEventListener('hashchange', route);
// render()'s guards redirect a signed-out visitor to #/login and a
// signed-in one away from it, so a single render() is enough here.
render();
}
bootstrap();
+91
View File
@@ -0,0 +1,91 @@
// Tiny DOM helpers — no framework, just ergonomic element creation.
// el('div', { class: 'x', onClick: fn }, child, child, ...)
// Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn),
// boolean true -> present attribute, anything else -> setAttribute.
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, val] of Object.entries(attrs || {})) {
if (val == null || val === false) continue;
if (key === 'class') node.className = val;
else if (key === 'dataset') Object.assign(node.dataset, val);
else if (key === 'style' && typeof val === 'object') {
// Custom properties (--x) must go through setProperty; plain assignment
// silently drops them.
for (const [prop, pv] of Object.entries(val)) {
if (pv == null) continue;
if (prop.startsWith('--')) node.style.setProperty(prop, pv);
else node.style[prop] = pv;
}
}
else if (key === 'html') node.innerHTML = val;
else if (key === 'value') node.value = val;
else if (key.startsWith('on') && typeof val === 'function')
node.addEventListener(key.slice(2).toLowerCase(), val);
else if (val === true) node.setAttribute(key, '');
else node.setAttribute(key, val);
}
appendAll(node, children);
return node;
}
function appendAll(node, children) {
for (const child of children.flat(Infinity)) {
if (child == null || child === false || child === true) continue;
node.appendChild(
typeof child === 'string' || typeof child === 'number'
? document.createTextNode(String(child))
: child,
);
}
}
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
}
export function mount(container, ...children) {
clear(container);
appendAll(container, children);
return container;
}
export function loading(text = 'Loading…') {
return el('div', { class: 'loading' }, el('span', { class: 'spinner' }), text);
}
export function errorBox(message, onRetry) {
return el(
'div',
{ class: 'error-box' },
el('p', {}, message || 'Something went wrong.'),
onRetry ? el('button', { class: 'btn', onClick: onRetry }, 'Retry') : null,
);
}
export function emptyState(title, subtitle, action) {
return el(
'div',
{ class: 'empty-state' },
el('div', { class: 'empty-icon' }, '🧭'),
el('h3', {}, title),
subtitle ? el('p', {}, subtitle) : null,
action || null,
);
}
let toastHost = null;
export function toast(message, type = 'error', ms = 4200) {
if (!toastHost) {
toastHost = el('div', { class: 'toast-host' });
document.body.appendChild(toastHost);
}
const node = el('div', { class: `toast toast-${type}` }, message);
toastHost.appendChild(node);
requestAnimationFrame(() => node.classList.add('show'));
setTimeout(() => {
node.classList.remove('show');
setTimeout(() => node.remove(), 300);
}, ms);
}
+148
View File
@@ -0,0 +1,148 @@
// Shared formatting helpers and the canonical entry-type palette.
// The type config here is the single source of truth for icon + color,
// reused by the calendar chips, map markers/popups, and the day editor.
// Order here drives the day-editor type picker and the calendar legend, so it
// is roughly most-common-first with Activity as the default for new entries.
export const ENTRY_TYPES = {
activity: { label: 'Activity', icon: '📍', color: '#059669' },
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
note: { label: 'Note', icon: '📝', color: '#64748b' },
};
export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) => ({
value,
...meta,
}));
export function typeInfo(type) {
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
}
// Split modes with the human labels the day-editor select shows.
export const SPLIT_MODES = [
{ value: 'equal', label: 'Split equally' },
{ value: 'own', label: 'Everyone pays their own (price per person)' },
{ value: 'payer', label: "Payer's own expense" },
];
export function splitModeLabel(mode) {
const found = SPLIT_MODES.find((m) => m.value === mode);
return found ? found.label : mode;
}
// Account tokens / join codes: strip separators + uppercase, or regroup for
// display. Works whether the server sends the value raw or already grouped.
export function normalizeCode(str) {
return String(str || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
}
export function groupCode(str, size = 4) {
const raw = normalizeCode(str);
const groups = raw.match(new RegExp(`.{1,${size}}`, 'g'));
return groups ? groups.join('-') : raw;
}
// Money as "1,234.56 USD". `compact` drops trailing zeros ("1,200 THB").
export function formatMoney(amount, currency = 'USD', { compact = false } = {}) {
const n = Number(amount) || 0;
const s = n.toLocaleString(undefined, compact
? { maximumFractionDigits: 2 }
: { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `${s} ${currency}`;
}
// Parse a YYYY-MM-DD string as a *local* date (avoid UTC off-by-one).
export function parseYMD(str) {
const [y, m, d] = String(str).split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
export function ymd(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function addDays(date, n) {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
export function daysBetweenInclusive(startStr, endStr) {
const a = parseYMD(startStr);
const b = parseYMD(endStr);
return Math.round((b - a) / 86400000) + 1;
}
export function eachDay(startStr, endStr) {
const out = [];
let d = parseYMD(startStr);
const end = parseYMD(endStr);
while (d <= end) {
out.push(ymd(d));
d = addDays(d, 1);
}
return out;
}
// Monday-based start of the week containing `date`.
export function startOfWeekMon(date) {
const d = new Date(date);
const offset = (d.getDay() + 6) % 7; // 0 = Monday
return addDays(d, -offset);
}
export function formatDate(str, opts = { month: 'short', day: 'numeric' }) {
return parseYMD(str).toLocaleDateString(undefined, opts);
}
export function formatFullDate(str) {
return parseYMD(str).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
export function formatRange(startStr, endStr) {
const s = parseYMD(startStr);
const e = parseYMD(endStr);
const sameYear = s.getFullYear() === e.getFullYear();
const sOpts = sameYear
? { month: 'short', day: 'numeric' }
: { month: 'short', day: 'numeric', year: 'numeric' };
const eOpts = { month: 'short', day: 'numeric', year: 'numeric' };
return `${s.toLocaleDateString(undefined, sOpts)} ${e.toLocaleDateString(undefined, eOpts)}`;
}
export function formatTimeRange(start, end) {
if (start && end) return `${start}${end}`;
return start || end || '';
}
export function pluralize(n, one, many) {
return `${n} ${n === 1 ? one : many || one + 's'}`;
}
// "CNX→BKK→DXB→FRA" from a flight entry's segments array.
export function flightChain(segments) {
if (!Array.isArray(segments) || segments.length === 0) return '';
const codes = [segments[0]?.from?.code, ...segments.map((s) => s?.to?.code)].filter(Boolean);
return codes.join('→');
}
export function hasSegments(entry) {
return entry && Array.isArray(entry.segments) && entry.segments.length > 0;
}
export function hasRental(entry) {
return entry && entry.rental && typeof entry.rental === 'object';
}
+162
View File
@@ -0,0 +1,162 @@
// Login view — Mullvad-style account tokens (no username/password).
// Two panels: create a new account (one-time token reveal) or log in with an
// existing account number.
import { api } from '../api.js';
import { el, mount, toast } from '../dom.js';
import { groupCode, normalizeCode } from '../format.js';
export function renderAuth(container, ctx) {
let mode = 'create'; // 'create' | 'login'
function draw() {
const card = el(
'div',
{ class: 'auth-card card' },
el(
'div',
{ class: 'auth-head' },
el('div', { class: 'brand-mark brand-mark-lg' }, '🧭'),
el('h1', {}, 'Trip Plan'),
el('p', { class: 'muted' }, 'Plan trips together, day by day.'),
),
el(
'div',
{ class: 'auth-tabs' },
tab('Create account', mode === 'create', () => setMode('create')),
tab('Log in', mode === 'login', () => setMode('login')),
),
mode === 'create' ? createPanel() : loginPanel(),
);
mount(container, el('div', { class: 'auth-wrap' }, card));
}
function setMode(next) {
if (mode !== next) {
mode = next;
draw();
}
}
function tab(label, active, onClick) {
return el('button', { class: `auth-tab${active ? ' active' : ''}`, type: 'button', onClick }, label);
}
// ----- Create account -----
function createPanel() {
const errorEl = el('p', { class: 'form-error' });
const createBtn = el('button', { class: 'btn btn-primary btn-block', type: 'button' }, 'Create account');
async function onCreate() {
errorEl.textContent = '';
createBtn.disabled = true;
createBtn.textContent = 'Creating…';
try {
const data = await api.auth.createAccount();
showToken(data.token, data.user);
} catch (err) {
errorEl.textContent = err.message;
createBtn.disabled = false;
createBtn.textContent = 'Create account';
}
}
createBtn.addEventListener('click', onCreate);
return el(
'div',
{ class: 'auth-panel' },
el('p', { class: 'auth-lead' }, 'No email, no password. We generate a private account number — it is your only key to your trips.'),
errorEl,
createBtn,
);
}
function showToken(token, user) {
const grouped = groupCode(token, 4);
const copyBtn = el('button', { class: 'btn', type: 'button' }, '📋 Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(grouped);
toast('Account number copied', 'success');
} catch {
toast('Copy failed — select and copy it manually');
}
});
const panel = el(
'div',
{ class: 'auth-panel token-reveal' },
el('div', { class: 'token-warning' },
el('strong', {}, 'This is your only credential.'),
' Save it now — it will never be shown again. Anyone with it can access your trips.'),
el('div', { class: 'token-box' }, grouped),
el('div', { class: 'token-actions' }, copyBtn),
el(
'button',
{
class: 'btn btn-primary btn-block',
type: 'button',
onClick: () => {
ctx.state.user = user;
ctx.navigate('#/trips');
},
},
"I've saved it — continue",
),
);
mount(container, el('div', { class: 'auth-wrap' }, el('div', { class: 'auth-card card' },
el('div', { class: 'auth-head' },
el('div', { class: 'brand-mark brand-mark-lg' }, '🎉'),
el('h1', {}, 'Account created'),
el('p', { class: 'muted' }, `You're ${user.display_name}. You can rename yourself later.`)),
panel,
)));
}
// ----- Log in -----
function loginPanel() {
const errorEl = el('p', { class: 'form-error' });
const input = el('input', {
class: 'input token-input',
type: 'text',
autocomplete: 'off',
autocapitalize: 'characters',
spellcheck: 'false',
placeholder: 'XXXX-XXXX-XXXX-XXXX',
'aria-label': 'Account number',
});
const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const token = normalizeCode(input.value);
if (token.length < 8) {
errorEl.textContent = 'Enter your full account number.';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
try {
const data = await api.auth.login(token);
ctx.state.user = data.user;
ctx.navigate('#/trips');
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Log in';
}
}
return el(
'form',
{ class: 'auth-panel auth-form', onSubmit },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Account number'), input),
el('p', { class: 'hint muted' }, 'Dashes, spaces and letter case do not matter.'),
errorEl,
submitBtn,
);
}
draw();
}
+185
View File
@@ -0,0 +1,185 @@
// Calendar grid for the trip's date range. Real weeks as rows (MonSun
// columns); days outside the range are greyed. Each in-range day shows its
// entries as compact, type-coloured chips. Clicking a day opens the editor.
import { el } from '../dom.js';
import {
ENTRY_TYPES,
typeInfo,
parseYMD,
ymd,
addDays,
startOfWeekMon,
formatMoney,
flightChain,
hasSegments,
hasRental,
} from '../format.js';
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function renderCalendar(tctx) {
const { trip, entries } = tctx.trip;
const currency = trip.currency || 'USD';
// Group entries by date for quick per-cell lookup.
const byDate = new Map();
// Derived "dropoff" chips: a rental whose dropoff day differs from its
// (pickup) entry date gets a secondary chip on the dropoff day.
const dropoffByDate = new Map();
for (const entry of entries) {
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
byDate.get(entry.date).push(entry);
if (hasRental(entry)) {
const dropDate = entry.rental.dropoff && entry.rental.dropoff.date;
if (dropDate && dropDate !== entry.date) {
if (!dropoffByDate.has(dropDate)) dropoffByDate.set(dropDate, []);
dropoffByDate.get(dropDate).push(entry);
}
}
}
const rangeStart = parseYMD(trip.start_date);
const rangeEnd = parseYMD(trip.end_date);
const gridStart = startOfWeekMon(rangeStart);
const section = el(
'section',
{ class: 'card calendar-section' },
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Calendar'),
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
),
legend(),
);
const grid = el('div', { class: 'calendar-grid' });
for (const label of WEEKDAYS) {
grid.appendChild(el('div', { class: 'cal-weekday' }, label));
}
// Walk whole weeks from gridStart until we've passed the range end.
let cursor = gridStart;
let guard = 0;
while (cursor <= rangeEnd && guard < 400) {
for (let i = 0; i < 7; i++) {
grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency));
cursor = addDays(cursor, 1);
}
guard += 7;
}
section.appendChild(grid);
return section;
}
function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) {
const key = ymd(date);
const inRange = date >= rangeStart && date <= rangeEnd;
const dayEntries = byDate.get(key) || [];
const dropoffs = dropoffByDate.get(key) || [];
const isFirstOfMonth = date.getDate() === 1;
const cell = el('div', {
class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' cal-has' : ''}`,
});
cell.appendChild(
el(
'div',
{ class: 'cal-day-head' },
el('span', { class: 'cal-daynum' }, String(date.getDate())),
isFirstOfMonth
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
: null,
!inRange && (dayEntries.length || dropoffs.length)
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
: null,
),
);
const chips = el('div', { class: 'cal-chips' });
for (const entry of dayEntries) chips.appendChild(chip(entry, currency));
// Secondary dropoff chips: clicking opens the pickup day where the entry lives.
for (const entry of dropoffs) chips.appendChild(dropoffChip(entry, tctx));
cell.appendChild(chips);
// In-range days are always clickable; out-of-range days only when they
// hold entries or a derived dropoff chip.
if (inRange || dayEntries.length || dropoffs.length) {
cell.classList.add('clickable');
cell.tabIndex = 0;
cell.setAttribute('role', 'button');
cell.addEventListener('click', () => tctx.openDay(key));
cell.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tctx.openDay(key);
}
});
}
return cell;
}
function chip(entry, currency) {
const info = typeInfo(entry.type);
const hasPrice = entry.price != null;
const chain = hasSegments(entry) ? flightChain(entry.segments) : '';
const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : '';
const label = chain || car || entry.title;
return el(
'div',
{
class: 'cal-chip',
style: { '--chip': info.color },
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
},
el('span', { class: 'chip-icon' }, info.icon),
el('span', { class: 'chip-text' }, label),
hasPrice
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
: null,
);
}
// Secondary, outlined chip shown on a rental's dropoff day. Clicking opens the
// PICKUP day's editor (the day the entry actually lives on).
function dropoffChip(entry, tctx) {
const info = typeInfo(entry.type);
const car = [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ');
const node = el(
'div',
{
class: 'cal-chip cal-chip-dropoff',
style: { '--chip': info.color },
role: 'button',
tabindex: '0',
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
},
el('span', { class: 'chip-icon' }, info.icon),
el('span', { class: 'chip-text' }, 'dropoff'),
);
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
node.addEventListener('click', open);
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
return node;
}
function legend() {
const wrap = el('div', { class: 'legend' });
for (const [type, info] of Object.entries(ENTRY_TYPES)) {
wrap.appendChild(
el(
'span',
{ class: 'legend-item', 'data-type': type },
el('span', { class: 'legend-dot', style: { background: info.color } }),
el('span', {}, `${info.icon} ${info.label}`),
),
);
}
return wrap;
}
+86
View File
@@ -0,0 +1,86 @@
// Cost & splitting subsection for the day editor. Self-contained so dayEditor
// stays small. read() returns the cost fields for the entry payload:
// { price: null } — no cost
// { price, paid_by, split_mode, participants } — cost set
// { error } — validation message
import { el } from '../dom.js';
import { SPLIT_MODES } from '../format.js';
export function createCostForm({ members, currency }) {
const priceInput = el('input', { class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00' });
const payerSelect = el(
'select',
{ class: 'input' },
el('option', { value: '' }, '— unassigned —'),
...members.map((m) => el('option', { value: String(m.id) }, m.display_name)),
);
const modeSelect = el(
'select',
{ class: 'input' },
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
);
const participantChecks = members.map((m) =>
el('input', { type: 'checkbox', class: 'part-check', value: String(m.id), checked: true }),
);
const participantsBox = el(
'div',
{ class: 'participants' },
...members.map((m, i) =>
el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name)),
),
);
const costDetail = el(
'div',
{ class: 'cost-detail' },
el('div', { class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect)),
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
);
const node = el(
'div',
{ class: 'cost-section' },
el('div', { class: 'form-row' },
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Price (${currency})`), priceInput)),
costDetail,
);
// Dim the payer/split/participants controls until a price is entered.
function syncVisibility() {
costDetail.classList.toggle('disabled', priceInput.value.trim() === '');
}
priceInput.addEventListener('input', syncVisibility);
function read() {
const priceRaw = priceInput.value.trim();
if (priceRaw === '') return { price: null };
const price = Number(priceRaw);
if (!Number.isFinite(price) || price < 0) return { error: 'Price must be a number ≥ 0.' };
const split_mode = modeSelect.value;
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
if (split_mode === 'payer' && paid_by == null) {
return { error: "Choose who paid for a payer's own expense." };
}
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
if (checked.length === 0) {
return { error: 'Select at least one participant (or clear the price to drop the cost).' };
}
// [] means "all trip members"; only send an explicit list for a subset.
const participants = checked.length === members.length ? [] : checked;
return { price, paid_by, split_mode, participants };
}
function prefill(entry) {
priceInput.value = entry.price != null ? String(entry.price) : '';
payerSelect.value = entry.paid_by != null ? String(entry.paid_by) : '';
modeSelect.value = entry.split_mode || 'equal';
const parts = Array.isArray(entry.participants) ? entry.participants : [];
for (const cb of participantChecks) {
cb.checked = parts.length === 0 || parts.includes(Number(cb.value));
}
syncVisibility();
}
syncVisibility();
return { node, read, prefill };
}
+148
View File
@@ -0,0 +1,148 @@
// Costs & splitting panel from GET /api/trips/:id/costs.
// Total + currency, breakdown by entry type, a per-user share/paid/net table
// (net colored green when owed to them, red when they owe), a settle-up list,
// and a hint when some priced entries have no payer assigned.
import { el } from '../dom.js';
import { typeInfo, formatMoney } from '../format.js';
export function renderCosts(tctx) {
const costs = tctx.costs || {};
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
const total = costs.totalCost || 0;
const perUser = costs.perUser || [];
const byType = costs.byType || {};
const settlements = costs.settlements || [];
const unassigned = costs.unassigned || 0;
const money = (n) => formatMoney(n, currency);
const nameById = new Map(perUser.map((u) => [u.userId, u.displayName]));
const memberName = (id) => {
if (nameById.has(id)) return nameById.get(id);
const m = (tctx.trip.members || []).find((x) => x.id === id);
return m ? m.display_name : `user ${id}`;
};
const section = el('section', { class: 'card costs-section' });
section.appendChild(
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Costs'),
el('p', { class: 'muted' }, 'Who owes whom, split across the trip.'),
),
);
const hasCosts = total > 0 || perUser.some((u) => u.share || u.paid);
if (!hasCosts) {
section.appendChild(
el(
'div',
{ class: 'costs-empty' },
el('div', { class: 'empty-icon' }, '💰'),
el('p', {}, 'No costs tracked yet.'),
el('p', { class: 'muted' }, 'Add a price to an entry to start splitting expenses.'),
),
);
return section;
}
// Total.
section.appendChild(
el(
'div',
{ class: 'costs-total' },
el('span', { class: 'costs-total-value' }, money(total)),
el('span', { class: 'costs-total-label' }, 'total trip cost'),
),
);
if (unassigned > 0) {
section.appendChild(
el(
'div',
{ class: 'costs-warn' },
`${money(unassigned)} of priced entries have no payer assigned — assign payers to settle them.`,
),
);
}
// Breakdown by type.
const typeKeys = Object.keys(byType);
if (typeKeys.length) {
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
for (const type of typeKeys) {
const info = typeInfo(type);
bt.appendChild(
el(
'div',
{ class: 'bytype-row' },
el('span', { class: 'bytype-dot', style: { background: info.color } }),
el('span', { class: 'bytype-name' }, `${info.icon} ${info.label}`),
el('span', { class: 'bytype-amount' }, money(byType[type])),
),
);
}
section.appendChild(bt);
}
// Per-user table.
if (perUser.length) {
const table = el(
'table',
{ class: 'cost-table' },
el(
'thead',
{},
el(
'tr',
{},
el('th', {}, 'Member'),
el('th', { class: 'num' }, 'Share'),
el('th', { class: 'num' }, 'Paid'),
el('th', { class: 'num' }, 'Net'),
),
),
el(
'tbody',
{},
...perUser.map((u) => {
const net = u.net || 0;
const netClass = net > 0.004 ? 'net-pos' : net < -0.004 ? 'net-neg' : 'net-zero';
const netText = `${net > 0 ? '+' : ''}${money(net)}`;
return el(
'tr',
{},
el('td', {}, u.displayName),
el('td', { class: 'num' }, money(u.share || 0)),
el('td', { class: 'num' }, money(u.paid || 0)),
el('td', { class: `num ${netClass}` }, netText),
);
}),
),
);
section.appendChild(el('div', { class: 'cost-block' }, el('h3', {}, 'Per person'), table));
}
// Settle-up list.
const settleBlock = el('div', { class: 'cost-block' }, el('h3', {}, 'Settle up'));
if (!settlements.length) {
settleBlock.appendChild(el('p', { class: 'muted' }, 'All square — no transfers needed.'));
} else {
for (const s of settlements) {
settleBlock.appendChild(
el(
'div',
{ class: 'settle-row' },
el('span', { class: 'settle-from' }, memberName(s.fromUserId)),
el('span', { class: 'settle-arrow' }, '→'),
el('span', { class: 'settle-to' }, memberName(s.toUserId)),
el('span', { class: 'settle-amount' }, money(s.amount)),
),
);
}
}
section.appendChild(settleBlock);
return section;
}
+443
View File
@@ -0,0 +1,443 @@
// Slide-over panel for a single day: lists existing entries (edit/delete) and
// a form to add/update one, including a debounced geocode-backed location
// autocomplete. On any change it calls tctx.refreshTrip() so the calendar,
// map and summary update; while open it registers tctx._onModalRefresh so
// this panel re-renders itself from the freshly fetched trip data too.
import { api } from '../api.js';
import { el, clear, mount, toast } from '../dom.js';
import {
ENTRY_TYPE_LIST,
splitModeLabel,
typeInfo,
formatFullDate,
formatTimeRange,
formatMoney,
flightChain,
hasSegments,
hasRental,
} from '../format.js';
import { createFlightRoute, renderSegmentLines } from './segments.js';
import { createRentalDetails, renderRentalLine } from './rental.js';
import { createCostForm } from './costForm.js';
export function openDayEditor(tctx, date) {
// A form-state object for the entry currently being added/edited.
let editing = null; // entry id being edited, or null for a new entry
let loc = null; // { name, lat, lng } | null
const members = () => tctx.trip.members || [];
const currency = () => (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
const memberName = (id) => {
const m = members().find((x) => x.id === id);
return m ? m.display_name : `user ${id}`;
};
const overlay = el('div', { class: 'overlay' });
const panel = el('aside', { class: 'slideover', role: 'dialog', 'aria-modal': 'true' });
overlay.appendChild(panel);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
function close() {
tctx._onModalRefresh = null;
document.body.classList.remove('no-scroll');
overlay.remove();
document.removeEventListener('keydown', onKey);
window.removeEventListener('hashchange', close);
}
function onKey(e) {
if (e.key === 'Escape') close();
}
document.addEventListener('keydown', onKey);
// Self-close on any navigation so the overlay never orphans over another view.
window.addEventListener('hashchange', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
// Re-render this panel whenever the underlying trip data changes.
tctx._onModalRefresh = () => draw();
function entriesForDate() {
return (tctx.trip.entries || []).filter((e) => e.date === date);
}
function draw() {
const dayEntries = entriesForDate();
const header = el(
'div',
{ class: 'slideover-head' },
el(
'div',
{},
el('h2', {}, formatFullDate(date)),
el('p', { class: 'muted' }, dayEntries.length
? `${dayEntries.length} ${dayEntries.length === 1 ? 'entry' : 'entries'}`
: 'No entries yet'),
),
el('button', { class: 'icon-btn', title: 'Close', onClick: close }, '×'),
);
const list = el('div', { class: 'entry-list' });
if (!dayEntries.length) {
list.appendChild(el('p', { class: 'muted entry-empty' }, 'Nothing planned for this day yet.'));
} else {
for (const entry of dayEntries) list.appendChild(entryRow(entry));
}
mount(panel, header, list, formSection(dayEntries));
panel.scrollTop = 0;
}
function entryRow(entry) {
const info = typeInfo(entry.type);
const time = formatTimeRange(entry.start_time, entry.end_time);
const hasPrice = entry.price != null;
const flight = hasSegments(entry);
return el(
'div',
{ class: 'entry-row', style: { '--chip': info.color } },
el('span', { class: 'entry-icon' }, info.icon),
el(
'div',
{ class: 'entry-body' },
el(
'div',
{ class: 'entry-title-row' },
el('span', { class: 'entry-title' }, entry.title),
hasPrice
? el('span', { class: 'entry-price' }, formatMoney(entry.price, currency(), { compact: true }))
: null,
),
el(
'div',
{ class: 'entry-sub muted' },
info.label,
time ? ` · ${time}` : '',
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
),
flight ? renderSegmentLines(entry.segments) : null,
hasRental(entry) ? renderRentalLine(entry.rental) : null,
hasPrice
? el(
'div',
{ class: 'entry-cost muted' },
`💰 ${splitModeLabel(entry.split_mode)}`,
entry.paid_by != null ? ` · paid by ${memberName(entry.paid_by)}` : ' · no payer set',
)
: null,
entry.details ? el('div', { class: 'entry-details' }, entry.details) : null,
),
el(
'div',
{ class: 'entry-actions' },
el('button', { class: 'icon-btn', title: 'Edit', onClick: () => startEdit(entry) }, '✎'),
el('button', { class: 'icon-btn danger', title: 'Delete', onClick: () => onDelete(entry) }, '🗑'),
),
);
}
function startEdit(entry) {
editing = entry.id;
loc = entry.lat != null && entry.lng != null
? { name: entry.location_name || '', lat: entry.lat, lng: entry.lng }
: null;
draw();
// Populate fields from the entry after (re)draw.
fields.type.value = entry.type;
fields.title.value = entry.title;
fields.details.value = entry.details || '';
fields.start.value = entry.start_time || '';
fields.end.value = entry.end_time || '';
fields.cost.prefill(entry);
// Flight segments (load() triggers the flight-route onChange -> UI sync).
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
fields.rentalDetails.load(entry.rental || null);
renderLoc();
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
fields.title.focus();
}
// Field references for the current form render, so edit can populate them.
let fields = {};
function formSection(dayEntries) {
const typeSelect = el(
'select',
{ class: 'input' },
...ENTRY_TYPE_LIST.map((t) => el('option', { value: t.value }, `${t.icon} ${t.label}`)),
);
const titleInput = el('input', { class: 'input', type: 'text', maxlength: '200', placeholder: 'Title (e.g. Flight BKK → CNX)' });
const detailsInput = el('textarea', { class: 'input', rows: '2', placeholder: 'Details (optional)' });
const startInput = el('input', { class: 'input', type: 'time' });
const endInput = el('input', { class: 'input', type: 'time' });
const locWrap = el('div', { class: 'loc-field' });
const locInput = el('input', {
class: 'input',
type: 'text',
placeholder: 'Search a place (OpenStreetMap)…',
autocomplete: 'off',
});
const locResults = el('div', { class: 'loc-results' });
const locSelected = el('div', { class: 'loc-selected' });
locWrap.append(locInput, locResults, locSelected);
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
// ----- Flight route subsection (shown only for flight entries) -----
// The sub-modules fire onChange during construction (load of initial
// state), before the section elements below exist — gate until wired.
let typeUIReady = false;
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
const flightSection = el(
'div',
{ class: 'flight-section' },
el('div', { class: 'cost-heading' }, 'Flight route (optional)'),
el('p', { class: 'hint muted' }, 'Add legs for a multi-stop flight; airports plot the route on the map.'),
flightRoute.node,
);
// ----- Rental details subsection (shown only for rental entries) -----
const rentalDetails = createRentalDetails({ entryDate: date, onChange: () => { if (typeUIReady) syncTypeUI(); } });
const rentalSection = el(
'div',
{ class: 'rental-section' },
el('div', { class: 'cost-heading' }, 'Rental details'),
rentalDetails.node,
);
// Show the flight route for flights and the rental block for rentals; hide
// the generic location field only once flight segments exist (route then
// comes from the airport coords). Rentals keep the location field — their
// pickup/dropoff are plain text and don't feed the route.
function syncTypeUI() {
const type = typeSelect.value;
flightSection.style.display = type === 'flight' ? '' : 'none';
rentalSection.style.display = type === 'rental' ? '' : 'none';
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
}
typeSelect.addEventListener('change', syncTypeUI);
typeUIReady = true;
syncTypeUI();
// ----- Cost subsection (self-contained module) -----
const costForm = createCostForm({ members: members(), currency: currency() });
fields = {
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
locInput, locResults, locSelected,
cost: costForm, flightRoute, rentalDetails,
};
wireGeocode(locInput, locResults);
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, editing ? 'Save entry' : 'Add entry');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const title = titleInput.value.trim();
if (!title) return (errorEl.textContent = 'Title is required.');
const payload = {
date,
type: typeSelect.value,
title,
details: detailsInput.value.trim(),
start_time: startInput.value || null,
end_time: endInput.value || null,
location_name: loc ? loc.name : null,
lat: loc ? loc.lat : null,
lng: loc ? loc.lng : null,
};
if (!editing) {
payload.sort_order = dayEntries.length;
}
// Flight segments (flight entries only). Clear them otherwise so changing
// an entry's type away from flight drops any prior segments.
if (typeSelect.value === 'flight') {
const res = flightRoute.read();
if (res.error) return (errorEl.textContent = res.error);
payload.segments = res.segments; // array or null
// When segments exist, the route comes from the airports — clear the
// generic location so it doesn't add a stray stop.
if (res.segments) {
payload.location_name = null;
payload.lat = null;
payload.lng = null;
}
} else {
payload.segments = null;
}
// Rental details (rental entries only). The entry's own date follows the
// pickup date so it lives on the pickup day.
if (typeSelect.value === 'rental') {
const res = rentalDetails.read();
if (res.error) return (errorEl.textContent = res.error);
payload.rental = res.rental; // object or null
if (res.pickupDate) payload.date = res.pickupDate;
} else {
payload.rental = null;
}
// Cost fields: price is the toggle. When set, send the full cost set;
// when blank, send price:null (clears any prior cost) and omit the rest.
const costRes = costForm.read();
if (costRes.error) return (errorEl.textContent = costRes.error);
payload.price = costRes.price != null ? costRes.price : null;
if (costRes.price != null) {
payload.paid_by = costRes.paid_by;
payload.split_mode = costRes.split_mode;
payload.participants = costRes.participants;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Saving…';
try {
if (editing) await api.entries.update(editing, payload);
else await api.entries.create(tctx.tripId, payload);
toast(editing ? 'Entry updated' : 'Entry added', 'success');
editing = null;
loc = null;
await tctx.refreshTrip(); // triggers _onModalRefresh -> draw()
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
submitBtn.textContent = editing ? 'Save entry' : 'Add entry';
}
}
const cancelEdit = editing
? el('button', {
class: 'btn btn-ghost',
type: 'button',
onClick: () => { editing = null; loc = null; draw(); },
}, 'Cancel edit')
: null;
const form = el(
'form',
{ class: 'entry-form', onSubmit },
el('h3', {}, editing ? 'Edit entry' : 'Add entry'),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start time'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End time'), endInput),
),
flightSection,
rentalSection,
locationField,
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
costForm.node,
errorEl,
el('div', { class: 'form-actions' }, cancelEdit, submitBtn),
);
renderLoc();
syncTypeUI();
return form;
}
function renderLoc() {
const box = fields.locSelected;
if (!box) return;
clear(box);
if (loc) {
box.appendChild(
el(
'div',
{ class: 'loc-chip' },
el('span', {}, `📍 ${loc.name}`),
el('button', {
class: 'loc-clear',
type: 'button',
title: 'Clear location',
onClick: () => { loc = null; fields.locInput.value = ''; renderLoc(); },
}, '×'),
),
);
}
}
function wireGeocode(input, resultsBox) {
let timer = null;
let seq = 0;
input.addEventListener('input', () => {
const q = input.value.trim();
clearTimeout(timer);
if (q.length < 2) {
clear(resultsBox);
return;
}
timer = setTimeout(async () => {
const mySeq = ++seq;
resultsBox.classList.add('loading');
try {
const data = await api.geocode(q);
if (mySeq !== seq) return; // a newer query superseded this one
showResults(data.results || [], resultsBox);
} catch (err) {
if (mySeq !== seq) return;
clear(resultsBox);
toast(err.message || 'Location search failed');
} finally {
resultsBox.classList.remove('loading');
}
}, 400);
});
}
function showResults(results, resultsBox) {
clear(resultsBox);
if (!results.length) {
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
return;
}
for (const r of results) {
resultsBox.appendChild(
el(
'button',
{
class: 'loc-result',
type: 'button',
onClick: () => {
loc = { name: r.name, lat: r.lat, lng: r.lng };
fields.locInput.value = '';
clear(resultsBox);
renderLoc();
},
},
el('span', { class: 'loc-result-name' }, r.name),
),
);
}
}
async function onDelete(entry) {
if (!window.confirm(`Delete "${entry.title}"?`)) return;
try {
await api.entries.remove(entry.id);
toast('Entry deleted', 'success');
if (editing === entry.id) { editing = null; loc = null; }
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
draw();
}
+189
View File
@@ -0,0 +1,189 @@
// Split-flap flip-clock countdown to a trip's start. Vanilla JS + CSS, no
// library. The static top/bottom halves ALWAYS show the current digit (so the
// number is correct even if the flip animation is interrupted); the flip
// layers are a cosmetic overlay.
//
// Lifecycle: only one countdown is ever mounted, so the 1s interval id lives in
// module state. renderCountdown clears any previous timer before starting a new
// one (so refreshTrip's re-render never leaks or double-ticks), and a hashchange
// handler stops it on navigation away.
import { el, clear } from '../dom.js';
import { parseYMD, ymd, daysBetweenInclusive } from '../format.js';
const UNITS = [
{ key: 'days', label: 'Days' },
{ key: 'hours', label: 'Hours' },
{ key: 'mins', label: 'Min' },
{ key: 'secs', label: 'Sec' },
];
let activeTimer = null;
let activeHashHandler = null;
export function stopCountdown() {
if (activeTimer) {
clearInterval(activeTimer);
activeTimer = null;
}
if (activeHashHandler) {
window.removeEventListener('hashchange', activeHashHandler);
activeHashHandler = null;
}
}
export function renderCountdown(tctx) {
stopCountdown();
const trip = tctx.trip.trip;
const section = el('section', { class: 'countdown-section' });
function paint() {
const state = computeState(trip);
if (state.kind === 'future') {
buildClock(section, trip, tctx);
} else if (state.kind === 'ongoing') {
clear(section);
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
el('span', { class: 'cd-badge-icon' }, '✈'),
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
} else {
clear(section);
section.appendChild(el('div', { class: 'countdown-badge done' },
el('span', { class: 'cd-badge-icon' }, '🏁'),
el('span', {}, 'Trip completed')));
}
}
paint();
return section;
}
function computeState(trip) {
const today = ymd(new Date());
if (today < trip.start_date) return { kind: 'future' };
if (today <= trip.end_date) {
return {
kind: 'ongoing',
dayN: daysBetweenInclusive(trip.start_date, today),
total: daysBetweenInclusive(trip.start_date, trip.end_date),
};
}
return { kind: 'past' };
}
function buildClock(section, trip, tctx) {
const target = parseYMD(trip.start_date); // local midnight of the start day
clear(section);
const groups = {};
const row = el('div', { class: 'flipclock' });
UNITS.forEach((u, i) => {
if (i > 0) row.appendChild(el('div', { class: 'fc-sep' }, ':'));
const digitsWrap = el('div', { class: 'fc-digits' });
// days can be 3 wide; the rest are 2. Digit cards are created lazily to
// match the current width so a 3-digit day count still renders.
groups[u.key] = { wrap: digitsWrap, cards: [] };
row.appendChild(
el('div', { class: 'fc-group' }, digitsWrap, el('div', { class: 'fc-label' }, u.label)),
);
});
section.appendChild(el('p', { class: 'countdown-caption' }, 'until departure'));
section.appendChild(row);
function values() {
const totalSec = Math.max(0, Math.floor((target.getTime() - Date.now()) / 1000));
return {
days: Math.floor(totalSec / 86400),
hours: Math.floor((totalSec % 86400) / 3600),
mins: Math.floor((totalSec % 3600) / 60),
secs: totalSec % 60,
totalSec,
};
}
function update() {
const v = values();
setGroup(groups.days, v.days, Math.max(2, String(v.days).length));
setGroup(groups.hours, v.hours, 2);
setGroup(groups.mins, v.mins, 2);
setGroup(groups.secs, v.secs, 2);
if (v.totalSec <= 0) {
// Departure reached — swap to the "ongoing" badge (no network needed).
stopCountdown();
renderInto(section, tctx);
}
}
update();
activeTimer = setInterval(update, 1000);
activeHashHandler = stopCountdown;
window.addEventListener('hashchange', activeHashHandler);
}
// Re-run the whole countdown paint (used at the zero-crossing).
function renderInto(section, tctx) {
const trip = tctx.trip.trip;
const state = computeState(trip);
clear(section);
if (state.kind === 'ongoing') {
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
el('span', { class: 'cd-badge-icon' }, '✈'),
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
} else {
section.appendChild(el('div', { class: 'countdown-badge done' },
el('span', { class: 'cd-badge-icon' }, '🏁'),
el('span', {}, 'Trip completed')));
}
}
function setGroup(group, value, width) {
const str = String(value).padStart(width, '0');
// Rebuild the card set if the digit count changed (e.g. 100 -> 99 days).
if (group.cards.length !== str.length) {
clear(group.wrap);
group.cards = [];
for (const ch of str) {
const card = makeCard(ch);
group.cards.push(card);
group.wrap.appendChild(card.node);
}
return;
}
str.split('').forEach((ch, i) => setDigit(group.cards[i], ch));
}
function makeCard(digit) {
const top = face('fc-top', digit);
const bottom = face('fc-bottom', digit);
const flipTop = face('fc-flip fc-flip-top', digit);
const flipBottom = face('fc-flip fc-flip-bottom', digit);
const node = el('div', { class: 'fc-card' }, top, bottom, flipTop, flipBottom);
const card = { node, value: digit, top, bottom, flipTop, flipBottom };
card.node.addEventListener('animationend', (e) => {
if (e.animationName === 'fc-bottom') card.node.classList.remove('fc-flipping');
});
return card;
}
function face(cls, digit) {
return el('div', { class: `fc-face ${cls}` }, el('b', {}, digit));
}
function setDigit(card, next) {
if (card.value === next) return;
const prev = card.value;
card.value = next;
// Static halves show the new digit immediately (correctness guaranteed).
digitText(card.top, next);
digitText(card.bottom, next);
// Cosmetic flip: old top falls, new bottom rises.
digitText(card.flipTop, prev);
digitText(card.flipBottom, next);
card.node.classList.remove('fc-flipping');
void card.node.offsetWidth; // restart the animation
card.node.classList.add('fc-flipping');
}
function digitText(face, ch) {
face.firstChild.textContent = ch;
}
+190
View File
@@ -0,0 +1,190 @@
// Leaflet map: numbered markers for located stops, a polyline through them,
// per-leg km labels, and a leg-by-leg list. Graceful empty state when the
// trip has no located entries yet. Uses the /route response from tctx.route.
import { el } from '../dom.js';
import { typeInfo } from '../format.js';
export function renderMap(tctx) {
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
const stops = route.stops || [];
const section = el('section', { class: 'card map-section' });
section.appendChild(
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Map'),
el('p', { class: 'muted' }, stops.length
? `${stops.length} located ${stops.length === 1 ? 'stop' : 'stops'} · ${fmtKm(route.totalKm)} km total`
: 'Add a location to an entry to see it here.'),
),
);
if (!stops.length) {
section.appendChild(
el(
'div',
{ class: 'map-empty' },
el('div', { class: 'empty-icon' }, '🗺️'),
el('p', {}, 'No located entries yet.'),
el('p', { class: 'muted' }, 'Open a day, add an entry, and give it a location to plot it on the map.'),
),
);
return section;
}
const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` });
section.appendChild(mapDiv);
section.appendChild(legList(route, stops));
// Leaflet needs the container attached with a real size, so init on the
// next tick after this section is mounted into the page.
setTimeout(() => initMap(mapDiv, route, stops), 0);
return section;
}
function initMap(mapDiv, route, stops) {
const L = window.L;
if (!L) {
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
return;
}
const map = L.map(mapDiv, { scrollWheelZoom: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map);
// Geometry is driven by stop ORDER, not entryId: a multi-leg flight expands
// into several airport stops that all share the same entryId, so keying by
// entryId would collapse them.
const points = stops.map((s) => [s.lat, s.lng]);
stops.forEach((stop, i) => {
const info = typeInfo(stop.type);
L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) })
.addTo(map)
.bindPopup(popupHtml(stop, info));
});
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
// solid teal), each with a km label at its midpoint. Legs are matched to
// consecutive non-coincident stop pairs (skip <0.05km, the server's rule).
const legs = route.legs || [];
let li = 0;
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
const a = points[i];
const b = points[i + 1];
if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue;
const leg = legs[li];
li += 1;
const air = leg.mode === 'air';
L.polyline([a, b], {
color: air ? '#2563eb' : '#0f766e',
weight: 3,
opacity: 0.75,
dashArray: air ? '6 6' : null,
}).addTo(map);
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map);
}
if (points.length === 1) {
map.setView(points[0], 10);
} else {
map.fitBounds(L.latLngBounds(points).pad(0.2));
}
map.invalidateSize();
}
// Great-circle km — mirrors the server rule for skipping zero-distance legs.
function haversineKm(lat1, lng1, lat2, lng2) {
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
function numberedIcon(L, n, color) {
return L.divIcon({
className: 'map-pin-wrap',
html: `<span class="map-pin" style="background:${color}">${n}</span>`,
iconSize: [26, 26],
iconAnchor: [13, 26],
popupAnchor: [0, -24],
});
}
function kmLabel(L, km) {
return L.divIcon({
className: 'km-label-wrap',
html: `<span class="km-label">${fmtKm(km)} km</span>`,
iconSize: [0, 0],
});
}
// Built from server-provided fields; escape to keep the popup injection-safe.
function popupHtml(stop, info) {
const isAirport = stop.kind === 'airport' && stop.code;
const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.icon} ${esc(stop.title)}`;
const sub = isAirport
? `${esc(info.label)} · ${esc(stop.date)}${stop.title ? ` · ${esc(stop.title)}` : ''}`
: `${esc(info.label)} · ${esc(stop.date)}`;
const locIcon = isAirport ? '✈️' : '📍';
return (
`<div class="map-popup">` +
`<strong>${heading}</strong>` +
`<div class="map-popup-sub">${sub}</div>` +
(stop.location_name ? `<div class="map-popup-loc">${locIcon} ${esc(stop.location_name)}</div>` : '') +
`</div>`
);
}
function stopLabel(stop) {
if (stop.kind === 'airport' && stop.code) return stop.code;
return stop.location_name || stop.title || '?';
}
function legList(route, stops) {
const legs = route.legs || [];
if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.');
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
let li = 0;
let shown = 0;
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
const a = stops[i];
const b = stops[i + 1];
if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue;
const leg = legs[li++];
shown += 1;
const air = leg.mode === 'air';
wrap.appendChild(
el(
'div',
{ class: 'leg-row' },
el('span', { class: 'leg-index' }, String(shown)),
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`),
),
);
}
return wrap;
}
function fmtKm(n) {
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
}
function esc(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+131
View File
@@ -0,0 +1,131 @@
// Rental-car details subsection used inside the day editor for rental entries,
// plus a compact one-line renderer for the entry list. Pickup/dropoff use
// plain-text location names only (they do not feed the route), so there is no
// geocode/lat-lng UI here. Kept separate so dayEditor.js stays small.
import { el } from '../dom.js';
import { formatDate } from '../format.js';
// createRentalDetails({ initial, entryDate, onChange }) ->
// { node, read(), load(rental) }
// read() -> { rental: {...} | null, pickupDate } or { error }.
export function createRentalDetails({ initial = null, entryDate, onChange = () => {} } = {}) {
const brandI = textInput('Brand (e.g. Toyota)', 60);
const modelI = textInput('Model (e.g. Yaris Cross)', 60);
const carTypeI = textInput('Type (e.g. SUV)', 40);
const bookingI = textInput('Booking ref', 60);
const includedI = el('input', { class: 'input', type: 'number', min: '0', step: '1', placeholder: 'e.g. 1500' });
const pDate = el('input', { class: 'input', type: 'date' });
const pTime = el('input', { class: 'input', type: 'time' });
const pLoc = textInput('Location (free text)', 120);
const dDate = el('input', { class: 'input', type: 'date' });
const dTime = el('input', { class: 'input', type: 'time' });
const dLoc = textInput('Location (free text)', 120);
for (const inp of [brandI, modelI, carTypeI, bookingI, includedI, pDate, pTime, pLoc, dDate, dTime, dLoc]) {
inp.addEventListener('input', onChange);
}
const node = el(
'div',
{ class: 'rental-details' },
el('div', { class: 'form-row' },
field('Brand', brandI), field('Model', modelI)),
el('div', { class: 'form-row' },
field('Car type', carTypeI), field('Booking ref', bookingI),
field('Included km', includedI)),
el('div', { class: 'rental-blocks' },
rentalBlock('Pickup', pDate, pTime, pLoc),
rentalBlock('Dropoff', dDate, dTime, dLoc)),
);
function read() {
const brand = brandI.value.trim();
const model = modelI.value.trim();
const car_type = carTypeI.value.trim();
const booking_ref = bookingI.value.trim();
const includedRaw = includedI.value.trim();
const pickupDate = pDate.value || entryDate;
const dropoffDate = dDate.value || pickupDate;
const anyContent = brand || model || car_type || booking_ref || includedRaw ||
pTime.value || pLoc.value.trim() || dTime.value || dLoc.value.trim();
if (!anyContent) return { rental: null, pickupDate };
let included_km = null;
if (includedRaw !== '') {
const n = Number(includedRaw);
if (!Number.isFinite(n) || n < 0) return { error: 'Included km must be a number ≥ 0.' };
included_km = n;
}
const rental = {};
if (brand) rental.brand = brand;
if (model) rental.model = model;
if (car_type) rental.car_type = car_type;
if (booking_ref) rental.booking_ref = booking_ref;
if (included_km != null) rental.included_km = included_km;
rental.pickup = { date: pickupDate };
if (pTime.value) rental.pickup.time = pTime.value;
if (pLoc.value.trim()) rental.pickup.location_name = pLoc.value.trim();
rental.dropoff = { date: dropoffDate };
if (dTime.value) rental.dropoff.time = dTime.value;
if (dLoc.value.trim()) rental.dropoff.location_name = dLoc.value.trim();
return { rental, pickupDate };
}
function load(rental) {
const r = rental || {};
brandI.value = r.brand || '';
modelI.value = r.model || '';
carTypeI.value = r.car_type || '';
bookingI.value = r.booking_ref || '';
includedI.value = r.included_km != null ? String(r.included_km) : '';
const p = r.pickup || {};
pDate.value = p.date || entryDate || '';
pTime.value = p.time || '';
pLoc.value = p.location_name || '';
const d = r.dropoff || {};
dDate.value = d.date || p.date || entryDate || '';
dTime.value = d.time || '';
dLoc.value = d.location_name || '';
onChange();
}
// Initial values (defaults: both dates to the entry's day).
load(initial || { pickup: { date: entryDate }, dropoff: { date: entryDate } });
return { node, read, load };
}
// "🚙 Toyota Yaris Cross · pickup 09:00 CNX Airport → dropoff 7 Aug 18:00 · 1,500 km included · RC-889231"
export function renderRentalLine(rental) {
const car = [rental.brand, rental.model].filter(Boolean).join(' ') || 'Rental car';
const p = rental.pickup || {};
const d = rental.dropoff || {};
const pickup = ['pickup', p.time, p.location_name].filter(Boolean).join(' ');
const dropoff = ['dropoff', d.date ? formatDate(d.date) : null, d.time, d.location_name].filter(Boolean).join(' ');
const bits = [`🚙 ${car}`, `${pickup}${dropoff}`];
if (rental.included_km != null) bits.push(`${Number(rental.included_km).toLocaleString()} km included`);
if (rental.booking_ref) bits.push(rental.booking_ref);
return el('div', { class: 'entry-rental muted' }, bits.join(' · '));
}
function textInput(placeholder, maxlength) {
return el('input', { class: 'input', type: 'text', maxlength: String(maxlength), placeholder });
}
function field(label, input) {
return el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, label), input);
}
function rentalBlock(title, dateInput, timeInput, locInput) {
return el(
'div',
{ class: 'rental-block' },
el('h4', { class: 'rental-block-title' }, title),
el('div', { class: 'form-row' },
field('Date', dateInput), field('Time', timeInput)),
field('Location', locInput),
);
}
+332
View File
@@ -0,0 +1,332 @@
// Flight-route builder used inside the day editor for flight entries.
// Provides a quick "CNX-BKK-DXB-FRA" expander plus editable per-leg rows with
// airport-code autocomplete backed by GET /api/airports. Kept as its own
// module so dayEditor.js stays small.
import { el, clear, toast } from '../dom.js';
import { api } from '../api.js';
import { formatTimeRange } from '../format.js';
const CODE_RE = /^[A-Z0-9]{2,4}$/;
// Per-segment display lines for an entry row: "TG103 CNX→BKK 10:3011:45".
export function renderSegmentLines(segments) {
return el(
'div',
{ class: 'entry-segments' },
...segments.map((s) => {
const legTime = formatTimeRange(s.dep_time, s.arr_time);
const codes = `${s.from?.code || '?'}${s.to?.code || '?'}`;
return el(
'div',
{ class: 'entry-seg muted' },
s.flight_no ? el('span', { class: 'seg-flight' }, s.flight_no) : null,
el('span', {}, codes),
legTime ? el('span', {}, legTime) : null,
);
}),
);
}
// createFlightRoute({ initialSegments, onChange }) ->
// { node, read(), hasSegments() }
// read() returns { segments: [...] | null } or { error: 'message' }.
export function createFlightRoute({ initialSegments = null, onChange = () => {} } = {}) {
const rows = []; // { state:{flight_no,dep_time,arr_time,from,to}, node, refs }
const listEl = el('div', { class: 'seg-list' });
const quickInput = el('input', {
class: 'input seg-quick-input',
type: 'text',
placeholder: 'CNX-BKK-DXB-FRA',
autocomplete: 'off',
spellcheck: 'false',
'aria-label': 'Airport code chain',
});
const buildBtn = el('button', { class: 'btn btn-sm', type: 'button' }, 'Build route');
buildBtn.addEventListener('click', () => buildFromChain(quickInput.value));
const addBtn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '+ Add leg');
addBtn.addEventListener('click', () => {
const prev = rows[rows.length - 1];
addRow(prev ? { from: { ...prev.state.to } } : {});
renumber();
onChange();
});
const node = el(
'div',
{ class: 'flight-route' },
el('div', { class: 'seg-quick' },
el('label', { class: 'field field-grow' },
el('span', { class: 'field-label' }, 'Quick route (airport codes)'),
quickInput),
buildBtn),
listEl,
addBtn,
);
function emptyAirport() {
return { code: '', name: '', lat: null, lng: null };
}
function addRow(seg = {}) {
const state = {
flight_no: seg.flight_no || '',
dep_time: seg.dep_time || '',
arr_time: seg.arr_time || '',
from: { ...emptyAirport(), ...(seg.from || {}) },
to: { ...emptyAirport(), ...(seg.to || {}) },
};
const { rowNode, refs } = buildRowDom(state);
const row = { state, node: rowNode, refs };
rows.push(row);
listEl.appendChild(rowNode);
refreshRowLabels(row);
return row;
}
function removeRow(row) {
const i = rows.indexOf(row);
if (i === -1) return;
rows.splice(i, 1);
row.node.remove();
renumber();
onChange();
}
function renumber() {
rows.forEach((r, i) => { r.refs.num.textContent = String(i + 1); });
}
function buildRowDom(state) {
const num = el('span', { class: 'seg-num' }, '1');
const flightNo = el('input', { class: 'input input-sm seg-flightno', type: 'text', maxlength: '12', placeholder: 'Flight no.', value: state.flight_no });
flightNo.addEventListener('input', () => { state.flight_no = flightNo.value; });
const from = buildAirportField(state.from, 'From');
const to = buildAirportField(state.to, 'To');
const dep = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Departure time' });
dep.value = state.dep_time || '';
dep.addEventListener('input', () => { state.dep_time = dep.value; });
const arr = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Arrival time' });
arr.value = state.arr_time || '';
arr.addEventListener('input', () => { state.arr_time = arr.value; });
const removeBtn = el('button', { class: 'icon-btn danger', type: 'button', title: 'Remove leg' }, '×');
const rowNode = el(
'div',
{ class: 'seg-row' },
el('div', { class: 'seg-row-head' }, num, flightNo, removeBtn),
el('div', { class: 'seg-airports' }, from.field, el('span', { class: 'seg-arrow' }, '→'), to.field),
el('div', { class: 'seg-times' },
el('label', { class: 'seg-time' }, el('span', {}, 'Dep'), dep),
el('label', { class: 'seg-time' }, el('span', {}, 'Arr'), arr)),
);
const refs = { num, from, to };
removeBtn.addEventListener('click', () => {
const row = rows.find((r) => r.node === rowNode);
if (row) removeRow(row);
});
return { rowNode, refs };
}
// One airport code input + autocomplete dropdown + resolved-name line.
function buildAirportField(airportState, label) {
const input = el('input', {
class: 'input input-sm seg-code',
type: 'text',
maxlength: '4',
placeholder: label === 'From' ? 'From (e.g. CNX)' : 'To (e.g. BKK)',
autocomplete: 'off',
spellcheck: 'false',
'aria-label': `${label} airport code`,
});
input.value = airportState.code || '';
const results = el('div', { class: 'ap-results' });
const nameLine = el('span', { class: 'ap-name' });
const field = el('div', { class: 'seg-ap' }, input, results, nameLine);
let timer = null;
let seq = 0;
input.addEventListener('input', () => {
const val = input.value.toUpperCase();
// Manual edit clears any previously resolved coordinates.
airportState.code = val;
airportState.name = '';
airportState.lat = null;
airportState.lng = null;
setNameLine(nameLine, airportState);
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 2) { clear(results); return; }
timer = setTimeout(async () => {
const mine = ++seq;
try {
const data = await api.airports(q);
if (mine !== seq) return;
showAirportResults(results, data.results || [], (ap) => {
applyAirport(airportState, ap);
input.value = ap.code;
clear(results);
setNameLine(nameLine, airportState);
onChange();
});
} catch (err) {
if (mine !== seq) return;
clear(results);
}
}, 300);
});
return { field, input, nameLine, state: airportState };
}
function refreshRowLabels(row) {
setNameLine(row.refs.from.nameLine, row.state.from);
setNameLine(row.refs.to.nameLine, row.state.to);
}
// Quick-build: split codes, make N-1 legs, resolve each code to coords.
async function buildFromChain(raw) {
const codes = String(raw || '')
.split(/[\s,>\-]+/)
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
if (codes.length < 2) {
toast('Enter at least two airport codes, e.g. CNX-BKK');
return;
}
if (codes.length - 1 > 8) {
toast('A flight can have at most 8 legs.');
return;
}
// Replace existing rows.
rows.slice().forEach((r) => { r.node.remove(); });
rows.length = 0;
for (let i = 0; i < codes.length - 1; i++) {
addRow({ from: { code: codes[i] }, to: { code: codes[i + 1] } });
}
renumber();
onChange();
// Resolve unique codes once, then apply to every matching field.
const unique = [...new Set(codes)];
const resolved = {};
await Promise.all(unique.map(async (code) => {
resolved[code] = await resolveCode(code);
}));
for (const row of rows) {
applyResolved(row.state.from, resolved[row.state.from.code], row.refs.from);
applyResolved(row.state.to, resolved[row.state.to.code], row.refs.to);
}
onChange();
}
function applyResolved(airportState, ap, ref) {
if (ap) {
applyAirport(airportState, ap);
ref.input.value = ap.code;
ref.field.classList.remove('ap-unresolved');
} else {
// Keep the typed code but flag that it has no coordinates.
ref.field.classList.add('ap-unresolved');
}
setNameLine(ref.nameLine, airportState);
}
async function resolveCode(code) {
try {
const data = await api.airports(code);
const list = data.results || [];
return list.find((r) => (r.code || '').toUpperCase() === code) || null;
} catch {
return null;
}
}
function read() {
if (rows.length === 0) return { segments: null };
if (rows.length > 8) return { error: 'A flight can have at most 8 legs.' };
const segments = [];
for (const r of rows) {
const fromCode = (r.state.from.code || '').trim().toUpperCase();
const toCode = (r.state.to.code || '').trim().toUpperCase();
if (!CODE_RE.test(fromCode) || !CODE_RE.test(toCode)) {
return { error: 'Every leg needs a valid from and to airport code (24 characters).' };
}
const seg = { from: airportOut(r.state.from, fromCode), to: airportOut(r.state.to, toCode) };
if (r.state.flight_no.trim()) seg.flight_no = r.state.flight_no.trim();
if (r.state.dep_time) seg.dep_time = r.state.dep_time;
if (r.state.arr_time) seg.arr_time = r.state.arr_time;
segments.push(seg);
}
return { segments };
}
// Replace all rows from a segments array (used when editing an entry).
function load(segments) {
rows.slice().forEach((r) => { r.node.remove(); });
rows.length = 0;
if (Array.isArray(segments)) {
for (const seg of segments) addRow(seg);
}
renumber();
onChange();
}
// Prefill from an existing entry's segments (edit).
if (Array.isArray(initialSegments)) {
for (const seg of initialSegments) addRow(seg);
renumber();
}
return { node, read, load, hasSegments: () => rows.length > 0 };
}
function applyAirport(airportState, ap) {
airportState.code = (ap.code || '').toUpperCase();
airportState.name = ap.name || '';
airportState.lat = ap.lat != null ? ap.lat : null;
airportState.lng = ap.lng != null ? ap.lng : null;
}
function airportOut(airportState, code) {
const out = { code };
if (airportState.name) out.name = airportState.name;
if (airportState.lat != null && airportState.lng != null) {
out.lat = airportState.lat;
out.lng = airportState.lng;
}
return out;
}
function setNameLine(nameLine, airportState) {
if (airportState.name) {
nameLine.textContent = airportState.name;
nameLine.classList.remove('muted');
} else if (airportState.code) {
nameLine.textContent = 'no coordinates — pick from the list';
nameLine.classList.add('muted');
} else {
nameLine.textContent = '';
}
}
function showAirportResults(box, results, onPick) {
clear(box);
if (!results.length) {
box.appendChild(el('div', { class: 'ap-empty muted' }, 'No airports found'));
return;
}
for (const r of results) {
const meta = [r.city, r.country].filter(Boolean).join(', ');
box.appendChild(
el('button', { class: 'ap-result', type: 'button', onClick: () => onPick(r) },
el('span', { class: 'ap-code' }, r.code),
el('span', { class: 'ap-desc' }, `${r.name}${meta ? `${meta}` : ''}`)),
);
}
}
+111
View File
@@ -0,0 +1,111 @@
// Summary panel built from the /route response's `summary` block:
// days, nights, flights, hotels, travel legs, activities, total km, and the
// list of locations in visit order.
import { el } from '../dom.js';
export function renderSummary(tctx) {
const route = tctx.route || {};
const s = route.summary || {
days: 0, nights: 0, flights: 0, flightSegments: 0, hotels: 0, travelLegs: 0, activities: 0, locations: [],
};
const totalKm = route.totalKm || 0;
// Show the leg count under the Flights tile only when a flight has segments.
const flightSub = s.flightSegments > 0
? `${s.flightSegments} ${s.flightSegments === 1 ? 'leg' : 'legs'}`
: '';
const section = el('section', { class: 'card summary-section' });
section.appendChild(
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Summary'),
el('p', { class: 'muted' }, 'Trip at a glance.'),
),
);
const tiles = el(
'div',
{ class: 'stat-grid' },
tile('🗓️', s.days, s.days === 1 ? 'Day' : 'Days'),
tile('🌙', s.nights, s.nights === 1 ? 'Night' : 'Nights'),
tile('✈️', s.flights, 'Flights', flightSub),
tile('🏨', s.hotels, 'Hotels'),
tile('🚗', s.travelLegs, 'Travel legs'),
tile('📍', s.activities, 'Activities'),
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
);
section.appendChild(tiles);
const kmDriven = s.kmDriven || 0;
const kmAir = s.kmAir || 0;
const includedKm = s.includedKm != null ? s.includedKm : null;
const kmBlock = el(
'div',
{ class: 'km-block' },
el(
'div',
{ class: 'total-km' },
el('span', { class: 'total-km-value' }, fmtKm(totalKm)),
el('span', { class: 'total-km-label' }, 'km total (great-circle)'),
),
);
const breakdown = el('div', { class: 'km-breakdown' });
if (kmDriven > 0) {
// Rental allowance (includedKm) is paired with the rough driven figure;
// flag red when the rough estimate already exceeds the included allowance.
const over = includedKm != null && kmDriven > includedKm;
const drivenText = includedKm != null
? `${fmtKm(kmDriven)} km / ${fmtKm(includedKm)} incl.`
: `${fmtKm(kmDriven)} km`;
breakdown.appendChild(kmRow('🚗', drivenText, 'driven',
over
? 'Rough great-circle estimate already exceeds the included allowance — real road km will be higher'
: 'Rough great-circle estimate — not routed driving distance',
over ? 'km-over' : ''));
}
if (kmAir > 0) {
breakdown.appendChild(kmRow('✈️', `${fmtKm(kmAir)} km`, 'flown',
'Great-circle distance between airports'));
}
if (breakdown.childElementCount) kmBlock.appendChild(breakdown);
section.appendChild(kmBlock);
const locations = s.locations || [];
const locBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Locations in order'));
if (!locations.length) {
locBlock.appendChild(el('p', { class: 'muted' }, 'No located stops yet.'));
} else {
const ol = el('ol', { class: 'loc-order' });
for (const name of locations) ol.appendChild(el('li', {}, name));
locBlock.appendChild(ol);
}
section.appendChild(locBlock);
return section;
}
function kmRow(icon, valueText, label, title, cls = '') {
return el(
'div',
{ class: `km-row${cls ? ` ${cls}` : ''}`, title },
el('span', { class: 'km-row-icon' }, icon),
el('span', { class: 'km-row-value' }, valueText),
el('span', { class: 'km-row-label muted' }, label),
);
}
function tile(icon, value, label, sub) {
return el(
'div',
{ class: 'stat-tile' },
el('span', { class: 'stat-icon' }, icon),
el('span', { class: 'stat-value' }, String(value ?? 0)),
el('span', { class: 'stat-label' }, label),
sub ? el('span', { class: 'stat-sub' }, sub) : null,
);
}
function fmtKm(n) {
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
}
+264
View File
@@ -0,0 +1,264 @@
// Orchestrates the trip page: header + calendar, then map + summary.
// Owns the shared trip context passed to the calendar, map, summary and
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
// derived /route data so all three panels stay in sync.
import { api } from '../api.js';
import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
import { renderCalendar } from './calendar.js';
import { renderMap } from './map.js';
import { renderSummary } from './summary.js';
import { renderCosts } from './costs.js';
import { renderCountdown } from './flipclock.js';
import { openDayEditor } from './dayEditor.js';
export function renderTripDetail(container, ctx, id) {
const tctx = {
...ctx,
tripId: id,
trip: null,
route: null,
costs: null,
_onModalRefresh: null,
};
mount(container, loading('Loading trip…'));
init();
async function load() {
const [trip, route, costs] = await Promise.all([
api.trips.get(id),
api.trips.route(id),
api.trips.costs(id),
]);
tctx.trip = trip;
tctx.route = route;
tctx.costs = costs;
}
tctx.refreshTrip = async () => {
try {
await load();
draw();
if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh();
} catch (err) {
toast(err.message);
}
};
tctx.openDay = (date) => openDayEditor(tctx, date);
async function init() {
try {
await load();
draw();
} catch (err) {
clear(container);
if (err.status === 404) {
toast('Trip not found (or you are not a member).');
ctx.navigate('#/trips');
return;
}
mount(container, errorBox(err.message, init));
}
}
function draw() {
const page = el('div', { class: 'page' });
page.appendChild(renderHeader());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx));
page.appendChild(
el(
'div',
{ class: 'detail-grid' },
renderMap(tctx),
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx)),
),
);
mount(container, page);
}
function renderHeader() {
const { trip, members } = tctx.trip;
const isOwner = trip.owner_id === tctx.state.user.id;
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
const header = el('div', { class: 'trip-header card' });
const titleRow = el(
'div',
{ class: 'trip-header-top' },
el(
'div',
{},
el(
'a',
{ class: 'back-link', href: '#/trips' },
'← All trips',
),
el('h1', { class: 'trip-title' }, trip.name),
el(
'p',
{ class: 'trip-subtitle muted' },
'📅 ', formatRange(trip.start_date, trip.end_date),
' · ', pluralize(dayCount, 'day', 'days'),
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
),
),
el(
'div',
{ class: 'trip-header-actions' },
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
isOwner
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
: null,
),
);
const membersRow = el(
'div',
{ class: 'members-row' },
el('span', { class: 'members-label' }, 'Members:'),
...members.map((m) =>
el(
'span',
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
m.display_name,
isOwner && m.role !== 'owner'
? el('button', {
class: 'member-remove',
title: `Remove ${m.display_name}`,
onClick: () => onRemoveMember(m),
}, '×')
: null,
),
),
);
header.appendChild(titleRow);
header.appendChild(joinCodeRow(trip, isOwner));
header.appendChild(membersRow);
return header;
}
// Share code for inviting others — display only, not a credential.
function joinCodeRow(trip, isOwner) {
const grouped = groupCode(trip.join_code, 4);
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(grouped);
toast('Join code copied', 'success');
} catch {
toast('Copy failed — select and copy it manually');
}
});
async function onRegenerate() {
if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return;
try {
await api.trips.regenerateJoinCode(tctx.tripId);
toast('Join code regenerated', 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
return el(
'div',
{ class: 'joincode-row' },
el('span', { class: 'members-label' }, 'Join code:'),
el('span', { class: 'joincode-chip' }, grouped),
copyBtn,
isOwner
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
: null,
);
}
function toggleEdit(header) {
const existing = header.querySelector('.trip-edit');
if (existing) {
existing.remove();
return;
}
const { trip } = tctx.trip;
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name });
const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Name cannot be empty.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success');
await tctx.refreshTrip();
} catch (err) {
errorEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = 'Save changes';
}
}
const editBox = el(
'form',
{ class: 'trip-edit', onSubmit },
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput),
),
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
errorEl,
el('div', { class: 'form-actions' }, saveBtn),
);
header.appendChild(editBox);
}
async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try {
await api.trips.removeMember(tctx.tripId, member.id);
toast(`${member.display_name} removed`, 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
async function onDelete() {
const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
try {
await api.trips.remove(tctx.tripId);
toast('Trip deleted', 'success');
ctx.navigate('#/trips');
} catch (err) {
toast(err.message);
}
}
}
+221
View File
@@ -0,0 +1,221 @@
// Trip list dashboard: cards, a "new trip" form, and a "join a trip" form.
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';
export function renderTrips(container, ctx) {
mount(container, loading('Loading your trips…'));
load();
async function load() {
try {
const data = await api.trips.list();
draw(data.trips || []);
} catch (err) {
mount(container, errorBox(err.message, load));
}
}
function draw(trips) {
const page = el('div', { class: 'page' });
// The form slot is created up front so every button below can capture a
// fully-initialized reference (no reliance on declaration ordering).
const formSlot = el('div', { class: 'form-slot' });
const newTripBtn = el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip');
const joinBtn = el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip');
page.appendChild(
el(
'div',
{ class: 'page-head' },
el(
'div',
{},
el('h1', {}, 'Your Trips'),
el('p', { class: 'muted' }, trips.length
? pluralize(trips.length, 'trip', 'trips')
: 'Start planning your next adventure.'),
),
el('div', { class: 'page-head-actions' }, joinBtn, newTripBtn),
),
);
page.appendChild(formSlot);
if (!trips.length) {
page.appendChild(
emptyState(
'No trips yet',
'Create your first trip, or join one with a code a friend shared.',
el('div', { class: 'empty-actions' },
el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip'),
el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip'),
),
),
);
} else {
const grid = el('div', { class: 'trip-grid' });
for (const trip of trips) grid.appendChild(tripCard(trip));
page.appendChild(grid);
}
mount(container, page);
}
function tripCard(trip) {
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(
'a',
{ class: 'trip-card card', href: `#/trip/${trip.id}` },
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-dates' },
'📅 ', formatRange(trip.start_date, trip.end_date),
future ? el('span', { class: 'trip-card-countdown' }, `in ${daysUntil} ${daysUntil === 1 ? 'day' : 'days'}`) : null,
),
el(
'div',
{ class: 'trip-card-meta' },
el('span', {}, '👥 ', pluralize(trip.member_count ?? 1, 'member', 'members')),
el('span', {}, '📝 ', pluralize(trip.entry_count ?? 0, 'entry', 'entries')),
el('span', { class: 'trip-card-currency' }, trip.currency || 'USD'),
),
);
}
// Render `build()` into the slot, or close it if the same panel is open.
function togglePanel(slot, kind, build) {
if (slot.dataset.open === kind) {
clear(slot);
slot.dataset.open = '';
return;
}
mount(slot, build());
slot.dataset.open = kind;
}
function openNewTrip(slot) {
togglePanel(slot, 'new', () => newTripForm(slot));
}
function openJoin(slot) {
togglePanel(slot, 'join', () => joinForm(slot));
}
function closeSlot(slot) {
clear(slot);
slot.dataset.open = '';
}
function newTripForm(slot) {
const today = ymd(new Date());
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', placeholder: 'e.g. Northern Thailand' });
const startInput = el('input', { class: 'input', type: 'date', value: today });
const endInput = el('input', { class: 'input', type: 'date', value: today });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: 'USD', placeholder: 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Create trip');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Please give the trip a name.');
if (!start_date || !end_date) return (errorEl.textContent = 'Pick a start and end date.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating…';
try {
const data = await api.trips.create({ name, start_date, end_date, currency });
toast('Trip created', 'success');
ctx.navigate(`#/trip/${data.trip.id}`);
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Create trip';
}
}
const form = el(
'form',
{ class: 'card new-trip-form', onSubmit },
el('h3', {}, 'New trip'),
el('div', { class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput)),
el('div', { class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput)),
errorEl,
el('div', { class: 'form-actions' },
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
submitBtn),
);
setTimeout(() => nameInput.focus(), 0);
return form;
}
function joinForm(slot) {
const codeInput = el('input', {
class: 'input token-input',
type: 'text',
autocomplete: 'off',
autocapitalize: 'characters',
spellcheck: 'false',
placeholder: 'XXXX-XXXX',
'aria-label': 'Join code',
});
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const code = normalizeCode(codeInput.value);
if (code.length < 4) return (errorEl.textContent = 'Enter the join code your friend shared.');
submitBtn.disabled = true;
submitBtn.textContent = 'Joining…';
try {
const data = await api.trips.join(code);
toast('Joined trip', 'success');
ctx.navigate(`#/trip/${data.trip.id}`);
} catch (err) {
errorEl.textContent = err.status === 404 ? 'No trip found for that code.' : err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Join trip';
}
}
const form = el(
'form',
{ class: 'card join-form', onSubmit },
el('h3', {}, 'Join a trip'),
el('p', { class: 'muted' }, 'Paste the join code from a trip you were invited to.'),
el('div', { class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Join code'), codeInput)),
errorEl,
el('div', { class: 'form-actions' },
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
submitBtn),
);
setTimeout(() => codeInput.focus(), 0);
return form;
}
}