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
+20 -1
View File
@@ -400,7 +400,26 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept
## Frontend contract notes ## Frontend contract notes
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed. - SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id[/:tab]`) is the expected design, so no server-side fallback is needed.
### Trip page tabs & compact nav header (frontend-only)
The trip page is split into three tabs, and the old full-width trip-header card is REMOVED — its contents move into the top navbar so the page starts with actual content.
**Navbar trip context**`renderNav()` (app.js) always includes an empty `.nav-trip-slot` element between the brand and the user area; non-trip views leave it empty (it costs no space). On a trip page, `tripDetail` fills it after the trip loads with a compact identity block:
- `←` back icon-link to `#/trips`, the trip name (truncated with ellipsis when long, `title` attr carries the full name), and a muted one-line subtitle `29 Sept 6 Oct · 8 days · EUR` (hidden on narrow screens).
- A single `⋯` trip-menu button on the nav's right (before the user area) opening a small dropdown panel containing everything the old header card held: the members list (with the owner's `×` remove buttons), the join-code chip + 📋 Copy + ↻ Regenerate (owner only), `✎ Edit trip`, and `Delete trip` (owner only, danger-styled). The dropdown closes on outside click and Escape.
- `✎ Edit trip` opens the existing edit form (name/dates/currency) in a centered modal (`.overlay-center`/`.modal` pattern like the checklist-suggestions dialog) instead of expanding inside a header card.
- The slot is naturally discarded when the router re-renders the nav on navigation; `refreshTrip()` must re-fill it (member/join-code/name changes show immediately).
- **Tabs**: `trip` (countdown, calendar, then map + summary in the existing `detail-grid`), `money` (Costs and Expenses cards side by side in a two-column `money-grid`, stacked on narrow screens), `checklist` (the checklist card, centered at a comfortable reading width). Tab bar order: Trip, Money, Checklist — icons + labels (🗓️ Trip / 💰 Money / 🧳 Checklist), rendered as the first element of the page (the navbar now carries the trip identity; there is no header card).
- **URL**: `#/trip/:id/:tab` with `tab ∈ trip|money|checklist`; missing or unknown tab segment → `trip` (so old `#/trip/5` links keep working). `parseHash` passes the extra segment through as `params.tab`; unknown segments must not 404.
- **Switching is NOT navigation**: clicking a tab toggles panel visibility and updates the address bar via `history.replaceState` — it must NOT set `location.hash` (a `hashchange` would close the day-editor overlay, stop the calendar's now-ticker, and trigger a full route/re-fetch). Deep-links work because the router reads the tab on load/route; the back button traverses pages, not tabs.
- **All three panels stay mounted** — switching toggles a class/`hidden` attr, never re-renders. `refreshTrip()` re-draws all panels but must preserve the active tab. `refreshCosts()` keeps its swap-in-place behaviour regardless of which tab is active.
- **Map sizing**: Leaflet initialised in a hidden panel has zero size; on every activation of the `trip` tab the map must be size-revalidated (an exposed `invalidateSize` hook on the map view, or an equivalent explicit mechanism — not a reliance on browser resize happening to occur).
- **Keyboard/a11y**: the tab bar uses `role="tablist"`/`role="tab"`/`aria-selected` and the panels `role="tabpanel"`; Left/Right arrows move between tabs.
- Tab, nav-slot, trip-menu-dropdown, and edit-modal styles live in `public/css/tabs.css` (new; styles.css is at its 500-line cap and must not grow).
- Leaflet 1.9.x via unpkg CDN in `index.html`. - Leaflet 1.9.x via unpkg CDN in `index.html`.
- Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load. - Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load.
- Expenses UI lives in `public/js/views/expenses.js` (+ `public/css/expenses.css` — styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching from `GET .../expenses` (never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a **sort control** (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../expenses/export.csv` (cookie auth makes a plain link work). - Expenses UI lives in `public/js/views/expenses.js` (+ `public/css/expenses.css` — styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching from `GET .../expenses` (never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a **sort control** (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../expenses/export.csv` (cookie auth makes a plain link work).
+1 -19
View File
@@ -175,8 +175,6 @@ textarea.input { resize: vertical; }
.trip-card-currency { margin-left: auto; font-weight: 700; font-size: 0.72rem; letter-spacing: 0.03em; color: var(--text-faint); } .trip-card-currency { margin-left: auto; font-weight: 700; font-size: 0.72rem; letter-spacing: 0.03em; color: var(--text-faint); }
.join-form { display: flex; flex-direction: column; gap: 0.8rem; } .join-form { display: flex; flex-direction: column; gap: 0.8rem; }
/* Join-code row on the trip header */
.joincode-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
.joincode-chip { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; letter-spacing: 0.08em; background: var(--brand-soft); color: var(--brand-dark); padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); user-select: all; } .joincode-chip { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; letter-spacing: 0.08em; background: var(--brand-soft); color: var(--brand-dark); padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); user-select: all; }
/* ---------- Trip list ---------- */ /* ---------- Trip list ---------- */
@@ -211,22 +209,9 @@ textarea.input { resize: vertical; }
.empty-state h3 { color: var(--text); } .empty-state h3 { color: var(--text); }
.error-box { text-align: center; padding: 2rem; color: var(--danger); display: flex; flex-direction: column; gap: 0.8rem; align-items: center; } .error-box { text-align: center; padding: 2rem; color: var(--danger); display: flex; flex-direction: column; gap: 0.8rem; align-items: center; }
/* ---------- Trip header ---------- */ /* ---------- Trip menu dropdown (nav-bar trip identity, see tabs.css) ---------- */
.trip-header { display: flex; flex-direction: column; gap: 1rem; }
.trip-header-top { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.back-link { font-size: 0.85rem; color: var(--brand); font-weight: 600; }
.back-link:hover { text-decoration: underline; }
.trip-title { margin-top: 0.3rem; }
.trip-subtitle { margin-top: 0.2rem; }
.trip-header-actions { display: flex; gap: 0.5rem; align-items: flex-start; }
.members-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
.members-label { font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
.member-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.6rem 0.25rem 0.3rem; font-size: 0.85rem; font-weight: 600; }
.member-owner { background: #fffbeb; border-color: #fde68a; }
.member-remove { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 1rem; line-height: 1; padding: 0; } .member-remove { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 1rem; line-height: 1; padding: 0; }
.member-remove:hover { color: var(--danger); } .member-remove:hover { color: var(--danger); }
.invite-form { display: flex; gap: 0.4rem; margin-left: auto; }
.invite-form .input { width: 180px; }
.trip-edit { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 0.9rem; border-top: 1px solid var(--border); } .trip-edit { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 0.9rem; border-top: 1px solid var(--border); }
/* ---------- Calendar ---------- */ /* ---------- Calendar ---------- */
@@ -484,9 +469,6 @@ textarea.input { resize: vertical; }
.cal-chip .chip-text { display: none; } .cal-chip .chip-text { display: none; }
.cal-chip { justify-content: center; } .cal-chip { justify-content: center; }
.stat-grid { grid-template-columns: repeat(3, 1fr); } .stat-grid { grid-template-columns: repeat(3, 1fr); }
.invite-form { margin-left: 0; width: 100%; }
.invite-form .input { flex: 1; width: auto; }
.trip-header-actions { width: 100%; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.calendar { gap: 3px; } .calendar { gap: 3px; }
+66
View File
@@ -0,0 +1,66 @@
/* Trip page tabs + compact nav header. Split out of styles.css — which is at
its 500-line cap — following the same per-feature stylesheet convention as
flipclock.css/checklist.css/expenses.css. Reuses the shared palette and
existing vocabulary (.card, .btn, .icon-btn, .field, .form-row,
.form-actions, .form-error, .slideover-head, .overlay, .overlay-center,
.modal, .member-avatar, .member-remove, .role-badge, .joincode-chip)
rather than re-declaring it. */
/* ---------- Nav trip-identity slot ---------- */
.nav-trip-slot { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; margin: 0 1rem; }
.nav-trip-slot:empty { display: none; margin: 0; }
.nav-trip-back { flex-shrink: 0; font-size: 1.1rem; font-weight: 700; color: var(--brand); }
.nav-trip-back:hover { text-decoration: underline; }
.nav-trip-id { display: flex; flex-direction: column; min-width: 0; flex: 1; line-height: 1.3; }
.nav-trip-name { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-trip-sub { font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-trip-menu { position: relative; flex-shrink: 0; }
.nav-trip-menu-btn { font-size: 1.15rem; }
.trip-menu-dropdown {
position: absolute; top: calc(100% + 0.5rem); right: 0; z-index: 30;
width: min(300px, 90vw); max-height: 70vh; overflow-y: auto;
display: none; flex-direction: column; gap: 0.7rem;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg); padding: 0.85rem; animation: fade 0.12s ease;
}
.trip-menu-dropdown.open { display: flex; }
.trip-menu-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-bottom: 0.35rem; }
.trip-menu-members { display: flex; flex-direction: column; gap: 0.4rem; max-height: 200px; overflow-y: auto; }
.trip-menu-member { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; }
.trip-menu-member-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trip-menu-joincode { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; padding-top: 0.6rem; border-top: 1px solid var(--border); }
.trip-menu-actions { display: flex; flex-direction: column; gap: 0.4rem; padding-top: 0.6rem; border-top: 1px solid var(--border); }
.trip-menu-actions .btn { justify-content: flex-start; }
/* Edit-trip modal reuses .trip-edit's field layout but not its old header-card
top border/spacing (it now opens directly under the modal's own heading). */
.modal .trip-edit { padding-top: 0; border-top: none; }
/* ---------- Tab bar ---------- */
.tab-bar { display: inline-flex; gap: 0.3rem; padding: 0.3rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.tab-btn { display: flex; align-items: center; gap: 0.4rem; border: none; background: none; padding: 0.5rem 0.9rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: 600; font-size: 0.9rem; color: var(--text-muted); }
.tab-btn:hover { color: var(--text); }
.tab-btn.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
.tab-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.tab-icon { font-size: 1rem; }
.tab-panel[hidden] { display: none; }
/* ---------- Money tab (Costs + Expenses side by side) ---------- */
.money-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; align-items: start; }
/* ---------- Checklist tab (single card, comfortable reading width) ---------- */
.checklist-wrap { max-width: 720px; margin: 0 auto; }
@media (max-width: 900px) {
.money-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.nav-trip-sub { display: none; }
.nav-trip-slot { gap: 0.4rem; margin: 0 0.5rem; }
}
@media (max-width: 480px) {
.tab-label { display: none; }
.tab-btn { padding: 0.5rem 0.65rem; }
}
+1
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="./css/checklist.css" /> <link rel="stylesheet" href="./css/checklist.css" />
<link rel="stylesheet" href="./css/expenses.css" /> <link rel="stylesheet" href="./css/expenses.css" />
<link rel="stylesheet" href="./css/timeline.css" /> <link rel="stylesheet" href="./css/timeline.css" />
<link rel="stylesheet" href="./css/tabs.css" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+16 -4
View File
@@ -18,16 +18,22 @@ function navigate(hash) {
} }
// Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } } // Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } }
// "#/trip/5/money" -> { name: 'trip', params: { id: '5', tab: 'money' } }; an
// unrecognized third segment is passed through as-is (tripDetail defaults it
// to 'trip' rather than the router 404ing on it).
function parseHash() { function parseHash() {
const raw = (location.hash || '').replace(/^#/, ''); const raw = (location.hash || '').replace(/^#/, '');
const parts = raw.split('/').filter(Boolean); const parts = raw.split('/').filter(Boolean);
if (parts.length === 0) return { name: 'trips', params: {} }; if (parts.length === 0) return { name: 'trips', params: {} };
if (parts[0] === 'login') return { name: 'login', params: {} }; if (parts[0] === 'login') return { name: 'login', params: {} };
if (parts[0] === 'trips') return { name: 'trips', params: {} }; if (parts[0] === 'trips') return { name: 'trips', params: {} };
if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1] } }; if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1], tab: parts[2] } };
return { name: 'trips', params: {} }; return { name: 'trips', params: {} };
} }
// The empty .nav-trip-slot sits between the brand and the user area; non-trip
// views leave it empty (it costs no space — see .nav-trip-slot:empty in
// tabs.css). tripDetail fills it via ctx.navTripSlot once the trip loads.
function renderNav() { function renderNav() {
const nav = el( const nav = el(
'header', 'header',
@@ -38,6 +44,7 @@ function renderNav() {
el('span', { class: 'brand-mark' }, '🧭'), el('span', { class: 'brand-mark' }, '🧭'),
el('span', { class: 'brand-name' }, 'Trip Plan'), el('span', { class: 'brand-name' }, 'Trip Plan'),
), ),
el('div', { class: 'nav-trip-slot' }),
el( el(
'div', 'div',
{ class: 'nav-right' }, { class: 'nav-right' },
@@ -132,16 +139,21 @@ function render() {
const container = root(); const container = root();
clear(container); clear(container);
if (state.user) container.appendChild(renderNav()); let navTripSlot = null;
if (state.user) {
const nav = renderNav();
navTripSlot = nav.querySelector('.nav-trip-slot');
container.appendChild(nav);
}
const viewEl = el('main', { class: 'view', id: 'view' }); const viewEl = el('main', { class: 'view', id: 'view' });
container.appendChild(viewEl); container.appendChild(viewEl);
const ctx = { state, navigate, refresh: render }; const ctx = { state, navigate, refresh: render, navTripSlot };
if (view.name === 'login') renderAuth(viewEl, ctx); if (view.name === 'login') renderAuth(viewEl, ctx);
else if (view.name === 'trips') renderTrips(viewEl, ctx); else if (view.name === 'trips') renderTrips(viewEl, ctx);
else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id); else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id, view.params.tab);
} }
// Exposed so views can update the current user after login/register. // Exposed so views can update the current user after login/register.
+29 -6
View File
@@ -17,6 +17,13 @@ export function renderMap(tctx) {
const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
const stops = route.stops || []; 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' }); const section = el('section', { class: 'card map-section' });
section.appendChild( section.appendChild(
el( el(
@@ -54,7 +61,7 @@ export function renderMap(tctx) {
// Leaflet needs the container attached with a real size, so init on the // Leaflet needs the container attached with a real size, so init on the
// next tick after this section is mounted into the page. // 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; return section;
} }
@@ -75,7 +82,7 @@ function visibleLegs(route, stops) {
return result; return result;
} }
function initMap(mapDiv, stops, legs, kmEls) { function initMap(mapDiv, stops, legs, kmEls, tctx) {
const L = window.L; const L = window.L;
if (!L) { if (!L) {
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.')); 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 }; return { leg, a, b, line, label, color };
}); });
if (points.length === 1) { function fit() {
map.setView(points[0], 10); if (points.length === 1) map.setView(points[0], 10);
} else { else map.fitBounds(L.latLngBounds(points).pad(0.2));
map.fitBounds(L.latLngBounds(points).pad(0.2));
} }
fit();
map.invalidateSize(); 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); fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls);
} }
+259 -103
View File
@@ -1,8 +1,11 @@
// Orchestrates the trip page: header + calendar, then map + summary. // Orchestrates the trip page: a compact nav-bar trip identity block, a
// Owns the shared trip context passed to the calendar, map, summary and // Trip/Money/Checklist tab bar, and three always-mounted panels. Owns the
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip, // shared trip context passed to the calendar, map, summary, costs, expenses,
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its // checklist and day-editor sub-views: { state, navigate, navTripSlot,
// derived /route data so all three panels stay in sync. // 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 { api } from '../api.js';
import { el, clear, mount, loading, errorBox, toast } from '../dom.js'; import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js'; import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
@@ -15,7 +18,22 @@ import { renderChecklist } from './checklist.js';
import { renderCountdown } from './flipclock.js'; import { renderCountdown } from './flipclock.js';
import { openDayEditor } from './dayEditor.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 = { const tctx = {
...ctx, ...ctx,
tripId: id, tripId: id,
@@ -25,10 +43,14 @@ export function renderTripDetail(container, ctx, id) {
_onModalRefresh: null, _onModalRefresh: null,
}; };
let activeTab = TAB_IDS.includes(tab) ? tab : 'trip';
// The mounted Costs section node, so refreshCosts() can swap it in place // The mounted Costs section node, so refreshCosts() can swap it in place
// without redrawing the whole page (self-fetching cards like Expenses // without redrawing the whole page (self-fetching cards like Expenses
// trigger this instead of tctx.refreshTrip()). // trigger this instead of tctx.refreshTrip()).
let costsSection = null; let costsSection = null;
let tabButtons = [];
let tabPanels = [];
mount(container, loading('Loading trip…')); mount(container, loading('Loading trip…'));
init(); init();
@@ -89,95 +111,184 @@ export function renderTripDetail(container, ctx, id) {
} }
function draw() { function draw() {
renderNavTripContext();
const page = el('div', { class: 'page' }); const page = el('div', { class: 'page' });
page.appendChild(renderHeader()); page.appendChild(renderTabBar());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx)); const tripPanel = el('section', {
costsSection = renderCosts(tctx); class: 'tab-panel', id: 'tab-panel-trip', role: 'tabpanel', 'aria-labelledby': 'tab-trip',
page.appendChild( });
tripPanel.appendChild(renderCountdown(tctx));
tripPanel.appendChild(renderCalendar(tctx));
tripPanel.appendChild(
el( el(
'div', 'div',
{ class: 'detail-grid' }, { class: 'detail-grid' },
renderMap(tctx), renderMap(tctx),
el( el('div', { class: 'detail-side' }, renderSummary(tctx)),
'div',
{ class: 'detail-side' },
renderSummary(tctx),
costsSection,
renderExpenses(tctx),
renderChecklist(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); 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 { trip, members } = tctx.trip;
const isOwner = trip.owner_id === tctx.state.user.id; const isOwner = trip.owner_id === tctx.state.user.id;
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date); 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( let outsideHandler = null;
'div', let escHandler = null;
{ class: 'trip-header-top' }, let hashHandler = null;
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( function closeMenu() {
'div', dropdown.classList.remove('open');
{ class: 'members-row' }, menuBtn.setAttribute('aria-expanded', 'false');
el('span', { class: 'members-label' }, 'Members:'), if (outsideHandler) { document.removeEventListener('click', outsideHandler); outsideHandler = null; }
...members.map((m) => if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
el( if (hashHandler) { window.removeEventListener('hashchange', hashHandler); hashHandler = null; }
'span', }
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` }, function openMenu() {
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()), dropdown.classList.add('open');
m.display_name, menuBtn.setAttribute('aria-expanded', 'true');
isOwner && m.role !== 'owner' outsideHandler = (e) => { if (!dropdown.contains(e.target) && e.target !== menuBtn) closeMenu(); };
? el('button', { escHandler = (e) => { if (e.key === 'Escape') closeMenu(); };
class: 'member-remove', hashHandler = closeMenu;
title: `Remove ${m.display_name}`, document.addEventListener('click', outsideHandler);
onClick: () => onRemoveMember(m), document.addEventListener('keydown', escHandler);
}, '×') window.addEventListener('hashchange', hashHandler);
: null,
),
),
);
header.appendChild(titleRow);
header.appendChild(joinCodeRow(trip, isOwner));
header.appendChild(membersRow);
return header;
} }
// Share code for inviting others — display only, not a credential. const dropdown = buildDropdown(trip, members, isOwner, closeMenu);
function joinCodeRow(trip, isOwner) { 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',
{ class: 'nav-trip-id' },
el('span', { class: 'nav-trip-name', title: trip.name }, trip.name),
el(
'span',
{ 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),
);
}
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 grouped = groupCode(trip.join_code, 4);
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy'); const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
copyBtn.addEventListener('click', async () => { copyBtn.addEventListener('click', async () => {
@@ -188,43 +299,65 @@ export function renderTripDetail(container, ctx, id) {
toast('Copy failed — select and copy it manually'); toast('Copy failed — select and copy it manually');
} }
}); });
const joinRow = el(
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', 'div',
{ class: 'joincode-row' }, { class: 'trip-menu-joincode' },
el('span', { class: 'members-label' }, 'Join code:'),
el('span', { class: 'joincode-chip' }, grouped), el('span', { class: 'joincode-chip' }, grouped),
copyBtn, copyBtn,
isOwner 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, : 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) { // ---------- Edit-trip modal ----------
const existing = header.querySelector('.trip-edit');
if (existing) { function openEditModal() {
existing.remove();
return;
}
const { trip } = tctx.trip; 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 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 startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_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 errorEl = el('p', { class: 'form-error' });
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes'); 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) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
@@ -241,6 +374,7 @@ export function renderTripDetail(container, ctx, id) {
try { try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency }); await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success'); toast('Trip updated', 'success');
close();
await tctx.refreshTrip(); await tctx.refreshTrip();
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
@@ -249,7 +383,15 @@ export function renderTripDetail(container, ctx, id) {
} }
} }
const editBox = el( mount(
modal,
el(
'div',
{ class: 'slideover-head' },
el('h2', {}, '✎ Edit trip'),
el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'),
),
el(
'form', 'form',
{ class: 'trip-edit', onSubmit }, { class: 'trip-edit', onSubmit },
el( el(
@@ -266,11 +408,14 @@ export function renderTripDetail(container, ctx, id) {
), ),
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'), el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
errorEl, errorEl,
el('div', { class: 'form-actions' }, saveBtn), el('div', { class: 'form-actions' }, cancelBtn, saveBtn),
),
); );
header.appendChild(editBox); nameInput.focus();
} }
// ---------- Member / trip mutations ----------
async function onRemoveMember(member) { async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return; if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try { 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() { async function onDelete() {
const { trip } = tctx.trip; const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return; if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;