Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
overdue+today badge still has a sensible target on mobile.
Dashboard:
- Agenda renders inline as a new collapsible section between the
upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
collapsible via a chevron toggle; state persists in
localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
refreshes on the existing 60s dashboard poll.
Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
type, shared by client/agenda.ts and client/dashboard.ts. Standalone
agenda.ts shrinks accordingly; behaviour is identical.
i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
callers; i18n-keys.ts auto-regenerated).
227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
import { initI18n, onLangChange } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
|
|
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
|
|
|
let eventTypeFilter: FilterHandle | null = null;
|
|
|
|
type TypeFilter = "both" | "deadlines" | "appointments";
|
|
|
|
interface AgendaPayload {
|
|
items: AgendaItem[];
|
|
from: string;
|
|
to: string;
|
|
types: string[];
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__PALIAD_AGENDA__?: AgendaPayload | null;
|
|
}
|
|
}
|
|
|
|
// Range presets match the TSX chips; 30d stays the default (server agrees).
|
|
const RANGE_DAYS_DEFAULT = 30;
|
|
const VALID_RANGES = new Set([7, 14, 30, 90]);
|
|
|
|
const state = {
|
|
items: [] as AgendaItem[],
|
|
type: "both" as TypeFilter,
|
|
rangeDays: RANGE_DAYS_DEFAULT,
|
|
};
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
readInitialStateFromURL();
|
|
|
|
const inlined = window.__PALIAD_AGENDA__;
|
|
if (inlined !== undefined) {
|
|
if (inlined === null) {
|
|
showUnavailable();
|
|
} else {
|
|
hydrate(inlined);
|
|
}
|
|
} else {
|
|
void refetch();
|
|
}
|
|
|
|
wireControls();
|
|
onLangChange(() => render());
|
|
});
|
|
|
|
// Pull initial state from ?types=...&range=... so reloads and bookmarks work.
|
|
// Any deviation triggers a refetch via wireControls once the UI is ready.
|
|
function readInitialStateFromURL(): void {
|
|
const q = new URLSearchParams(window.location.search);
|
|
const typesRaw = q.get("types");
|
|
if (typesRaw) {
|
|
const set = new Set(typesRaw.split(",").map((s) => s.trim()));
|
|
const hasD = set.has("deadlines");
|
|
const hasA = set.has("appointments");
|
|
if (hasD && !hasA) state.type = "deadlines";
|
|
else if (hasA && !hasD) state.type = "appointments";
|
|
else state.type = "both";
|
|
}
|
|
const rangeRaw = q.get("range");
|
|
if (rangeRaw) {
|
|
const n = parseInt(rangeRaw, 10);
|
|
if (!isNaN(n) && VALID_RANGES.has(n)) state.rangeDays = n;
|
|
}
|
|
}
|
|
|
|
function hydrate(payload: AgendaPayload): void {
|
|
state.items = payload.items;
|
|
// Infer type filter from server payload when the URL didn't pin it.
|
|
if (!window.location.search.includes("types=")) {
|
|
const set = new Set(payload.types);
|
|
if (set.has("deadlines") && !set.has("appointments")) state.type = "deadlines";
|
|
else if (set.has("appointments") && !set.has("deadlines")) state.type = "appointments";
|
|
else state.type = "both";
|
|
}
|
|
render();
|
|
}
|
|
|
|
function wireControls(): void {
|
|
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const next = (btn.dataset.type || "both") as TypeFilter;
|
|
if (state.type === next) return;
|
|
state.type = next;
|
|
pushURL();
|
|
void refetch();
|
|
});
|
|
});
|
|
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const next = parseInt(btn.dataset.range || "30", 10);
|
|
if (!VALID_RANGES.has(next) || state.rangeDays === next) return;
|
|
state.rangeDays = next;
|
|
pushURL();
|
|
void refetch();
|
|
});
|
|
});
|
|
syncChips();
|
|
|
|
const eventTrigger = document.getElementById("agenda-filter-event-type") as HTMLButtonElement | null;
|
|
const eventPanel = document.getElementById("agenda-filter-event-type-panel") as HTMLElement | null;
|
|
if (eventTrigger && eventPanel) {
|
|
const q = new URLSearchParams(window.location.search);
|
|
const initialEventIDs: string[] = [];
|
|
let initialIncludeUntyped = false;
|
|
const raw = q.get("event_type") ?? "";
|
|
if (raw) {
|
|
for (const tok of raw.split(",")) {
|
|
const t = tok.trim();
|
|
if (!t) continue;
|
|
if (t === "none") initialIncludeUntyped = true;
|
|
else initialEventIDs.push(t);
|
|
}
|
|
}
|
|
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
|
|
initialIDs: initialEventIDs,
|
|
initialIncludeUntyped,
|
|
onChange: () => {
|
|
pushURL();
|
|
void refetch();
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function pushURL(): void {
|
|
const q = new URLSearchParams(window.location.search);
|
|
q.set("range", String(state.rangeDays));
|
|
q.set("types", typesParam(state.type));
|
|
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
|
if (eventQuery) q.set("event_type", eventQuery);
|
|
else q.delete("event_type");
|
|
history.replaceState(null, "", `${window.location.pathname}?${q.toString()}`);
|
|
}
|
|
|
|
function typesParam(tf: TypeFilter): string {
|
|
if (tf === "deadlines") return "deadlines";
|
|
if (tf === "appointments") return "appointments";
|
|
return "deadlines,appointments";
|
|
}
|
|
|
|
async function refetch(): Promise<void> {
|
|
const loading = document.getElementById("agenda-loading")!;
|
|
const timeline = document.getElementById("agenda-timeline")!;
|
|
const empty = document.getElementById("agenda-empty")!;
|
|
loading.style.display = "block";
|
|
timeline.style.display = "none";
|
|
empty.style.display = "none";
|
|
syncChips();
|
|
|
|
const from = toISODate(startOfToday());
|
|
const to = toISODate(addDays(startOfToday(), state.rangeDays - 1));
|
|
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
|
const eventParam = eventQuery ? `&event_type=${encodeURIComponent(eventQuery)}` : "";
|
|
const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}${eventParam}`;
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (resp.status === 503) {
|
|
showUnavailable();
|
|
return;
|
|
}
|
|
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
|
state.items = (await resp.json()) as AgendaItem[];
|
|
render();
|
|
} catch {
|
|
showUnavailable();
|
|
} finally {
|
|
loading.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function showUnavailable(): void {
|
|
document.getElementById("agenda-unavailable")!.style.display = "block";
|
|
document.getElementById("agenda-timeline")!.style.display = "none";
|
|
document.getElementById("agenda-empty")!.style.display = "none";
|
|
}
|
|
|
|
function render(): void {
|
|
syncChips();
|
|
const timeline = document.getElementById("agenda-timeline")!;
|
|
const empty = document.getElementById("agenda-empty")!;
|
|
|
|
if (!state.items.length) {
|
|
timeline.innerHTML = "";
|
|
timeline.style.display = "none";
|
|
empty.style.display = "block";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
timeline.style.display = "";
|
|
timeline.innerHTML = renderAgendaTimeline(state.items);
|
|
}
|
|
|
|
function syncChips(): void {
|
|
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
|
|
btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type);
|
|
});
|
|
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
|
|
btn.classList.toggle("agenda-chip-active", btn.dataset.range === String(state.rangeDays));
|
|
});
|
|
}
|
|
|
|
function startOfToday(): Date {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function addDays(d: Date, days: number): Date {
|
|
const r = new Date(d);
|
|
r.setDate(r.getDate() + days);
|
|
return r;
|
|
}
|
|
|
|
function toISODate(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|