Calendar chips can be dragged onto another in-range day (multi-day entries shift end_date by the same delta in one PATCH); day-editor rows get a drag handle to reorder within a day, patching sort_order only for changed positions. Continues/dropoff ghost chips and stay bands are not draggable; touch devices fall back to the existing click/edit flow. Each stay band now gets a deterministic colour from an 8-hue palette by chronological index, mirrored as dots in the summary Areas list.
132 lines
4.6 KiB
JavaScript
132 lines
4.6 KiB
JavaScript
// 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';
|
|
import { stayColor } from './stayBands.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,
|
|
s.stays > 0 ? tile('🏙️', s.stays, s.stays === 1 ? 'Stay' : 'Stays') : 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 areas = s.areas || [];
|
|
if (areas.length) {
|
|
const areaBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Areas'));
|
|
const ul = el('ul', { class: 'area-list' });
|
|
// areas arrive in the same chronological order as computeStayLayout sorts
|
|
// stays, so index i shares the band colour (stayColor(i)).
|
|
areas.forEach((a, i) => {
|
|
ul.appendChild(el(
|
|
'li',
|
|
{ class: 'area-item' },
|
|
el('span', { class: 'area-dot', style: { background: stayColor(i) } }),
|
|
`🏙️ ${a.name} — ${a.days} ${a.days === 1 ? 'day' : 'days'}`,
|
|
));
|
|
});
|
|
areaBlock.appendChild(ul);
|
|
section.appendChild(areaBlock);
|
|
}
|
|
|
|
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();
|
|
}
|