diff --git a/docs/API.md b/docs/API.md index fe4aea5..dbd04f5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -34,7 +34,9 @@ trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES u role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor' PRIMARY KEY (trip_id, user_id)) entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id), - date TEXT NOT NULL, type TEXT NOT NULL, + date TEXT NOT NULL, -- start date + end_date TEXT, -- optional inclusive end date (multi-day entries; null = single day) + type TEXT NOT NULL, title TEXT NOT NULL, details TEXT DEFAULT '', start_time TEXT, end_time TEXT, location_name TEXT, lat REAL, lng REAL, @@ -49,7 +51,18 @@ entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REF -- no rows for an entry = "all trip members participate" (dynamic default) ``` -Entry `type` ∈ `flight | immigration | travel | hotel | activity | rental | note`. +Entry `type` ∈ `flight | immigration | travel | hotel | activity | rental | stay | note`. + +### Multi-day entries & stays + +Any entry may carry an optional `end_date` (inclusive, `YYYY-MM-DD`, must be ≥ `date`; PATCH `end_date: null` clears it). Use cases: + +- **Flights/travel longer than 24 h** — dep on `date` at `start_time`, arr on `end_date` at `end_time`. +- **`stay` entries ("area blocks")** — "3 days Venice", "7 days Berlin": a location plus a date range. The frontend renders stays as continuous bands across the calendar days (all-day-event style), not as chips; other multi-day entries show a chip on the start day and lightweight "…continues" markers on the days up to `end_date`. + +Stays behave like normal entries otherwise: optional geocoded location (feeds the route as one stop, positioned at the start date), optional price/split (e.g. an apartment for the whole block). + +Summary additions: `stays` (count) and `areas` — stay entries in chronological order as `[{ "name": "Venice", "days": 3 }]` where `name` is the stay's `location_name` (fallback: title) and `days` is the inclusive span (`end_date` null → 1). Example: `"areas": [{"name":"Venice","days":3},{"name":"Berlin","days":7}]`. ### Rental car details @@ -142,11 +155,11 @@ User JSON shape everywhere: `{id, display_name}`. | Method & path | Body | Response | |---|---|---| -| `POST /api/trips/:id/entries` | `{date, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above | +| `POST /api/trips/:id/entries` | `{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above | | `PATCH /api/entries/:id` | any subset of the above | `200 {entry}` (member of the entry's trip required; `participants` replaces the whole set) | | `DELETE /api/entries/:id` | — | `204` (also deletes its entry_participants rows) | -Entry JSON shape (always full row): `{id, trip_id, date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`. +Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`. ### Route & summary (computed) @@ -162,6 +175,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, type, title, details, s "days": 10, "nights": 9, "flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4, "rentals": 1, "includedKm": 1500, + "stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}], "kmAir": 0, "kmDriven": 587.3, "locations": ["Bangkok", "Chiang Mai"] } diff --git a/public/css/styles.css b/public/css/styles.css index b6fbda5..600d7c0 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -277,6 +277,7 @@ textarea.input { resize: vertical; } .leg-mode { flex-shrink: 0; font-size: 0.85rem; } .loc-block h3 { margin-bottom: 0.5rem; } .loc-order { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; } +.area-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; } /* ---------- Slide-over day editor ---------- */ .overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45); z-index: 50; display: flex; justify-content: flex-end; animation: fade 0.15s ease; } diff --git a/public/js/format.js b/public/js/format.js index c72c485..d9d6c9f 100644 --- a/public/js/format.js +++ b/public/js/format.js @@ -7,6 +7,7 @@ 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' }, flight: { label: 'Flight', icon: '✈️', color: '#2563eb' }, rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' }, @@ -146,3 +147,20 @@ export function hasSegments(entry) { export function hasRental(entry) { return entry && entry.rental && typeof entry.rental === 'object'; } + +// True when an entry spans more than its start day (valid end_date after date). +export function isMultiDay(entry) { + return !!(entry && entry.end_date && entry.end_date > entry.date); +} + +// Inclusive span in days (end_date null → 1). +export function entrySpanDays(entry) { + const end = entry.end_date && entry.end_date >= entry.date ? entry.end_date : entry.date; + return daysBetweenInclusive(entry.date, end); +} + +// A stay's short label: first comma-segment of its location name (fallback title). +export function stayShortName(entry) { + const base = entry.location_name || entry.title || 'Stay'; + return base.split(',')[0].trim(); +} diff --git a/public/js/views/calendar.js b/public/js/views/calendar.js index 26412bc..cce7f0e 100644 --- a/public/js/views/calendar.js +++ b/public/js/views/calendar.js @@ -13,7 +13,9 @@ import { flightChain, hasSegments, hasRental, + isMultiDay, } from '../format.js'; +import { computeStayLayout, renderWeekBands } from './stayBands.js'; const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -26,6 +28,9 @@ export function renderCalendar(tctx) { // 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(); + // Continuation ghosts: a non-stay multi-day entry marks each following day + // through end_date with a "…continues" chip. + const contByDate = new Map(); for (const entry of entries) { if (!byDate.has(entry.date)) byDate.set(entry.date, []); byDate.get(entry.date).push(entry); @@ -36,8 +41,20 @@ export function renderCalendar(tctx) { dropoffByDate.get(dropDate).push(entry); } } + if (entry.type !== 'stay' && isMultiDay(entry)) { + let d = addDays(parseYMD(entry.date), 1); + const end = parseYMD(entry.end_date); + while (d <= end) { + const k = ymd(d); + if (!contByDate.has(k)) contByDate.set(k, []); + contByDate.get(k).push(entry); + d = addDays(d, 1); + } + } } + const stayLayout = computeStayLayout(entries); + const rangeStart = parseYMD(trip.start_date); const rangeEnd = parseYMD(trip.end_date); const gridStart = startOfWeekMon(rangeStart); @@ -54,35 +71,54 @@ export function renderCalendar(tctx) { 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. + // Collect whole weeks (Mon–Sun) covering the range. + const weeks = []; let cursor = gridStart; let guard = 0; while (cursor <= rangeEnd && guard < 400) { + const week = []; for (let i = 0; i < 7; i++) { - grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency)); + week.push(cursor); cursor = addDays(cursor, 1); } + weeks.push(week); guard += 7; } - section.appendChild(grid); + const cal = el('div', { class: 'calendar' }); + const header = el('div', { class: 'cal-weekdays' }); + for (const label of WEEKDAYS) header.appendChild(el('div', { class: 'cal-weekday' }, label)); + cal.appendChild(header); + + for (const week of weeks) { + // Each week is a stay-bands strip (all-day bars) above a row of day cells. + const weekEl = el('div', { class: 'cal-week' }); + const bands = renderWeekBands(week.map(ymd), stayLayout, tctx); + if (bands) weekEl.appendChild(bands); + const daysRow = el('div', { class: 'cal-week-days' }); + for (const date of week) { + daysRow.appendChild(dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, tctx, currency)); + } + weekEl.appendChild(daysRow); + cal.appendChild(weekEl); + } + + section.appendChild(cal); return section; } -function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) { +function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, tctx, currency) { const key = ymd(date); const inRange = date >= rangeStart && date <= rangeEnd; - const dayEntries = byDate.get(key) || []; + // Stays render as bands, not chips, so exclude them from the day cell. + const dayEntries = (byDate.get(key) || []).filter((e) => e.type !== 'stay'); + const conts = contByDate.get(key) || []; const dropoffs = dropoffByDate.get(key) || []; + const hasAny = dayEntries.length || conts.length || dropoffs.length; const isFirstOfMonth = date.getDate() === 1; const cell = el('div', { - class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' cal-has' : ''}`, + class: `cal-day${inRange ? '' : ' cal-out'}${hasAny ? ' cal-has' : ''}`, }); cell.appendChild( @@ -93,7 +129,7 @@ function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, curren isFirstOfMonth ? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' })) : null, - !inRange && (dayEntries.length || dropoffs.length) + !inRange && hasAny ? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠') : null, ), @@ -101,13 +137,15 @@ function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, curren const chips = el('div', { class: 'cal-chips' }); for (const entry of dayEntries) chips.appendChild(chip(entry, currency)); + // "…continues" ghosts for multi-day entries; click opens the start day. + for (const entry of conts) chips.appendChild(continuationChip(entry, tctx)); // 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) { + // In-range days are always clickable; out-of-range days only when they hold + // something (a chip, continuation, or dropoff marker). + if (inRange || hasAny) { cell.classList.add('clickable'); cell.tabIndex = 0; cell.setAttribute('role', 'button'); @@ -144,6 +182,30 @@ function chip(entry, currency) { ); } +// Ghost chip on the days a multi-day entry spans after its start. Clicking +// opens the START day's editor (where the entry lives). +function continuationChip(entry, tctx) { + const info = typeInfo(entry.type); + const node = el( + 'div', + { + class: 'cal-chip cal-chip-cont', + style: { '--chip': info.color }, + role: 'button', + tabindex: '0', + title: `${info.label}: ${entry.title} (continues) — opens the start day`, + }, + el('span', { class: 'chip-icon' }, '⤷'), + el('span', { class: 'chip-text' }, 'continues'), + ); + 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; +} + // 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) { diff --git a/public/js/views/dayEditor.js b/public/js/views/dayEditor.js index f7bcdb3..e725f1b 100644 --- a/public/js/views/dayEditor.js +++ b/public/js/views/dayEditor.js @@ -9,12 +9,15 @@ import { ENTRY_TYPE_LIST, splitModeLabel, typeInfo, + formatDate, formatFullDate, formatTimeRange, formatMoney, flightChain, hasSegments, hasRental, + isMultiDay, + entrySpanDays, } from '../format.js'; import { createFlightRoute, renderSegmentLines } from './segments.js'; import { createRentalDetails, renderRentalLine } from './rental.js'; @@ -119,6 +122,7 @@ export function openDayEditor(tctx, date) { flight ? ` · ✈️ ${flightChain(entry.segments)}` : '', !flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '', ), + isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null, flight ? renderSegmentLines(entry.segments) : null, hasRental(entry) ? renderRentalLine(entry.rental) : null, hasPrice @@ -140,6 +144,16 @@ export function openDayEditor(tctx, date) { ); } + // "until 8 Aug · 3 days" for stays; "5 Aug 20:50 → 7 Aug 06:30" otherwise. + function spanText(entry) { + if (entry.type === 'stay') { + return `until ${formatDate(entry.end_date)} · ${entrySpanDays(entry)} days`; + } + const a = `${formatDate(entry.date)}${entry.start_time ? ` ${entry.start_time}` : ''}`; + const b = `${formatDate(entry.end_date)}${entry.end_time ? ` ${entry.end_time}` : ''}`; + return `${a} → ${b}`; + } + function startEdit(entry) { editing = entry.id; loc = entry.lat != null && entry.lng != null @@ -152,6 +166,7 @@ export function openDayEditor(tctx, date) { fields.details.value = entry.details || ''; fields.start.value = entry.start_time || ''; fields.end.value = entry.end_time || ''; + fields.endDate.value = entry.end_date || ''; fields.cost.prefill(entry); // Flight segments (load() triggers the flight-route onChange -> UI sync). fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []); @@ -174,6 +189,11 @@ export function openDayEditor(tctx, date) { 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' }); + // Optional end date for multi-day entries (all types). Relabeled "Until" + // for stays via syncTypeUI. + const endDateInput = el('input', { class: 'input', type: 'date', min: date }); + const endDateLabel = el('span', { class: 'field-label' }, 'End date'); + const endDateField = el('label', { class: 'field' }, endDateLabel, endDateInput); const locWrap = el('div', { class: 'loc-field' }); const locInput = el('input', { @@ -218,6 +238,9 @@ export function openDayEditor(tctx, date) { flightSection.style.display = type === 'flight' ? '' : 'none'; rentalSection.style.display = type === 'rental' ? '' : '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'; + locationField.classList.toggle('field-emphasis', type === 'stay'); } typeSelect.addEventListener('change', syncTypeUI); typeUIReady = true; @@ -228,6 +251,7 @@ export function openDayEditor(tctx, date) { fields = { type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput, + endDate: endDateInput, locInput, locResults, locSelected, cost: costForm, flightRoute, rentalDetails, }; @@ -240,11 +264,19 @@ export function openDayEditor(tctx, date) { async function onSubmit(e) { e.preventDefault(); errorEl.textContent = ''; - const title = titleInput.value.trim(); - if (!title) return (errorEl.textContent = 'Title is required.'); + const isStay = typeSelect.value === 'stay'; + let title = titleInput.value.trim(); + if (!title) { + // Stays may leave the title blank — default to the location's short name. + if (isStay && loc) title = loc.name.split(',')[0].trim(); + else return (errorEl.textContent = isStay + ? 'Give the stay a title or pick a location.' + : 'Title is required.'); + } const payload = { date, + end_date: endDateInput.value || null, type: typeSelect.value, title, details: detailsInput.value.trim(), @@ -286,6 +318,12 @@ export function openDayEditor(tctx, date) { payload.rental = 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) { + return (errorEl.textContent = 'End date must be on or after the start date.'); + } + // 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(); @@ -337,6 +375,7 @@ export function openDayEditor(tctx, date) { { 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), + endDateField, ), flightSection, rentalSection, diff --git a/public/js/views/stayBands.js b/public/js/views/stayBands.js new file mode 100644 index 0000000..0cd2ac6 --- /dev/null +++ b/public/js/views/stayBands.js @@ -0,0 +1,80 @@ +// Stay "area" bands for the calendar. Stay entries render as continuous +// coloured bars spanning date..end_date across each week row (all-day-event +// style) instead of day chips. Lanes are assigned globally (interval +// partitioning) so a stay keeps the same vertical position across weeks. +import { el } from '../dom.js'; +import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.js'; + +// Returns { stays: [{entry,start,end,name,days,lane}], laneCount }. +export function computeStayLayout(entries) { + const stays = entries + .filter((e) => e.type === 'stay') + .map((e) => ({ + entry: e, + start: e.date, + end: e.end_date && e.end_date >= e.date ? e.end_date : e.date, + name: stayShortName(e), + days: entrySpanDays(e), + lane: 0, + })) + .sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.entry.id - b.entry.id)); + + const laneEnds = []; // last end date (ymd) occupying each lane + for (const s of stays) { + // A lane is free if its last stay ended strictly before this one starts. + let lane = laneEnds.findIndex((end) => end < s.start); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(s.end); + } else { + laneEnds[lane] = s.end; + } + s.lane = lane; + } + return { stays, laneCount: laneEnds.length }; +} + +// Bands strip for one week (7 ymd strings). Returns null if no stay intersects. +export function renderWeekBands(weekYmd, layout, tctx) { + const weekStart = weekYmd[0]; + const weekEnd = weekYmd[6]; + const inWeek = layout.stays.filter((s) => s.start <= weekEnd && s.end >= weekStart); + if (!inWeek.length) return null; + + const maxLane = inWeek.reduce((m, s) => Math.max(m, s.lane), 0); + const strip = el('div', { + class: 'cal-week-bands', + style: { gridTemplateRows: `repeat(${maxLane + 1}, var(--band-h))` }, + }); + + for (const s of inWeek) { + const segStart = s.start > weekStart ? s.start : weekStart; + const segEnd = s.end < weekEnd ? s.end : weekEnd; + const col = dayIndex(weekStart, segStart); // 0..6 + const span = daysBetweenInclusive(segStart, segEnd); + const roundLeft = segStart === s.start; + const roundRight = segEnd === s.end; + const info = typeInfo('stay'); + + const band = el('div', { + class: `cal-band${roundLeft ? ' round-l' : ''}${roundRight ? ' round-r' : ''}`, + style: { gridColumn: `${col + 1} / span ${span}`, gridRow: String(s.lane + 1), '--chip': info.color }, + role: 'button', + tabindex: '0', + title: `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`, + }, + el('span', { class: 'cal-band-label' }, `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`)); + + const open = (e) => { e.stopPropagation(); tctx.openDay(s.entry.date); }; + band.addEventListener('click', open); + band.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } + }); + strip.appendChild(band); + } + return strip; +} + +function dayIndex(weekStart, day) { + return Math.round((parseYMD(day) - parseYMD(weekStart)) / 86400000); +} diff --git a/public/js/views/summary.js b/public/js/views/summary.js index 8c568b3..e624d64 100644 --- a/public/js/views/summary.js +++ b/public/js/views/summary.js @@ -34,6 +34,7 @@ export function renderSummary(tctx) { 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); @@ -71,6 +72,17 @@ export function renderSummary(tctx) { 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' }); + for (const a of areas) { + ul.appendChild(el('li', {}, `🏙️ ${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) { diff --git a/src/server/db.js b/src/server/db.js index 9a3d502..a22f909 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL REFERENCES trips(id), date TEXT NOT NULL, + end_date TEXT, type TEXT NOT NULL, title TEXT NOT NULL, details TEXT DEFAULT '', @@ -68,6 +69,7 @@ const MIGRATIONS = [ { table: 'entries', column: 'split_mode', ddl: "ALTER TABLE entries ADD COLUMN split_mode TEXT NOT NULL DEFAULT 'equal'" }, { table: 'entries', column: 'segments', ddl: 'ALTER TABLE entries ADD COLUMN segments TEXT' }, { table: 'entries', column: 'rental', ddl: 'ALTER TABLE entries ADD COLUMN rental TEXT' }, + { table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' }, ]; function applyMigrations(db) { diff --git a/src/server/routes/entries.js b/src/server/routes/entries.js index 09b2092..7dd833b 100644 --- a/src/server/routes/entries.js +++ b/src/server/routes/entries.js @@ -12,6 +12,7 @@ const ENTRY_TYPES = new Set([ 'hotel', 'activity', 'rental', + 'stay', 'note', ]); const SPLIT_MODES = new Set(['equal', 'own', 'payer']); @@ -44,6 +45,25 @@ function validateEntry(body, { partial, existing, memberIds }) { } fields.date = body.date; } + // end_date: optional inclusive end, null clears, must be >= the (effective) date. + // ISO YYYY-MM-DD strings compare correctly lexicographically. + const effDate = 'date' in fields ? fields.date : existing?.date; + if (has('end_date')) { + if (body.end_date === null) { + fields.end_date = null; + } else { + if (!isValidDateStr(body.end_date)) { + return { error: 'end_date must be a valid YYYY-MM-DD date' }; + } + if (effDate && body.end_date < effDate) { + return { error: 'end_date must be on or after date' }; + } + fields.end_date = body.end_date; + } + } else if (partial && 'date' in fields && existing?.end_date && existing.end_date < fields.date) { + // Moving date past a stored end_date would invert the range. + return { error: 'end_date would be before the new date; clear or update end_date' }; + } if (!partial || has('title')) { if (typeof body.title !== 'string' || body.title.trim() === '') { return { error: 'title is required' }; @@ -190,7 +210,7 @@ export default function entriesRoutes(db) { `SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id` ); const getEntryRow = db.prepare( - 'SELECT trip_id, split_mode, paid_by, type FROM entries WHERE id = ?' + 'SELECT trip_id, split_mode, paid_by, type, date, end_date FROM entries WHERE id = ?' ); const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?'); const insertParticipant = db.prepare( @@ -235,13 +255,14 @@ export default function entriesRoutes(db) { const info = db .prepare( `INSERT INTO entries - (trip_id, date, type, title, details, start_time, end_time, + (trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( tripId, f.date, + f.end_date ?? null, f.type, f.title, f.details ?? '', diff --git a/src/server/routes/trips.js b/src/server/routes/trips.js index 4461599..59ed5bf 100644 --- a/src/server/routes/trips.js +++ b/src/server/routes/trips.js @@ -331,6 +331,13 @@ export default function tripsRoutes(db) { } } if (includedKm !== null) includedKm = Math.round(includedKm * 10) / 10; + // Stay "area blocks" in chronological order (allEntries is date-ordered). + const areas = allEntries + .filter((e) => e.type === 'stay') + .map((e) => ({ + name: e.location_name || e.title, + days: e.end_date ? daysInclusive(e.date, e.end_date) : 1, + })); const days = daysInclusive(trip.start_date, trip.end_date); const locations = []; for (const s of stops) { @@ -352,6 +359,8 @@ export default function tripsRoutes(db) { travelLegs: countType('travel'), activities: countType('activity'), rentals: countType('rental'), + stays: countType('stay'), + areas, kmAir: Math.round(kmAir * 10) / 10, kmDriven: Math.round(kmDriven * 10) / 10, includedKm, diff --git a/src/server/util/entrySerialize.js b/src/server/util/entrySerialize.js index e5df95c..050d9a0 100644 --- a/src/server/util/entrySerialize.js +++ b/src/server/util/entrySerialize.js @@ -3,7 +3,7 @@ // "all trip members participate") and a parsed `segments` array (or null). export const ENTRY_COLUMNS = - 'id, trip_id, date, type, title, details, start_time, end_time, ' + + 'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' + 'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental'; // Parse the stored segments JSON text into an array, or null if absent/invalid. diff --git a/tests/api.test.js b/tests/api.test.js index 96a54bb..ad4e583 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -270,8 +270,9 @@ test('entry CRUD and full-row shape', async () => { const entry = create.body.entry; assert.deepEqual( Object.keys(entry).sort(), - ['date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'trip_id', 'type'].sort() + ['date', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'trip_id', 'type'].sort() ); + assert.equal(entry.end_date, null); assert.equal(entry.details, ''); assert.equal(entry.lat, 18.79); assert.equal(entry.price, null); diff --git a/tests/index.js b/tests/index.js index e2a1b02..2aac55e 100644 --- a/tests/index.js +++ b/tests/index.js @@ -10,3 +10,4 @@ import './api.test.js'; import './costs.test.js'; import './flights.test.js'; import './rental.test.js'; +import './stays.test.js'; diff --git a/tests/stays.test.js b/tests/stays.test.js new file mode 100644 index 0000000..7fd62e7 --- /dev/null +++ b/tests/stays.test.js @@ -0,0 +1,130 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import request from 'supertest'; +import { createApp } from '../src/server/app.js'; + +let tmpDir; +let app; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-stays-')); + app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' }); +}); + +afterEach(() => { + try { + if (app.locals.db) app.locals.db.close(); + } catch { + /* ignore */ + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function createAccount() { + const agent = request.agent(app); + const res = await agent.post('/api/auth/account').send({}); + assert.equal(res.status, 201); + return { agent, user: res.body.user }; +} + +async function makeTrip(agent) { + return (await agent.post('/api/trips').send({ name: 'S', start_date: '2026-08-01', end_date: '2026-08-31' })).body.trip; +} + +// --------------------------------------------------------------------------- +// end_date validation +// --------------------------------------------------------------------------- + +test('end_date: accepted (>= date), rejected (< date), cleared via null', async () => { + const { agent } = await createAccount(); + const trip = await makeTrip(agent); + const base = `/api/trips/${trip.id}/entries`; + + const ok = await agent.post(base).send({ date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice' }); + assert.equal(ok.status, 201); + assert.equal(ok.body.entry.end_date, '2026-08-03'); + + const bad = await agent.post(base).send({ date: '2026-08-05', end_date: '2026-08-02', type: 'stay', title: 'X' }); + assert.equal(bad.status, 400); + + const cleared = await agent.patch(`/api/entries/${ok.body.entry.id}`).send({ end_date: null }); + assert.equal(cleared.status, 200); + assert.equal(cleared.body.entry.end_date, null); +}); + +test('end_date: PATCHing date past a stored end_date is rejected', async () => { + const { agent } = await createAccount(); + const trip = await makeTrip(agent); + const entry = (await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice', + })).body.entry; + + // Moving date to 08-10 leaves stored end_date (08-03) < date -> 400. + const conflict = await agent.patch(`/api/entries/${entry.id}`).send({ date: '2026-08-10' }); + assert.equal(conflict.status, 400); + + // Moving date and end_date together is fine. + const ok = await agent.patch(`/api/entries/${entry.id}`).send({ date: '2026-08-10', end_date: '2026-08-12' }); + assert.equal(ok.status, 200); + assert.equal(ok.body.entry.date, '2026-08-10'); + assert.equal(ok.body.entry.end_date, '2026-08-12'); +}); + +test('a >24h flight round-trips end_date', async () => { + const { agent } = await createAccount(); + const trip = await makeTrip(agent); + const res = await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-01', end_date: '2026-08-02', type: 'flight', title: 'SYD-LHR', + start_time: '21:00', end_time: '05:30', + }); + assert.equal(res.status, 201); + assert.equal(res.body.entry.date, '2026-08-01'); + assert.equal(res.body.entry.end_date, '2026-08-02'); +}); + +// --------------------------------------------------------------------------- +// Stay type + areas summary +// --------------------------------------------------------------------------- + +test('stay entries accepted; summary reports stays count and areas in order', async () => { + const { agent } = await createAccount(); + const trip = await makeTrip(agent); + + // Venice 3 days (name from location_name), then Berlin 7 days (name from title fallback). + await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice stay', + location_name: 'Venice', sort_order: 0, + }); + await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-04', end_date: '2026-08-10', type: 'stay', title: 'Berlin', sort_order: 1, + }); + // A single-day stay (no end_date) -> days 1. + await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-11', type: 'stay', title: 'Layover', location_name: 'Doha', + }); + + const res = await agent.get(`/api/trips/${trip.id}/route`); + assert.equal(res.status, 200); + assert.equal(res.body.summary.stays, 3); + assert.deepEqual(res.body.summary.areas, [ + { name: 'Venice', days: 3 }, + { name: 'Berlin', days: 7 }, + { name: 'Doha', days: 1 }, + ]); +}); + +test('stay with location feeds the route as one stop', async () => { + const { agent } = await createAccount(); + const trip = await makeTrip(agent); + await agent.post(`/api/trips/${trip.id}/entries`).send({ + date: '2026-08-01', end_date: '2026-08-05', type: 'stay', title: 'Venice', + location_name: 'Venice', lat: 45.44, lng: 12.32, + }); + const res = await agent.get(`/api/trips/${trip.id}/route`); + assert.equal(res.body.stops.length, 1); + assert.equal(res.body.stops[0].lat, 45.44); + assert.equal(res.body.summary.stays, 1); +});