Split the trip page into tabs and move the header into the navbar

Three deep-linkable tabs (#/trip/:id/:tab): Trip (countdown, calendar,
map, summary), Money (costs + expenses side by side), Checklist. Panels
stay mounted; switching toggles visibility and updates the URL via
history.replaceState so an open day editor and the live tickers are
undisturbed. The Leaflet map revalidates its size on every trip-tab
activation but only auto-fits once, when its container first gains
real size - later visits keep the user's pan/zoom.

The full-width trip-header card is gone: the navbar now carries a
compact identity (back link, truncated name, date/days/currency) plus
a single overflow menu holding members, join code, edit (now a
centered modal) and delete. Dead header CSS removed; new styles live
in tabs.css.
This commit is contained in:
2026-08-30 13:31:32 +02:00
parent b0bdd2570c
commit b361fb2687
7 changed files with 397 additions and 138 deletions
+29 -6
View File
@@ -17,6 +17,13 @@ export function renderMap(tctx) {
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
const stops = route.stops || [];
// Leaflet initialised while its tab panel is hidden gets a zero-size
// container; tripDetail calls this on every activation of the trip tab so
// the map (and its fitBounds zoom, computed against whatever size the
// container had at creation time) catches up. No-op until the map actually
// exists — overwritten at the end of initMap() below.
tctx.mapInvalidate = () => {};
const section = el('section', { class: 'card map-section' });
section.appendChild(
el(
@@ -54,7 +61,7 @@ export function renderMap(tctx) {
// 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, stops, legs, kmEls), 0);
setTimeout(() => initMap(mapDiv, stops, legs, kmEls, tctx), 0);
return section;
}
@@ -75,7 +82,7 @@ function visibleLegs(route, stops) {
return result;
}
function initMap(mapDiv, stops, legs, kmEls) {
function initMap(mapDiv, stops, legs, kmEls, tctx) {
const L = window.L;
if (!L) {
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
@@ -126,13 +133,29 @@ function initMap(mapDiv, stops, legs, kmEls) {
return { leg, a, b, line, label, color };
});
if (points.length === 1) {
map.setView(points[0], 10);
} else {
map.fitBounds(L.latLngBounds(points).pad(0.2));
function fit() {
if (points.length === 1) map.setView(points[0], 10);
else map.fitBounds(L.latLngBounds(points).pad(0.2));
}
fit();
map.invalidateSize();
// A container sized 0 at creation time (hidden tab panel) gives fitBounds a
// bogus zoom that invalidateSize() alone won't correct, so the first
// activation after the container actually gets a real size must re-fit.
// But re-fitting on EVERY activation would silently discard any pan/zoom
// the user did on a previous visit to the tab — so fit() only runs once,
// the first time the container transitions from zero-width to sized.
let fitted = mapDiv.offsetWidth > 0;
tctx.mapInvalidate = () => {
if (!mapDiv.isConnected) return;
map.invalidateSize();
if (!fitted && mapDiv.offsetWidth > 0) {
fit();
fitted = true;
}
};
fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls);
}
+264 -108
View File
@@ -1,8 +1,11 @@
// 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.
// Orchestrates the trip page: a compact nav-bar trip identity block, a
// Trip/Money/Checklist tab bar, and three always-mounted panels. Owns the
// shared trip context passed to the calendar, map, summary, costs, expenses,
// checklist and day-editor sub-views: { state, navigate, navTripSlot,
// tripId, trip, route, costs, refreshTrip, refreshCosts, openDay,
// mapInvalidate }. After any mutation, refreshTrip() re-fetches the trip and
// its derived /route data so all panels (including the nav identity block)
// 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';
@@ -15,7 +18,22 @@ import { renderChecklist } from './checklist.js';
import { renderCountdown } from './flipclock.js';
import { openDayEditor } from './dayEditor.js';
export function renderTripDetail(container, ctx, id) {
const TABS = [
{ id: 'trip', icon: '🗓️', label: 'Trip' },
{ id: 'money', icon: '💰', label: 'Money' },
{ id: 'checklist', icon: '🧳', label: 'Checklist' },
];
const TAB_IDS = TABS.map((t) => t.id);
// The nav trip-menu dropdown attaches document-level listeners (outside
// click, Escape, hashchange) while open. Only one trip page is ever mounted
// at a time, so — same convention as calendar.js's now-slider ticker — a
// module-level closer lets a fresh renderNavTripContext() tear down a
// still-open dropdown's listeners before it rebuilds the slot (refreshTrip
// re-draws the nav without navigating away, which would otherwise leak them).
let closeActiveDropdown = () => {};
export function renderTripDetail(container, ctx, id, tab) {
const tctx = {
...ctx,
tripId: id,
@@ -25,10 +43,14 @@ export function renderTripDetail(container, ctx, id) {
_onModalRefresh: null,
};
let activeTab = TAB_IDS.includes(tab) ? tab : 'trip';
// The mounted Costs section node, so refreshCosts() can swap it in place
// without redrawing the whole page (self-fetching cards like Expenses
// trigger this instead of tctx.refreshTrip()).
let costsSection = null;
let tabButtons = [];
let tabPanels = [];
mount(container, loading('Loading trip…'));
init();
@@ -89,95 +111,184 @@ export function renderTripDetail(container, ctx, id) {
}
function draw() {
renderNavTripContext();
const page = el('div', { class: 'page' });
page.appendChild(renderHeader());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx));
costsSection = renderCosts(tctx);
page.appendChild(
page.appendChild(renderTabBar());
const tripPanel = el('section', {
class: 'tab-panel', id: 'tab-panel-trip', role: 'tabpanel', 'aria-labelledby': 'tab-trip',
});
tripPanel.appendChild(renderCountdown(tctx));
tripPanel.appendChild(renderCalendar(tctx));
tripPanel.appendChild(
el(
'div',
{ class: 'detail-grid' },
renderMap(tctx),
el(
'div',
{ class: 'detail-side' },
renderSummary(tctx),
costsSection,
renderExpenses(tctx),
renderChecklist(tctx),
),
el('div', { class: 'detail-side' }, renderSummary(tctx)),
),
);
costsSection = renderCosts(tctx);
const moneyPanel = el('section', {
class: 'tab-panel', id: 'tab-panel-money', role: 'tabpanel', 'aria-labelledby': 'tab-money',
});
moneyPanel.appendChild(el('div', { class: 'money-grid' }, costsSection, renderExpenses(tctx)));
const checklistPanel = el('section', {
class: 'tab-panel', id: 'tab-panel-checklist', role: 'tabpanel', 'aria-labelledby': 'tab-checklist',
});
checklistPanel.appendChild(el('div', { class: 'checklist-wrap' }, renderChecklist(tctx)));
tabPanels = [tripPanel, moneyPanel, checklistPanel];
page.appendChild(tripPanel);
page.appendChild(moneyPanel);
page.appendChild(checklistPanel);
mount(container, page);
updateTabUI();
}
function renderHeader() {
// ---------- Tab bar ----------
function renderTabBar() {
tabButtons = TABS.map((t) => el(
'button',
{
class: 'tab-btn',
type: 'button',
id: `tab-${t.id}`,
role: 'tab',
'aria-controls': `tab-panel-${t.id}`,
'aria-selected': 'false',
tabindex: '-1',
onClick: () => setActiveTab(t.id),
},
el('span', { class: 'tab-icon' }, t.icon),
el('span', { class: 'tab-label' }, t.label),
));
const bar = el('div', { class: 'tab-bar', role: 'tablist', 'aria-label': 'Trip sections' }, ...tabButtons);
bar.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const idx = TAB_IDS.indexOf(activeTab);
const dir = e.key === 'ArrowRight' ? 1 : -1;
const next = TABS[(idx + dir + TABS.length) % TABS.length];
setActiveTab(next.id);
document.getElementById(`tab-${next.id}`).focus();
});
return bar;
}
// Switching is NOT navigation: toggles panel visibility and updates the
// address bar via history.replaceState — never location.hash (a
// hashchange would close the day-editor overlay and stop the calendar's
// now-ticker / countdown, see docs/API.md).
function setActiveTab(nextTab) {
activeTab = TAB_IDS.includes(nextTab) ? nextTab : 'trip';
updateTabUI();
history.replaceState(null, '', `#/trip/${tctx.tripId}/${activeTab}`);
if (activeTab === 'trip' && typeof tctx.mapInvalidate === 'function') tctx.mapInvalidate();
}
function updateTabUI() {
for (const btn of tabButtons) {
const isActive = btn.id === `tab-${activeTab}`;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-selected', String(isActive));
btn.tabIndex = isActive ? 0 : -1;
}
for (const panel of tabPanels) {
if (panel.id === `tab-panel-${activeTab}`) panel.removeAttribute('hidden');
else panel.setAttribute('hidden', '');
}
}
// ---------- Nav trip-identity block ----------
function renderNavTripContext() {
closeActiveDropdown();
const slot = tctx.navTripSlot;
if (!slot) return;
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 menuBtn = el('button', {
class: 'icon-btn nav-trip-menu-btn', type: 'button', title: 'Trip menu',
'aria-haspopup': 'true', 'aria-expanded': 'false',
}, '⋯');
const titleRow = el(
'div',
{ class: 'trip-header-top' },
let outsideHandler = null;
let escHandler = null;
let hashHandler = null;
function closeMenu() {
dropdown.classList.remove('open');
menuBtn.setAttribute('aria-expanded', 'false');
if (outsideHandler) { document.removeEventListener('click', outsideHandler); outsideHandler = null; }
if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
if (hashHandler) { window.removeEventListener('hashchange', hashHandler); hashHandler = null; }
}
function openMenu() {
dropdown.classList.add('open');
menuBtn.setAttribute('aria-expanded', 'true');
outsideHandler = (e) => { if (!dropdown.contains(e.target) && e.target !== menuBtn) closeMenu(); };
escHandler = (e) => { if (e.key === 'Escape') closeMenu(); };
hashHandler = closeMenu;
document.addEventListener('click', outsideHandler);
document.addEventListener('keydown', escHandler);
window.addEventListener('hashchange', hashHandler);
}
const dropdown = buildDropdown(trip, members, isOwner, closeMenu);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (dropdown.classList.contains('open')) closeMenu(); else openMenu();
});
closeActiveDropdown = closeMenu;
mount(
slot,
el('a', { class: 'nav-trip-back', href: '#/trips', title: 'All trips', 'aria-label': 'Back to all trips' }, '←'),
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) =>
{ class: 'nav-trip-id' },
el('span', { class: 'nav-trip-name', title: trip.name }, trip.name),
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,
{ class: 'nav-trip-sub muted' },
formatRange(trip.start_date, trip.end_date),
' · ', pluralize(dayCount, 'day', 'days'),
' · ', trip.currency || 'USD',
),
),
el('div', { class: 'nav-trip-menu' }, menuBtn, dropdown),
);
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) {
function buildDropdown(trip, members, isOwner, closeMenu) {
const membersList = el(
'div',
{ class: 'trip-menu-members' },
el('div', { class: 'trip-menu-label' }, 'Members'),
...members.map((m) => el(
'div',
{ class: 'trip-menu-member' },
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
el('span', { class: 'trip-menu-member-name' }, m.display_name),
m.role === 'owner' ? el('span', { class: 'role-badge role-owner' }, 'Owner') : null,
isOwner && m.role !== 'owner'
? el('button', {
class: 'member-remove', type: 'button', title: `Remove ${m.display_name}`,
onClick: () => { closeMenu(); onRemoveMember(m); },
}, '×')
: null,
)),
);
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 () => {
@@ -188,43 +299,65 @@ export function renderTripDetail(container, ctx, id) {
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(
const joinRow = el(
'div',
{ class: 'joincode-row' },
el('span', { class: 'members-label' }, 'Join code:'),
{ class: 'trip-menu-joincode' },
el('span', { class: 'joincode-chip' }, grouped),
copyBtn,
isOwner
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
? el('button', {
class: 'btn btn-sm btn-ghost', type: 'button',
onClick: () => { closeMenu(); onRegenerate(); },
}, '↻ Regenerate')
: null,
);
const actions = el(
'div',
{ class: 'trip-menu-actions' },
el('button', {
class: 'btn btn-sm', type: 'button', onClick: () => { closeMenu(); openEditModal(); },
}, '✎ Edit trip'),
isOwner
? el('button', {
class: 'btn btn-sm btn-danger-ghost', type: 'button',
onClick: () => { closeMenu(); onDelete(); },
}, 'Delete trip')
: null,
);
return el('div', { class: 'trip-menu-dropdown', role: 'menu' }, membersList, joinRow, actions);
}
function toggleEdit(header) {
const existing = header.querySelector('.trip-edit');
if (existing) {
existing.remove();
return;
}
// ---------- Edit-trip modal ----------
function openEditModal() {
const { trip } = tctx.trip;
const overlay = el('div', { class: 'overlay overlay-center' });
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' });
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
function close() {
document.body.classList.remove('no-scroll');
overlay.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
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 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');
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel');
async function onSubmit(e) {
e.preventDefault();
@@ -241,6 +374,7 @@ export function renderTripDetail(container, ctx, id) {
try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success');
close();
await tctx.refreshTrip();
} catch (err) {
errorEl.textContent = err.message;
@@ -249,28 +383,39 @@ export function renderTripDetail(container, ctx, id) {
}
}
const editBox = el(
'form',
{ class: 'trip-edit', onSubmit },
mount(
modal,
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
{ class: 'slideover-head' },
el('h2', {}, '✎ Edit trip'),
el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'),
),
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),
'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' }, cancelBtn, saveBtn),
),
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);
nameInput.focus();
}
// ---------- Member / trip mutations ----------
async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try {
@@ -282,6 +427,17 @@ export function renderTripDetail(container, ctx, id) {
}
}
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);
}
}
async function onDelete() {
const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;