51 lines
5.4 KiB
JavaScript
51 lines
5.4 KiB
JavaScript
// Calendar week and day views: a time grid with all-day strip, overlapping events side by side and a "now" line.
|
||
// Uses the calendar globals from app.js (calState, loadRange, evStore, ymd, fmtTime, esc, icon, newEvent, viewCalendar).
|
||
const HOUR_PX = 48;
|
||
|
||
/** Lay out one day's timed events in columns so overlapping ones sit side by side. */
|
||
function layoutDay(events) {
|
||
const evs = events.map(e => ({ e, s: e.sm, en: Math.max(e.em, e.sm + 20) })).sort((a, b) => a.s - b.s || b.en - a.en);
|
||
const out = []; let cluster = [], clusterEnd = -1;
|
||
const flush = () => { const cols = []; for (const it of cluster) { let c = cols.findIndex(end => end <= it.s); if (c < 0) { c = cols.length; cols.push(0); } cols[c] = it.en; it.col = c; } cluster.forEach(it => { it.cols = cols.length; out.push(it); }); cluster = []; };
|
||
for (const it of evs) { if (cluster.length && it.s >= clusterEnd) { flush(); clusterEnd = -1; } cluster.push(it); clusterEnd = Math.max(clusterEnd, it.en); }
|
||
if (cluster.length) flush();
|
||
return out;
|
||
}
|
||
|
||
async function renderTimeGrid(nDays) {
|
||
const sel = new Date(calState.sel + 'T00:00:00'), start = new Date(sel);
|
||
if (nDays === 7) start.setDate(start.getDate() - start.getDay());
|
||
const { byDay, errors } = await loadRange(start, nDays);
|
||
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
|
||
evStore.length = 0;
|
||
const today = ymd(new Date()), days = [...Array(nDays)].map((_, i) => { const d = new Date(start); d.setDate(d.getDate() + i); return d; });
|
||
const title = nDays === 1 ? sel.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||
: days[0].toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' – ' + days[6].toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||
const allDay = days.map(d => (byDay.get(ymd(d)) || []).filter(e => e.allDay));
|
||
const cols = days.map(d => {
|
||
const k = ymd(d), t0 = new Date(k + 'T00:00:00').getTime(), t1 = t0 + 864e5;
|
||
const timed = (byDay.get(k) || []).filter(e => !e.allDay).map(e => ({ ...e, sm: (Math.max(e.start, t0) - t0) / 6e4, em: (Math.min(e.end, t1) - t0) / 6e4, orig: e }));
|
||
return layoutDay(timed).map(({ e, s, en, col, cols: n }) => {
|
||
const i = evStore.push(e.orig) - 1, h = Math.max(18, (en - s) / 60 * HOUR_PX - 2);
|
||
return `<button class="tgev" data-ei="${i}" style="top:${s / 60 * HOUR_PX}px;height:${h}px;left:calc(${col * 100 / n}% + 1px);width:calc(${100 / n}% - 2px);--c:${esc(e.color)}" aria-label="${esc(e.title)}, ${fmtTime(e.start)} to ${fmtTime(e.end)}">
|
||
<b>${esc(e.title)}</b>${h > 30 ? `<small>${fmtTime(e.start)}</small>` : ''}</button>`;
|
||
}).join('');
|
||
});
|
||
const hours = [...Array(24)].map((_, h) => `<div class="tgh" style="height:${HOUR_PX}px"><span>${h === 0 ? '' : new Date(2000, 0, 1, h).toLocaleTimeString([], { hour: 'numeric' })}</span></div>`).join('');
|
||
const now = new Date(), nowTop = (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_PX;
|
||
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="tp" aria-label="Previous ${nDays === 1 ? 'day' : 'week'}">${icon('back')}</button>
|
||
<h2 style="margin:0;flex:1;text-align:center;font-size:16px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(title)}</h2>
|
||
<button class="ibtn" id="tn" aria-label="Next ${nDays === 1 ? 'day' : 'week'}" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tt" style="min-height:36px">Today</button></div>
|
||
<div class="tgwrap"><div class="tg" style="min-width:${nDays === 1 ? 0 : 560}px">
|
||
<div class="tgtop"><div class="tghead"><span class="tgg"></span>${days.map(d => `<button class="tgd${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" aria-label="${esc(d.toDateString())}"><small>${d.toLocaleDateString([], { weekday: 'short' })}</small><b>${d.getDate()}</b></button>`).join('')}</div>
|
||
${allDay.some(a => a.length) ? `<div class="tgad"><span class="tgg">all day</span>${allDay.map(a => `<div>${a.map(e => { const i = evStore.push(e) - 1; return `<button class="tgchip" data-ei="${i}" style="--c:${esc(e.color)}">${esc(e.title)}</button>`; }).join('')}</div>`).join('')}</div>` : ''}</div>
|
||
<div class="tgbody" id="tgbody"><div class="tgg">${hours}</div>${days.map((d, i) => `<div class="tgcol${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" style="height:${24 * HOUR_PX}px">${cols[i]}${ymd(d) === today ? `<i class="tgnow" style="top:${nowTop}px"></i>` : ''}</div>`).join('')}</div>
|
||
</div></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
|
||
const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); }
|
||
const shift = n => { const d = new Date(sel); d.setDate(d.getDate() + n * (nDays === 1 ? 1 : 7)); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
|
||
$('#tp').onclick = () => shift(-1); $('#tn').onclick = () => shift(1);
|
||
$('#tt').onclick = () => { const d = new Date(); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
|
||
$('#cbody').querySelectorAll('.tgd').forEach(b => b.onclick = () => { if (nDays === 7) { calState.sel = b.dataset.day; store.set('cal_view', 'day'); viewCalendar(); } });
|
||
$('#cbody').querySelectorAll('.tgcol').forEach(c => c.onclick = e => { if (e.target === c) newEvent(c.dataset.day); });
|
||
}
|