Files
hub/public/calendar-views.js
T
2026-10-22 07:50:38 -04:00

51 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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); });
}