Merge: t-paliad-115 PR-1 — events view ⊥ filter + unified calendar view

This commit is contained in:
m
2026-05-04 14:34:52 +02:00
5 changed files with 403 additions and 51 deletions

View File

@@ -24,6 +24,7 @@ declare global {
}
type EventTypeChoice = "deadline" | "appointment" | "all";
type EventView = "cards" | "list" | "calendar";
interface EventListItem {
type: "deadline" | "appointment";
@@ -88,6 +89,7 @@ interface Me {
const PERSONAL = "__personal__";
let currentType: EventTypeChoice = "deadline";
let currentView: EventView = "cards";
let statusFilter = "pending";
let projectFilter = "";
let appointmentTypeFilter = "";
@@ -97,6 +99,8 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
let calYear = 0;
let calMonth = 0;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -306,7 +310,6 @@ function applyOverdueState(overdue: number) {
async function loadList() {
const unavailable = document.getElementById("events-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
try {
const params = new URLSearchParams();
params.set("type", currentType);
@@ -329,13 +332,13 @@ async function loadList() {
const resp = await fetch(`/api/events?${params.toString()}`);
if (resp.status === 503) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
hideTableAndCalendar();
document.getElementById("events-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
hideTableAndCalendar();
return;
}
let data: EventListItem[] = await resp.json();
@@ -350,16 +353,31 @@ async function loadList() {
render();
} catch {
unavailable.style.display = "block";
tableWrap.style.display = "none";
hideTableAndCalendar();
}
}
function hideTableAndCalendar() {
const tableWrap = document.getElementById("events-table-wrap");
const calWrap = document.getElementById("events-calendar-wrap");
if (tableWrap) tableWrap.style.display = "none";
if (calWrap) calWrap.hidden = true;
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendar();
} else {
renderTable();
}
}
function renderTable() {
const tbody = document.getElementById("events-body")!;
const empty = document.getElementById("events-empty")!;
const emptyFiltered = document.getElementById("events-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
const table = document.getElementById("events-table");
if (allItems.length === 0) {
@@ -455,6 +473,163 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
// plotting an event onto the calendar. Deadlines bucket on due_date;
// appointments on start_at's local-date component.
function itemDateISO(item: EventListItem): string {
if (item.type === "deadline") {
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
}
if (!item.start_at) return item.event_date.slice(0, 10);
const d = new Date(item.start_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function fmtMonthYear(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function calDotClass(item: EventListItem): string {
// Per-item dot colour. Deadlines reuse the existing urgency palette;
// appointments get their own colour so they're visually distinct from
// deadlines on a mixed (Beides) calendar.
if (item.type === "appointment") return "events-cal-dot-appointment";
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
}
function renderCalendar() {
const wrap = document.getElementById("events-calendar-wrap")!;
const grid = document.getElementById("events-cal-grid")!;
const empty = document.getElementById("events-cal-empty") as HTMLElement;
const monthLabel = document.getElementById("events-cal-month-label")!;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
// Calendar always renders the visible month from allItems, regardless of
// pristine vs filtered state — empty calendar is allowed (the per-month
// empty hint communicates "no items in this month" without confusing it
// with the table-mode "no items at all" empty state).
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
wrap.hidden = false;
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
const firstDay = new Date(calYear, calMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
const byDate = new Map<string, EventListItem[]>();
for (const item of allItems) {
const iso = itemDateISO(item);
const list = byDate.get(iso);
if (list) list.push(item);
else byDate.set(iso, [item]);
}
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(calYear, calMonth, day);
const items = byDate.get(iso) ?? [];
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
});
const monthStart = isoDate(calYear, calMonth, 1);
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
const hasInMonth = allItems.some((it) => {
const iso = itemDateISO(it);
return iso >= monthStart && iso <= monthEnd;
});
empty.hidden = hasInMonth;
}
function openCalPopup(iso: string, items: EventListItem[]) {
if (items.length === 0) return;
const popup = document.getElementById("events-cal-popup") as HTMLElement;
const dateEl = document.getElementById("events-cal-popup-date")!;
const list = document.getElementById("events-cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((it) => {
const cls = calDotClass(it);
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
const projectLabel = it.project_reference ?? "";
const projectCell = projectHref
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
: "";
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
${projectCell}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function applyView() {
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
const calWrap = document.getElementById("events-calendar-wrap") as HTMLElement;
const summary = document.getElementById("events-summary") as HTMLElement;
// Active state on selector buttons.
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((b) => {
b.classList.toggle("events-view-btn-active", b.dataset.eventView === currentView);
});
// Body class so CSS can hook in if needed downstream.
document.body.classList.toggle("events-view-cards", currentView === "cards");
document.body.classList.toggle("events-view-list", currentView === "list");
document.body.classList.toggle("events-view-calendar", currentView === "calendar");
// Cards view = the original layout (5-card summary + table).
// List view = no summary cards, table only — gives more vertical space
// and matches users' mental model of a flat list.
// Calendar view = month grid; cards + table both hidden.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar" && loadedOK) renderCalendar();
}
function wireRowHandlers(tbody: HTMLElement) {
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
const id = row.dataset.id!;
@@ -529,9 +704,7 @@ function applyTypeVisibility() {
if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle");
if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title");
// Calendar links: hide the irrelevant one in single-type modes.
toggleDisplay("events-action-deadline-cal", isDeadline || isAll, "inline-flex");
toggleDisplay("events-action-appointment-cal", isAppointment || isAll, "inline-flex");
// Single "Neu …" CTA per type. View axis is the calendar/list/cards selector.
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
@@ -594,6 +767,7 @@ function toggleFilterPair(controlID: string, show: boolean, labelID?: string) {
function syncURLParams() {
const url = new URL(window.location.href);
url.searchParams.delete("type");
url.searchParams.delete("view");
url.searchParams.delete("status");
url.searchParams.delete("project_id");
url.searchParams.delete("event_type");
@@ -601,6 +775,7 @@ function syncURLParams() {
// Default type per route comes from window.__PALIAD_EVENTS__; only
// surface in the URL when the user opted into something else.
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
if (currentView !== "cards") url.searchParams.set("view", currentView);
if (currentType !== "appointment" && statusFilter && statusFilter !== "pending") {
url.searchParams.set("status", statusFilter);
}
@@ -633,6 +808,10 @@ function populateProjectFilter() {
function initFilters() {
const params = urlParams();
currentType = (params.get("type") as EventTypeChoice) || defaultType();
const rawView = params.get("view");
if (rawView === "cards" || rawView === "list" || rawView === "calendar") {
currentView = rawView;
}
if (params.has("status")) statusFilter = params.get("status")!;
if (params.has("project_id")) projectFilter = params.get("project_id")!;
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
@@ -697,6 +876,49 @@ function initFilters() {
});
}
function initView() {
// Calendar always opens on the current month — month navigation is
// local to the view (cheap pagination, doesn't refetch).
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
if (next === currentView) return;
currentView = next;
applyView();
syncURLParams();
});
});
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
calMonth -= 1;
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
renderCalendar();
});
document.getElementById("events-cal-next")?.addEventListener("click", () => {
calMonth += 1;
if (calMonth > 11) { calMonth = 0; calYear += 1; }
renderCalendar();
});
document.getElementById("events-cal-today")?.addEventListener("click", () => {
const t = new Date();
calYear = t.getFullYear();
calMonth = t.getMonth();
renderCalendar();
});
const popup = document.getElementById("events-cal-popup") as HTMLElement;
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
popup.style.display = "none";
});
popup?.addEventListener("click", (e) => {
if (e.target === popup) popup.style.display = "none";
});
}
function initSummaryCards() {
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
card.addEventListener("click", async () => {
@@ -717,8 +939,10 @@ document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initView();
initSummaryCards();
applyTypeVisibility();
applyView();
onLangChange(() => {
applyTypeVisibility();
render();

View File

@@ -1127,6 +1127,10 @@ const translations: Record<Lang, Record<string, string>> = {
"events.empty.hint": "Sobald Fristen oder Termine angelegt werden, erscheinen sie hier.",
"events.empty.filtered": "Keine Eintr\u00e4ge mit diesen Filtern.",
"events.unavailable": "Termin- und Fristenverwaltung zurzeit nicht verf\u00fcgbar \u2014 bitte Administrator kontaktieren.",
"events.view.cards": "Karten",
"events.view.list": "Liste",
"events.view.calendar": "Kalender",
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
"caldav.heading": "CalDAV-Synchronisation",
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
@@ -2621,6 +2625,10 @@ const translations: Record<Lang, Record<string, string>> = {
"events.empty.hint": "Once deadlines or appointments are added, they appear here.",
"events.empty.filtered": "No entries match these filters.",
"events.unavailable": "Deadline / appointment management currently unavailable \u2014 please contact your administrator.",
"events.view.cards": "Cards",
"events.view.list": "List",
"events.view.calendar": "Calendar",
"events.calendar.empty": "No entries in the selected period.",
"caldav.title": "CalDAV sync \u2014 Paliad",
"caldav.heading": "CalDAV sync",
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",

View File

@@ -50,22 +50,6 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
</p>
</div>
<div className="fristen-header-actions" id="events-actions">
<a
href="/deadlines/calendar"
className="btn-secondary"
id="events-action-deadline-cal"
data-i18n="deadlines.list.calendar"
>
Kalenderansicht
</a>
<a
href="/appointments/calendar"
className="btn-secondary"
id="events-action-appointment-cal"
data-i18n="appointments.list.calendar"
>
Kalenderansicht
</a>
<a
href="/deadlines/new"
className="btn-primary btn-cta-lime"
@@ -86,34 +70,76 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
</div>
</div>
<div className="agenda-chip-row event-type-chip-row" role="tablist" id="events-type-chips">
<button
type="button"
className="agenda-chip"
data-event-type="deadline"
data-i18n="events.toggle.deadline"
role="tab"
<div className="events-axis-row">
<div
className="agenda-chip-row event-type-chip-row"
role="tablist"
id="events-type-chips"
aria-label="Typ"
>
Fristen
</button>
<button
type="button"
className="agenda-chip"
data-event-type="appointment"
data-i18n="events.toggle.appointment"
role="tab"
<button
type="button"
className="agenda-chip"
data-event-type="deadline"
data-i18n="events.toggle.deadline"
role="tab"
>
Fristen
</button>
<button
type="button"
className="agenda-chip"
data-event-type="appointment"
data-i18n="events.toggle.appointment"
role="tab"
>
Termine
</button>
<button
type="button"
className="agenda-chip"
data-event-type="all"
data-i18n="events.toggle.all"
role="tab"
>
Beides
</button>
</div>
<div
className="events-view-selector"
role="tablist"
id="events-view-selector"
aria-label="Ansicht"
>
Termine
</button>
<button
type="button"
className="agenda-chip"
data-event-type="all"
data-i18n="events.toggle.all"
role="tab"
>
Beides
</button>
<button
type="button"
className="events-view-btn"
data-event-view="cards"
data-i18n="events.view.cards"
role="tab"
>
Karten
</button>
<button
type="button"
className="events-view-btn"
data-event-view="list"
data-i18n="events.view.list"
role="tab"
>
Liste
</button>
<button
type="button"
className="events-view-btn"
data-event-view="calendar"
data-i18n="events.view.calendar"
role="tab"
>
Kalender
</button>
</div>
</div>
<div className="frist-summary-cards" id="events-summary">
@@ -185,7 +211,7 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
</p>
</div>
<div className="entity-table-wrap">
<div className="entity-table-wrap" id="events-table-wrap">
<table className="entity-table fristen-table events-table" id="events-table">
<thead>
<tr>
@@ -205,6 +231,38 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
</table>
</div>
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
<div className="frist-calendar-controls">
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="events-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
Keine Eintr&auml;ge im ausgew&auml;hlten Zeitraum.
</p>
</div>
<div className="modal-overlay" id="events-cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="events-cal-popup-date" />
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
</div>
</div>
<div className="entity-empty" id="events-empty" style="display:none">
<h2 data-i18n="events.empty.title">Keine Eintr&auml;ge vorhanden</h2>
<p data-i18n="events.empty.hint">

View File

@@ -829,6 +829,7 @@ export type I18nKey =
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"
| "events.calendar.empty"
| "events.col.appointment_type"
| "events.col.date"
| "events.col.location"
@@ -842,6 +843,9 @@ export type I18nKey =
| "events.toggle.appointment"
| "events.toggle.deadline"
| "events.unavailable"
| "events.view.calendar"
| "events.view.cards"
| "events.view.list"
| "footer.text"
| "gebuehren.col.courtfee"
| "gebuehren.col.fee"

View File

@@ -8702,9 +8702,67 @@ dialog.quick-add-sheet::backdrop {
cards — reuses the agenda-chip visuals so users get muscle-memory
parity with /agenda. */
.event-type-chip-row {
margin: 0;
}
/* View ⊥ filter (t-paliad-115). The type-chip row is the FILTER axis;
the view selector is the VIEW axis (cards / list / calendar). The two
live side-by-side in `events-axis-row` so users can scan them as
independent controls. On narrow viewports they stack and the view
selector wraps below the chips. */
.events-axis-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
justify-content: space-between;
margin: 0.5rem 0 1rem;
}
.events-view-selector {
display: inline-flex;
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 2px;
gap: 0;
}
.events-view-btn {
appearance: none;
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 500;
padding: 0.3rem 0.85rem;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.events-view-btn:hover {
color: var(--color-text);
}
.events-view-btn-active {
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
/* Calendar view (t-paliad-115). Reuses the existing .frist-calendar
styles — only the appointment dot colour is new. The frist-cal-dot
urgency variants already cover the deadline palette; we just need a
distinct hue for appointments so a mixed-type cell reads at a glance. */
.events-calendar-wrap {
margin: 0.25rem 0 1rem;
}
.frist-cal-dot.events-cal-dot-appointment {
background: var(--bucket-next-week, #1d4ed8);
}
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
.event-type-add-modal {
width: 28rem;