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).
237 lines
9.0 KiB
TypeScript
237 lines
9.0 KiB
TypeScript
// Shared agenda timeline rendering primitives. The standalone /agenda page
|
||
// (client/agenda.ts) and the inline Agenda section on /dashboard
|
||
// (client/dashboard.ts) both render the same item shape; this module is
|
||
// the single source of truth for how an AgendaItem turns into HTML.
|
||
//
|
||
// Stateless. The caller fetches /api/agenda, hands the items to
|
||
// renderAgendaTimeline(), and drops the resulting HTML into a container.
|
||
// i18n labels are resolved at render time via t/tDyn from ./i18n, so the
|
||
// onLangChange hook on the calling page re-renders correctly.
|
||
|
||
import { t, tDyn, getLang } from "./i18n";
|
||
|
||
// Two-eyes glyph 👀 inside .approval-pill--icon. Kept in sync with the
|
||
// matching constants in events.ts / inbox.ts / dashboard.ts.
|
||
const APPROVAL_PILL_GLYPH = "👀";
|
||
// Sparkle glyph ✨ for Paliadin-drafted pending rows (t-paliad-161).
|
||
// Renders alongside (not in place of) 👀 — orthogonal axes.
|
||
const AGENT_PILL_GLYPH = "✨";
|
||
|
||
export type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||
export type AgendaType = "deadline" | "appointment";
|
||
|
||
export interface AgendaItem {
|
||
id: string;
|
||
type: AgendaType;
|
||
title: string;
|
||
date: string; // ISO 8601
|
||
end_at?: string | null;
|
||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||
status?: string | null;
|
||
location?: string | null;
|
||
appointment_type?: string | null;
|
||
urgency: Urgency;
|
||
project_id?: string | null;
|
||
project_title?: string | null;
|
||
project_type?: string | null;
|
||
project_reference?: string | null;
|
||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||
requester_kind?: "user" | "agent" | null;
|
||
}
|
||
|
||
interface DayBucket {
|
||
dayKey: string;
|
||
day: Date;
|
||
items: AgendaItem[];
|
||
}
|
||
|
||
// Render a full timeline (day buckets with items) for an array of agenda
|
||
// items. Returns a single HTML string ready to assign via innerHTML.
|
||
// The empty case returns an empty string — callers that want an empty-
|
||
// state UI handle it themselves (different copy on /agenda vs the
|
||
// dashboard inline slot).
|
||
export function renderAgendaTimeline(items: AgendaItem[]): string {
|
||
if (!items.length) return "";
|
||
const buckets = groupByDay(items);
|
||
return buckets.map((b) => renderDay(b)).join("");
|
||
}
|
||
|
||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||
const map = new Map<string, DayBucket>();
|
||
for (const it of items) {
|
||
const d = new Date(it.date);
|
||
if (isNaN(d.getTime())) continue;
|
||
const key = toLocalDayKey(d);
|
||
let b = map.get(key);
|
||
if (!b) {
|
||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||
map.set(key, b);
|
||
}
|
||
b.items.push(it);
|
||
}
|
||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||
}
|
||
|
||
function renderDay(bucket: DayBucket): string {
|
||
const expected = expectedUrgency(bucket.day);
|
||
return `<section class="agenda-day">
|
||
<h2 class="agenda-day-heading">
|
||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||
</h2>
|
||
<ul class="agenda-items">
|
||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||
</ul>
|
||
</section>`;
|
||
}
|
||
|
||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||
// bucket's day.
|
||
function expectedUrgency(day: Date): Urgency {
|
||
const today = startOfToday();
|
||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||
if (diff < 0) return "overdue";
|
||
if (diff === 0) return "today";
|
||
if (diff === 1) return "tomorrow";
|
||
if (diff <= 6) return "this_week";
|
||
return "later";
|
||
}
|
||
|
||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||
const typeClass = `agenda-item-type-${it.type}`;
|
||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||
const detailHref = itemDetailHref(it);
|
||
const project = it.project_id
|
||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||
: "";
|
||
const pendingLabel = it.approval_status === "pending" ? tDyn("approvals.pending_update.label") : "";
|
||
const pendingPill = it.approval_status === "pending"
|
||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||
: "";
|
||
const agentLabel = tDyn("approvals.agent.label");
|
||
const agentPill = it.approval_status === "pending" && it.requester_kind === "agent"
|
||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||
: "";
|
||
|
||
const timePart = it.type === "appointment"
|
||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||
: "";
|
||
const urgencyTag = it.urgency !== bucketUrgency
|
||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||
: "";
|
||
const locationPart = it.type === "appointment" && it.location
|
||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||
: "";
|
||
const typeLabelKey = it.type === "deadline"
|
||
? "agenda.label.deadline"
|
||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||
const typeLabel = tDyn(typeLabelKey);
|
||
|
||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||
<span class="agenda-item-main">
|
||
<span class="agenda-item-headline">
|
||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||
${pendingPill}
|
||
${agentPill}
|
||
</span>
|
||
<span class="agenda-item-sub">
|
||
${project}
|
||
${timePart}
|
||
${locationPart}
|
||
</span>
|
||
</span>
|
||
<span class="agenda-item-meta">
|
||
${urgencyTag}
|
||
</span>
|
||
</a>
|
||
</li>`;
|
||
}
|
||
|
||
function itemDetailHref(it: AgendaItem): string {
|
||
return it.type === "deadline"
|
||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||
}
|
||
|
||
function formatProjectLabel(it: AgendaItem): string {
|
||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||
const title = it.project_title || "";
|
||
return `${ref}${title}`.trim();
|
||
}
|
||
|
||
function formatAppointmentTime(it: AgendaItem): string {
|
||
const start = new Date(it.date);
|
||
if (isNaN(start.getTime())) return "";
|
||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||
if (!it.end_at) return startStr;
|
||
const end = new Date(it.end_at);
|
||
if (isNaN(end.getTime())) return startStr;
|
||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||
return `${startStr}–${endStr}`;
|
||
}
|
||
|
||
function relativeDayLabel(day: Date): string {
|
||
const today = startOfToday();
|
||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||
if (diff < 0) {
|
||
const n = Math.abs(diff);
|
||
return getLang() === "de"
|
||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||
}
|
||
if (diff === 0) return t("agenda.day.today");
|
||
if (diff === 1) return t("agenda.day.tomorrow");
|
||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||
}
|
||
|
||
function fullDateLabel(day: Date): string {
|
||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||
return day.toLocaleDateString(locale, {
|
||
weekday: "long",
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
function startOfToday(): Date {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
function toLocalDayKey(d: Date): string {
|
||
return toISODate(d);
|
||
}
|
||
|
||
function esc(s: string): string {
|
||
const div = document.createElement("div");
|
||
div.textContent = s ?? "";
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function deadlineIcon(): string {
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||
}
|
||
|
||
function appointmentIcon(): string {
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||
}
|