Calendar week and day views

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:50:38 -04:00
1 parent e26b38a119
commit fa1452d23d
5 files changed
+78 -5

No files matched your search

+3 -3
View File
@@ -448,16 +448,16 @@ async function viewCalendar() {
const mode = store.get('cal_view', 'month'); const mode = store.get('cal_view', 'month');
const v = $('#view'); const v = $('#view');
v.innerHTML = `<div class="wrap"><h1>Calendar</h1> v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
<div class="seg">${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div> <div class="seg">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div> <div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`; <div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date())); $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars; $('#mc').onclick = manageCalendars;
try { try {
const cals = await jfetch('/api/cal/list'); const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; } if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
if (mode === 'month') await renderMonth(); else await renderAgendaList(); if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; } } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
} }
+50
View File
@@ -0,0 +1,50 @@
// 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); });
}
+1
View File
@@ -55,6 +55,7 @@
<script src="/webauthn.js"></script> <script src="/webauthn.js"></script>
<script src="/links.js"></script> <script src="/links.js"></script>
<script src="/palette.js"></script> <script src="/palette.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
<script src="/shell.js"></script> <script src="/shell.js"></script>
</body> </body>
+22
View File
@@ -504,3 +504,25 @@ body.lyropen { overflow: hidden; }
.row.subtask { padding-left: 34px; } .row.subtask { padding-left: 34px; }
.row.subtask .tick { transform: scale(.88); } .row.subtask .tick { transform: scale(.88); }
/* Calendar week and day views */
.tgwrap { overflow: auto; max-height: 68vh; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); }
.tg { position: relative; }
.tgg { width: 44px; flex: none; }
.tghead, .tgad, .tgbody { display: flex; }
.tgtop { position: sticky; top: 0; z-index: 3; background: var(--panel); border-bottom: 1px solid var(--line); }
.tgd { flex: 1; border: 0; background: none; padding: 6px 0; display: flex; flex-direction: column; align-items: center; gap: 0; color: var(--muted); min-width: 0; }
.tgd b { font-size: 17px; color: var(--text); width: 30px; height: 30px; line-height: 30px; border-radius: 50%; }
.tgd.today b { background: var(--accent); color: var(--accent-t); }
.tgad { border-bottom: 1px solid var(--line); }
.tgad .tgg { font-size: 10px; color: var(--muted); padding: 6px 4px 0; }
.tgad > div { flex: 1; min-width: 0; padding: 3px 2px; display: flex; flex-direction: column; gap: 2px; border-left: 1px solid var(--line); }
.tgchip { border: 0; text-align: left; font-size: 12px; padding: 2px 6px; border-radius: 6px; background: color-mix(in srgb, var(--c) 28%, transparent); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tgh { position: relative; }
.tgh span { position: absolute; top: -8px; right: 6px; font-size: 10px; color: var(--muted); }
.tgcol { flex: 1; position: relative; min-width: 0; border-left: 1px solid var(--line); cursor: cell; background-image: linear-gradient(var(--line) 1px, transparent 1px); background-size: 100% 48px; }
.tgcol.today { background-color: color-mix(in srgb, var(--accent) 5%, transparent); }
.tgev { position: absolute; border: 0; border-left: 3px solid var(--c); border-radius: 6px; padding: 2px 5px; text-align: left; overflow: hidden; font-size: 12px; line-height: 1.25; background: color-mix(in srgb, var(--c) 24%, var(--panel)); color: var(--text); cursor: pointer; }
.tgev b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tgev small { color: var(--muted); font-size: 11px; }
.tgnow { position: absolute; left: 0; right: 0; height: 2px; background: var(--danger); z-index: 2; pointer-events: none; }
+2 -2
View File
@@ -99,7 +99,7 @@ function serveStatic(req, res) {
if (err) { res.writeHead(404); return res.end('Not found'); } if (err) { res.writeHead(404); return res.end('Not found'); }
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in // The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
@@ -113,7 +113,7 @@ function serveStatic(req, res) {
} }
// Changes whenever the app's files change, so an open page can tell that a newer version is available. // Changes whenever the app's files change, so an open page can tell that a newer version is available.
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';