Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.
What lands:
- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
(the editor). One TSX per page; client/views.ts + views-editor.ts
hydrate.
- Three render-shape components in client/views/: shape-list.ts (table
for density=comfortable, compact one-line stream for density=compact —
the activity-feed look without a separate "activity" shape per Q4 lock-
in 2026-05-07), shape-cards.ts (day-grouped chronological), and
shape-calendar.ts (month grid with day-pills, mobile cards-fallback
notice on viewports <600px per design §9 trade-off 8).
- Generic view shell that resolves a slug to a system view (via
/api/views/system) or a user view (via /api/user-views), runs it via
POST /api/views/{slug}/run, dispatches to the matching shape, exposes
a 3-button shape switcher that swaps the live render without re-fetching,
and surfaces the inaccessible-projects toast when the substrate flags
some IDs (Q17 fail-open attribution).
- View editor with widgets for name/slug/icon, sources (4 checkboxes),
scope mode (all_visible / my_subtree / personal_only), time horizon
(six fixed options), shape, and list density. Slug regex enforced
client-side mirroring the server validator. Save → POST/PATCH; delete
→ simple yes/no confirm (Q25 lock-in).
- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
GET /api/user-views on mount, injecting one nav item per saved view
+ an always-present "+ Neue Sicht" trailing entry. show_count=true
views get a sidebar badge updated by a fire-and-forget run query.
- Page handlers /views (most-recently-used redirect or onboarding shell),
/views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.
- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
shape labels, source/kind/horizon/scope vocabulary, editor form,
empty/error/onboarding states.
- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
Meine Sichten sidebar group.
- build.ts registers views.tsx + views-editor.tsx page renderers and
the two client bundles.
Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { t, type I18nKey, getLang } from "../i18n";
|
|
import type { RenderSpec, ViewRow } from "./types";
|
|
|
|
// shape-calendar: month grid. Toggleable to week-view via per-shape
|
|
// config. Mirrors the look of /events?view=calendar but generic across
|
|
// sources.
|
|
|
|
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
|
host.innerHTML = "";
|
|
const cfg = render.calendar ?? {};
|
|
const view = cfg.default_view ?? "month";
|
|
|
|
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
|
// screens). Documented in design §9 trade-off 8.
|
|
if (window.innerWidth < 600) {
|
|
const notice = document.createElement("p");
|
|
notice.className = "views-calendar-mobile-notice";
|
|
notice.textContent = t("views.calendar.mobile_fallback");
|
|
host.appendChild(notice);
|
|
}
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = `views-calendar views-calendar--${view}`;
|
|
|
|
const monthRef = pickMonthAnchor(rows);
|
|
wrap.appendChild(renderMonth(monthRef, rows));
|
|
host.appendChild(wrap);
|
|
}
|
|
|
|
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
|
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "views-calendar-month";
|
|
|
|
const header = document.createElement("h2");
|
|
header.className = "views-calendar-month-label";
|
|
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
|
wrap.appendChild(header);
|
|
|
|
// Weekday headers (Mon-Sun, ISO week).
|
|
const weekdayBar = document.createElement("div");
|
|
weekdayBar.className = "views-calendar-weekdays";
|
|
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
|
|
for (const k of weekdayKeys) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "views-calendar-weekday";
|
|
cell.textContent = t(k);
|
|
weekdayBar.appendChild(cell);
|
|
}
|
|
wrap.appendChild(weekdayBar);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "views-calendar-grid";
|
|
|
|
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
|
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
|
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
|
|
|
// Pad start with prev-month spillover.
|
|
for (let i = 0; i < startWeekday; i++) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "views-calendar-cell views-calendar-cell--out";
|
|
grid.appendChild(cell);
|
|
}
|
|
|
|
// Bucket rows by ISO date (yyyy-mm-dd).
|
|
const byDate = new Map<string, ViewRow[]>();
|
|
for (const row of rows) {
|
|
const d = new Date(row.event_date);
|
|
if (isNaN(d.getTime())) continue;
|
|
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
|
|
const key = isoDate(d);
|
|
const arr = byDate.get(key);
|
|
if (arr) arr.push(row);
|
|
else byDate.set(key, [row]);
|
|
}
|
|
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "views-calendar-cell";
|
|
const dayLabel = document.createElement("div");
|
|
dayLabel.className = "views-calendar-cell-day";
|
|
dayLabel.textContent = String(day);
|
|
cell.appendChild(dayLabel);
|
|
|
|
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
|
|
const dayRows = byDate.get(dateKey) ?? [];
|
|
if (dayRows.length > 0) {
|
|
const ul = document.createElement("ul");
|
|
ul.className = "views-calendar-pills";
|
|
const visible = dayRows.slice(0, 3);
|
|
for (const row of visible) {
|
|
const li = document.createElement("li");
|
|
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
|
li.textContent = row.title;
|
|
li.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
|
ul.appendChild(li);
|
|
}
|
|
if (dayRows.length > visible.length) {
|
|
const more = document.createElement("li");
|
|
more.className = "views-calendar-pill views-calendar-pill--more";
|
|
more.textContent = `+${dayRows.length - visible.length}`;
|
|
ul.appendChild(more);
|
|
}
|
|
cell.appendChild(ul);
|
|
}
|
|
grid.appendChild(cell);
|
|
}
|
|
|
|
wrap.appendChild(grid);
|
|
return wrap;
|
|
}
|
|
|
|
function pickMonthAnchor(rows: ViewRow[]): Date {
|
|
// Anchor on the first row's month, or "this month" if empty.
|
|
for (const row of rows) {
|
|
const d = new Date(row.event_date);
|
|
if (!isNaN(d.getTime())) return d;
|
|
}
|
|
const now = new Date();
|
|
return new Date(now.getFullYear(), now.getMonth(), 1);
|
|
}
|
|
|
|
function isoDate(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}`;
|
|
}
|