// 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'; // Per-instance band palette: each stay gets its own hue (not the generic stay // type colour) so overlapping/adjacent areas read apart. Muted-but-distinct // tones that harmonise with the entry-type palette and keep #334155 text // readable on their color-mix(…20%, white) band backgrounds. Assignment is // deterministic — index in the chronologically-sorted stays list % 8 — so a // stay keeps its colour across re-renders, week rows, and the Areas list. export const STAY_PALETTE = [ '#f59e0b', // amber '#14b8a6', // teal '#8b5cf6', // violet '#f43f5e', // rose '#0ea5e9', // sky '#65a30d', // lime '#f97316', // orange '#6366f1', // indigo ]; // Deterministic colour for the Nth stay in chronological order. export function stayColor(index) { return STAY_PALETTE[((index % STAY_PALETTE.length) + STAY_PALETTE.length) % STAY_PALETTE.length]; } // Returns { stays: [{entry,start,end,name,days,lane,color}], 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, color: null, })) .sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.entry.id - b.entry.id)); // Colour by chronological index (same ordering summary.areas uses), assigned // here next to lanes so every consumer of the layout shares one source. stays.forEach((s, i) => { s.color = stayColor(i); }); 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': s.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); }