Rework entry types: merge hotel into stay, transport with modes, auto-transport
- Type set is now activity/stay/transport/flight/rental/note; hotel, travel and immigration are removed with idempotent startup data migrations (hotel->stay, travel->transport, immigration->activity with flag prefix) - Transport entries carry an optional mode (train/bus/ferry/taxi/drive/other) that drives the chip/map icon; route stops expose transport_mode - Creating a stay auto-creates a bridging transport to its neighbouring stays unless a transport/flight already covers the gap (one-shot) - Summary: transports count replaces hotels/travelLegs
This commit is contained in:
+22
-3
@@ -6,12 +6,10 @@
|
||||
// is roughly most-common-first with Activity as the default for new entries.
|
||||
export const ENTRY_TYPES = {
|
||||
activity: { label: 'Activity', icon: '📍', color: '#059669' },
|
||||
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
|
||||
stay: { label: 'Stay', icon: '🏙️', color: '#f59e0b' },
|
||||
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
|
||||
transport: { label: 'Transport', icon: '🚆', color: '#d97706' },
|
||||
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
|
||||
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
|
||||
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
|
||||
note: { label: 'Note', icon: '📝', color: '#64748b' },
|
||||
};
|
||||
|
||||
@@ -20,10 +18,31 @@ export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) =
|
||||
...meta,
|
||||
}));
|
||||
|
||||
// Transport entries may carry an optional transport_mode; this is its own
|
||||
// select (shown only for type === 'transport'), separate from ENTRY_TYPES.
|
||||
export const TRANSPORT_MODES = [
|
||||
{ value: 'train', label: 'Train', icon: '🚆' },
|
||||
{ value: 'bus', label: 'Bus', icon: '🚌' },
|
||||
{ value: 'ferry', label: 'Ferry', icon: '⛴️' },
|
||||
{ value: 'taxi', label: 'Taxi', icon: '🚕' },
|
||||
{ value: 'drive', label: 'Drive', icon: '🚗' },
|
||||
{ value: 'other', label: 'Other', icon: '➡️' },
|
||||
];
|
||||
|
||||
export function typeInfo(type) {
|
||||
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
|
||||
}
|
||||
|
||||
// Icon for an entry: a transport entry with a mode shows the mode's icon,
|
||||
// otherwise falls back to the type's icon.
|
||||
export function entryIcon(entry) {
|
||||
if (entry && entry.type === 'transport' && entry.transport_mode) {
|
||||
const mode = TRANSPORT_MODES.find((m) => m.value === entry.transport_mode);
|
||||
if (mode) return mode.icon;
|
||||
}
|
||||
return typeInfo(entry && entry.type).icon;
|
||||
}
|
||||
|
||||
// Split modes with the human labels the day-editor select shows.
|
||||
export const SPLIT_MODES = [
|
||||
{ value: 'equal', label: 'Split equally' },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { el } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPES,
|
||||
typeInfo,
|
||||
entryIcon,
|
||||
parseYMD,
|
||||
ymd,
|
||||
addDays,
|
||||
@@ -189,7 +190,7 @@ function chip(entry, currency) {
|
||||
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-icon' }, entryIcon(entry)),
|
||||
el('span', { class: 'chip-text' }, label),
|
||||
hasPrice
|
||||
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
|
||||
@@ -239,7 +240,7 @@ function dropoffChip(entry, tctx) {
|
||||
tabindex: '0',
|
||||
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-icon' }, entryIcon(entry)),
|
||||
el('span', { class: 'chip-text' }, 'dropoff'),
|
||||
);
|
||||
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from '../api.js';
|
||||
import { el, clear, mount, toast } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPE_LIST,
|
||||
TRANSPORT_MODES,
|
||||
splitModeLabel,
|
||||
typeInfo,
|
||||
formatDate,
|
||||
@@ -183,6 +184,7 @@ export function openDayEditor(tctx, date) {
|
||||
fields.start.value = entry.start_time || '';
|
||||
fields.end.value = entry.end_time || '';
|
||||
fields.endDate.value = entry.end_date || '';
|
||||
fields.mode.value = entry.transport_mode || '';
|
||||
fields.cost.prefill(entry);
|
||||
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
||||
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
||||
@@ -223,6 +225,18 @@ export function openDayEditor(tctx, date) {
|
||||
locWrap.append(locInput, locResults, locSelected);
|
||||
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
|
||||
|
||||
// ----- Transport mode (shown only for transport entries) -----
|
||||
// Declared before the flight/rental subsections below: their onChange
|
||||
// callbacks can call syncTypeUI() during construction (see the TDZ note
|
||||
// below it), so anything syncTypeUI touches must already exist.
|
||||
const modeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '' }, '—'),
|
||||
...TRANSPORT_MODES.map((m) => el('option', { value: m.value }, `${m.icon} ${m.label}`)),
|
||||
);
|
||||
const modeField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Mode'), modeSelect);
|
||||
|
||||
// ----- 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.
|
||||
@@ -253,6 +267,7 @@ export function openDayEditor(tctx, date) {
|
||||
const type = typeSelect.value;
|
||||
flightSection.style.display = type === 'flight' ? '' : 'none';
|
||||
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
||||
modeField.style.display = type === 'transport' ? '' : 'none';
|
||||
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
||||
// Stays emphasise an end date ("until"); other types call it "End date".
|
||||
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
|
||||
@@ -267,7 +282,7 @@ export function openDayEditor(tctx, date) {
|
||||
|
||||
fields = {
|
||||
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
||||
endDate: endDateInput,
|
||||
endDate: endDateInput, mode: modeSelect,
|
||||
locInput, locResults, locSelected,
|
||||
cost: costForm, flightRoute, rentalDetails,
|
||||
};
|
||||
@@ -334,6 +349,10 @@ export function openDayEditor(tctx, date) {
|
||||
payload.rental = null;
|
||||
}
|
||||
|
||||
// Transport mode (transport entries only). Clear it otherwise so
|
||||
// changing an entry's type away from transport drops any prior mode.
|
||||
payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null;
|
||||
|
||||
// end_date must be on/after the effective start date (rental may have
|
||||
// moved payload.date to the pickup date above).
|
||||
if (payload.end_date && payload.end_date < payload.date) {
|
||||
@@ -383,6 +402,7 @@ export function openDayEditor(tctx, date) {
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
|
||||
modeField,
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
|
||||
),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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';
|
||||
import { typeInfo, entryIcon } from '../format.js';
|
||||
|
||||
export function renderMap(tctx) {
|
||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||
@@ -65,7 +65,7 @@ function initMap(mapDiv, route, stops) {
|
||||
const info = typeInfo(stop.type);
|
||||
L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) })
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(stop, info));
|
||||
.bindPopup(popupHtml(stop, info, entryIcon(stop)));
|
||||
});
|
||||
|
||||
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
||||
@@ -128,9 +128,9 @@ function kmLabel(L, km) {
|
||||
}
|
||||
|
||||
// Built from server-provided fields; escape to keep the popup injection-safe.
|
||||
function popupHtml(stop, info) {
|
||||
function popupHtml(stop, info, icon) {
|
||||
const isAirport = stop.kind === 'airport' && stop.code;
|
||||
const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.icon} ${esc(stop.title)}`;
|
||||
const heading = isAirport ? `${icon} ${esc(stop.code)}` : `${icon} ${esc(stop.title)}`;
|
||||
const sub = isAirport
|
||||
? `${esc(info.label)} · ${esc(stop.date)}${stop.title ? ` · ${esc(stop.title)}` : ''}`
|
||||
: `${esc(info.label)} · ${esc(stop.date)}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Summary panel built from the /route response's `summary` block:
|
||||
// days, nights, flights, hotels, travel legs, activities, total km, and the
|
||||
// days, nights, flights, transports, activities, total km, and the
|
||||
// list of locations in visit order.
|
||||
import { el } from '../dom.js';
|
||||
import { stayColor } from './stayBands.js';
|
||||
@@ -7,7 +7,7 @@ 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: [],
|
||||
days: 0, nights: 0, flights: 0, flightSegments: 0, transports: 0, activities: 0, locations: [],
|
||||
};
|
||||
const totalKm = route.totalKm || 0;
|
||||
// Show the leg count under the Flights tile only when a flight has segments.
|
||||
@@ -31,9 +31,8 @@ export function renderSummary(tctx) {
|
||||
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.transports > 0 ? tile('🚆', s.transports, s.transports === 1 ? 'Transport' : 'Transports') : null,
|
||||
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
|
||||
s.stays > 0 ? tile('🏙️', s.stays, s.stays === 1 ? 'Stay' : 'Stays') : null,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user