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:
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Calendar grid for the trip's date range. Real weeks as rows (Mon–Sun
|
||||
// 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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:30–11: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 (2–4 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}` : ''}`)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user