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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user