calendar

A HTML/PHP project for a self-hosted (family)-calendar.
Log | Files | Refs | README

commit b4aad6e242ad174350f6f1be6871080031861601
parent 5d26999e2c78f88c0fec74e7d0acc7d62e060831
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Sun, 10 May 2026 13:27:54 +0000

Adding event preview in side-bar

Diffstat:
Mapp/main.py | 33++++++++++++++++++++++++++++++++-
Mstatic/app.js | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mstatic/i18n.js | 10++++++++++
Mstatic/index.html | 2++
Mstatic/style.css | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 215 insertions(+), 4 deletions(-)

diff --git a/app/main.py b/app/main.py @@ -4,7 +4,7 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from sqlalchemy import text -from datetime import datetime +from datetime import datetime, timedelta import os from .database import get_db, engine @@ -382,6 +382,37 @@ def list_events(year: int, month: int, request: Request, db: Session = Depends(g ] +@app.get("/api/events/upcoming") +def list_upcoming_events(request: Request, days: int = 90, db: Session = Depends(get_db)): + username, _, _dn = _get_identity(request) + memberships = db.query(CalendarMembership).filter(CalendarMembership.username == username).all() + cal_ids = [m.calendar_id for m in memberships] + + now = datetime.now() + today_start = datetime(now.year, now.month, now.day) + cutoff = today_start + timedelta(days=days) + + events = ( + db.query(Event) + .filter(Event.calendar_id.in_(cal_ids), Event.end_dt >= today_start, Event.start_dt < cutoff) + .order_by(Event.start_dt) + .all() + ) + return [ + { + "id": e.id, + "calendar_id": e.calendar_id, + "title": e.title, + "start_dt": e.start_dt.isoformat(), + "end_dt": e.end_dt.isoformat(), + "description": e.description, + "all_day": e.all_day, + "attendees": [a.username for a in e.attendees], + } + for e in events + ] + + @app.post("/api/events", status_code=201) def create_event(body: EventCreate, request: Request, db: Session = Depends(get_db)): username, _, _dn = _get_identity(request) diff --git a/static/app.js b/static/app.js @@ -6,6 +6,7 @@ const state = { email: '', calendars: [], events: {}, // keyed "YYYY-MM-DD" → array of event objects + upcomingEvents: [], year: new Date().getFullYear(), month: new Date().getMonth(), // 0-based selectedDay: null, @@ -45,7 +46,7 @@ async function init() { document.getElementById('username-display').textContent = me.display_name || me.username; await loadCalendars(); - await fetchEvents(); + await Promise.all([fetchEvents(), fetchUpcomingEvents()]); renderMonthly(); // Collapse sidebar on mobile by default @@ -65,6 +66,11 @@ async function fetchEvents() { indexEvents(evs); } +async function fetchUpcomingEvents() { + state.upcomingEvents = await api('GET', '/api/events/upcoming'); + renderUpcomingEvents(); +} + function indexEvents(arr) { state.events = {}; arr.forEach(ev => { @@ -89,6 +95,115 @@ document.getElementById('btn-sidebar-toggle').addEventListener('click', () => { document.querySelector('.sidebar').classList.toggle('collapsed'); }); +// ── Sidebar: Upcoming events ────────────────────────────────────────────────── +function renderUpcomingEvents() { + const section = document.getElementById('upcoming-section'); + section.innerHTML = ''; + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const in7Days = new Date(todayStart); + in7Days.setDate(in7Days.getDate() + 7); + + const visibleEvs = state.upcomingEvents.filter(ev => { + const cal = state.calendars.find(c => c.id === ev.calendar_id); + return cal && cal.is_visible; + }); + + const near = visibleEvs.filter(ev => new Date(ev.start_dt) < in7Days); + const later = visibleEvs.filter(ev => new Date(ev.start_dt) >= in7Days); + + section.appendChild(makeUpcomingSectionHeader(t('upcoming-7days'))); + if (near.length === 0) { + section.appendChild(makeUpcomingEmpty()); + } else { + near.forEach(ev => section.appendChild(makeUpcomingItem(ev))); + } + + const divider = document.createElement('div'); + divider.className = 'sidebar-divider'; + section.appendChild(divider); + + section.appendChild(makeUpcomingSectionHeader(t('upcoming-later'))); + if (later.length === 0) { + section.appendChild(makeUpcomingEmpty()); + } else { + later.forEach(ev => section.appendChild(makeUpcomingItem(ev))); + } +} + +function makeUpcomingSectionHeader(label) { + const el = document.createElement('div'); + el.className = 'upcoming-header'; + el.textContent = label; + return el; +} + +function makeUpcomingEmpty() { + const el = document.createElement('div'); + el.className = 'upcoming-empty'; + el.textContent = t('upcoming-empty'); + return el; +} + +function makeUpcomingItem(ev) { + const cal = state.calendars.find(c => c.id === ev.calendar_id); + const color = cal ? cal.color : '#88C0D0'; + + const item = document.createElement('div'); + item.className = 'upcoming-item'; + item.style.borderLeftColor = color; + + const title = document.createElement('div'); + title.className = 'upcoming-title'; + title.textContent = ev.title; + + const meta = document.createElement('div'); + meta.className = 'upcoming-meta'; + meta.textContent = formatUpcomingDate(new Date(ev.start_dt), ev.all_day); + + item.append(title, meta); + item.addEventListener('click', () => navigateToEvent(ev)); + return item; +} + +function formatUpcomingDate(date, allDay) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const eventDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + let dayLabel; + if (eventDay.getTime() === today.getTime()) { + dayLabel = t('upcoming-today'); + } else if (eventDay.getTime() === tomorrow.getTime()) { + dayLabel = t('upcoming-tomorrow'); + } else { + dayLabel = date.toLocaleDateString(getLang() === 'de' ? 'de-DE' : 'en-GB', { + weekday: 'short', day: 'numeric', month: 'short', + }); + } + + if (allDay) return dayLabel; + return `${dayLabel} · ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; +} + +async function navigateToEvent(ev) { + const start = new Date(ev.start_dt); + const newYear = start.getFullYear(); + const newMonth = start.getMonth(); + const newDay = start.getDate(); + state.selectedDay = newDay; + if (newYear !== state.year || newMonth !== state.month) { + state.year = newYear; + state.month = newMonth; + await fetchEvents(); + } + renderMonthly(); + openDailyView(newDay); +} + // ── Sidebar: Calendars ──────────────────────────────────────────────────────── function renderSidebar() { const list = document.getElementById('calendar-list'); @@ -123,6 +238,7 @@ async function toggleCalVisibility(cal) { await api('PATCH', `/api/calendars/${cal.id}/visibility`, { is_visible: !cal.is_visible }); cal.is_visible = !cal.is_visible; renderSidebar(); + renderUpcomingEvents(); renderMonthly(); if (state.selectedDay) renderDailyEvents(); } @@ -733,7 +849,7 @@ document.getElementById('event-modal-submit').addEventListener('click', async () await api('POST', '/api/events', payload); } closeEventModal(); - await fetchEvents(); + await Promise.all([fetchEvents(), fetchUpcomingEvents()]); renderMonthly(); if (state.selectedDay) renderDailyEvents(); } catch (err) { alert(err.message); } @@ -743,7 +859,7 @@ document.getElementById('ev-delete-btn').addEventListener('click', async () => { if (!editingEvent || !confirm(`Delete "${editingEvent.title}"?`)) return; await api('DELETE', `/api/events/${editingEvent.id}`); closeEventModal(); - await fetchEvents(); + await Promise.all([fetchEvents(), fetchUpcomingEvents()]); renderMonthly(); if (state.selectedDay) renderDailyEvents(); }); @@ -769,6 +885,7 @@ function svgMinus() { document.addEventListener('langchange', () => { renderMonthly(); renderSidebar(); + renderUpcomingEvents(); if (state.selectedDay) { const date = new Date(state.year, state.month, state.selectedDay); document.getElementById('daily-title').textContent = date.toLocaleDateString( diff --git a/static/i18n.js b/static/i18n.js @@ -7,6 +7,11 @@ // sidebar 'sidebar-calendars': 'Calendars', 'new-calendar': 'New calendar', + 'upcoming-7days': 'Next 7 days', + 'upcoming-later': 'Later', + 'upcoming-today': 'Today', + 'upcoming-tomorrow': 'Tomorrow', + 'upcoming-empty': 'Nothing scheduled', // monthly view 'toggle-sidebar': 'Toggle sidebar', 'prev-month': 'Previous month', @@ -69,6 +74,11 @@ 'sign-out': 'Abmelden', 'sidebar-calendars': 'Kalender', 'new-calendar': 'Neuer Kalender', + 'upcoming-7days': 'Nächste 7 Tage', + 'upcoming-later': 'Später', + 'upcoming-today': 'Heute', + 'upcoming-tomorrow': 'Morgen', + 'upcoming-empty': 'Nichts geplant', 'toggle-sidebar': 'Seitenleiste umschalten', 'prev-month': 'Vorheriger Monat', 'next-month': 'Nächster Monat', diff --git a/static/index.html b/static/index.html @@ -39,6 +39,8 @@ <!-- Sidebar --> <aside class="sidebar"> + <div id="upcoming-section" class="upcoming-section"></div> + <div class="sidebar-divider"></div> <div class="sidebar-header" data-i18n="sidebar-calendars">Calendars</div> <div class="calendar-list" id="calendar-list"></div> <div class="sidebar-actions"> diff --git a/static/style.css b/static/style.css @@ -789,6 +789,57 @@ textarea.form-input { flex-shrink: 0; } +/* ── Upcoming events ── */ +.upcoming-section { + flex-shrink: 0; + max-height: 260px; + overflow-y: auto; + padding-bottom: 0.15rem; +} +.upcoming-header { + padding: 1.1rem 1rem 0.5rem; + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--snow1); + opacity: 0.45; +} +.upcoming-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.35rem 0.6rem 0.35rem 0.65rem; + margin: 1px 0.5rem; + border-radius: 5px; + border-left: 3px solid transparent; + cursor: pointer; + transition: background 0.15s; +} +.upcoming-item:hover { background: var(--polar3); } +.upcoming-title { + font-size: 0.8rem; + font-weight: 500; + color: var(--snow2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} +.upcoming-meta { + font-size: 0.68rem; + color: var(--snow1); + opacity: 0.5; + line-height: 1.2; +} +.upcoming-empty { + padding: 0.2rem 1rem 0.6rem; + font-size: 0.75rem; + color: var(--snow1); + opacity: 0.3; + font-style: italic; +} + /* ── Daily header with add-event button ── */ .daily-header-right { display: flex;