diff --git a/public/app.js b/public/app.js
index 775707b..252297a 100644
--- a/public/app.js
+++ b/public/app.js
@@ -235,8 +235,7 @@ function viewSearch() {
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
-async function loadAgenda(days) {
- const start = new Date(); start.setHours(0, 0, 0, 0);
+async function loadRange(start, days) {
const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
@@ -251,6 +250,7 @@ async function loadAgenda(days) {
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start };
}
+const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evRow = e => `
${esc(e.title)}
${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`;
@@ -268,22 +268,67 @@ async function renderAgenda(el, mode) { // Home card (today only)
} catch {}
}
+const now0 = new Date();
+const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
+
async function viewCalendar() {
- const range = store.get('cal_range', 7);
+ const mode = store.get('cal_view', 'month');
const v = $('#view');
- v.innerHTML = `Calendar
${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l}`).join('')}
-
`;
- v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
+ v.innerHTML = `Calendar
+
${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `
${l}`).join('')}
+
`;
+ v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
- if (!cals.length) { $('#ag').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
'; $('#ac').onclick = addCalendar; return; }
- const { byDay, errors } = await loadAgenda(range);
- if (!location.hash.startsWith('#/calendar')) return;
- const keys = [...byDay.keys()].sort();
- $('#ag').innerHTML = (keys.map(k => `${esc(dayLabel(k))}
${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
- + errors.map(e => `${esc(e)}
`).join('');
- } catch (e) { $('#ag').innerHTML = `${esc(e.message)}
`; }
+ if (!cals.length) { $('#cbody').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
'; $('#ac').onclick = addCalendar; return; }
+ if (mode === 'month') await renderMonth(); else await renderAgendaList();
+ } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `${esc(e.message)}
`; }
+}
+
+async function renderAgendaList() {
+ const range = store.get('cal_range', 7);
+ const { byDay, errors } = await loadAgenda(range);
+ if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
+ const keys = [...byDay.keys()].sort();
+ $('#cbody').innerHTML = `${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l}`).join('')}
`
+ + (keys.map(k => `${esc(dayLabel(k))}
${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
+ + errors.map(e => `${esc(e)}
`).join('');
+ $('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
+}
+
+async function renderMonth() {
+ const { y, m } = calState;
+ const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
+ const { byDay, errors } = await loadRange(gridStart, 42);
+ if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
+ const today = ymd(new Date());
+ if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
+ const cells = [];
+ for (let i = 0; i < 42; i++) {
+ const d = new Date(gridStart); d.setDate(d.getDate() + i);
+ const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
+ cells.push(``);
+ }
+ $('#cbody').innerHTML = `
+
${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}
+
+ ${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d}`).join('')}
+ ${cells.join('')}
+ ${errors.map(e => `${esc(e)}
`).join('')}`;
+ const showDay = () => {
+ const k = calState.sel, evs = byDay.get(k) || [];
+ $('#dayev').innerHTML = `${esc(dayLabel(k))}
${evs.length ? evs.map(evRow).join('') : '
No events
'}
`;
+ $('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
+ };
+ showDay();
+ $('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
+ const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
+ $('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
+ $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
}
function addCalendar() {
diff --git a/public/style.css b/public/style.css
index 2aaab09..ddcd871 100644
--- a/public/style.css
+++ b/public/style.css
@@ -201,3 +201,25 @@ body.inedit #view { padding: 0; display: flex; flex-direction: column; }
#audiowrap iframe { width: 100%; height: 100%; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
body.onaudio #view { flex: none; overflow: visible; padding-bottom: 8px; }
@media (min-width: 768px) { #audiowrap.on { padding-right: 32px; padding-left: 32px; padding-bottom: 24px; } }
+
+/* Month calendar */
+.mhdr { display: flex; align-items: center; gap: 6px; margin: 4px 0 10px; }
+.cgrid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
+.cgrid.wk { background: none; border: 0; border-radius: 0; margin-bottom: 4px; gap: 0; }
+.cgrid.wk span { text-align: center; color: var(--muted); font-size: 12px; padding: 4px 0; }
+.cday { background: var(--panel); border: 0; padding: 4px 2px 6px; min-height: 54px; display: flex; flex-direction: column; align-items: center; gap: 3px; text-align: center; min-width: 0; }
+.cday.dim { background: var(--bg); color: var(--muted); }
+.cday .dn { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; }
+.cday.today .dn { background: var(--accent); color: var(--accent-t); font-weight: 700; }
+.cday.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
+.cday .dots { display: flex; gap: 3px; min-height: 6px; }
+.cday .dots i { width: 6px; height: 6px; border-radius: 50%; }
+.cday .chips { display: none; }
+@media (min-width: 768px) {
+ .cday { min-height: 104px; align-items: stretch; padding: 6px; gap: 4px; }
+ .cday .dn { align-self: flex-start; }
+ .cday .dots { display: none; }
+ .cday .chips { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+ .cday .chips em { font-style: normal; font-size: 12px; line-height: 1.25; padding: 1px 6px; border-radius: 5px; border-left: 3px solid var(--c); background: var(--panel2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
+ .cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; }
+}