Add multi-day entries and area stay blocks
Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
This commit is contained in:
+18
-4
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+77
-15
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
@@ -10,3 +10,4 @@ import './api.test.js';
|
||||
import './costs.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
import './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);
|
||||
});
|
||||
Reference in New Issue
Block a user