Compare commits

..

6 Commits

Author SHA1 Message Date
m
fdde9eb754 feat(t-paliad-144 A2): frontend Custom Views UI
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.
2026-05-07 13:15:55 +02:00
m
cda4b4083d Merge: t-paliad-144 A1 — backend substrate + Custom Views API (migration 056 paliad.user_views + ViewService 4-source union + FilterSpec/RenderSpec validators + SystemView registry + UserViewService + 9 HTTP handlers) 2026-05-07 12:53:52 +02:00
m
b516201110 feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only;
no user-visible change in A1. A2 (frontend) lands separately.

What's new:

- Migration 056: paliad.user_views table with RLS scoped to caller
  (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE
  (user_id, slug). No is_system flag — system defaults stay code-
  resident per Q8 lock-in.

- internal/services/filter_spec.go (+test): structured FilterSpec
  with Sources / Scope / Time / Predicates. Server-side validator
  rejects unknown sources, duplicate sources, conflicting scope
  modes, horizon=all without explicit projects (Q26 clamp), and
  every per-source enum (deadline.status, appointment_types,
  project_event kinds, approval_request status / viewer_role).

- internal/services/render_spec.go (+test): RenderSpec with three
  shapes (list / cards / calendar — Q4 lock-in 2026-05-07).
  Per-shape config kept separately so flipping shapes preserves
  tweaks. Validator over column / sort / density / group_by /
  default_view enums.

- internal/services/system_views.go (+test): code-resident
  SystemView definitions for dashboard / agenda / events / inbox /
  inbox-mine. Reserved-slug list (Q23) prevents user-views from
  colliding with top-level URLs. Case-folded matching.

- internal/services/view_service.go: extends EventService with
  RunSpec — runs a FilterSpec across all four substrate sources
  (deadline + appointment + project_event + approval_request)
  and merges into []ViewRow sorted by event_date. ViewRow is a
  discriminated projection (kind + common header + per-source
  Detail json.RawMessage). Q17 fail-open attribution: returns
  inaccessible_project_ids for explicit-scope queries where the
  caller can't see some IDs.

- internal/services/user_view_service.go (+test): CRUD on
  paliad.user_views — Create (server-assigns sort_order MAX+1
  in tx), GetBySlug, GetByID, Update (partial), Delete, Touch
  (last_used_at), MostRecent. Reserved-slug + slug-format
  validators on every write.

- internal/handlers/views.go: nine HTTP handlers wiring the
  endpoints (GET/POST/PATCH/DELETE /api/user-views/...,
  POST /api/user-views/{id}/touch, POST /api/views/run,
  POST /api/views/{slug}/run, GET /api/views/system).

- main.go + handlers.go + projects.go: wire UserViewService
  into the bundle; conditional route registration when both
  UserView + Event services are present.

Pure-Go tests (no DB): 32 cases pass — filter spec validators,
render spec validators, system view registry, reserved slugs.

Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases
covering create / list / get / uniqueness / update / delete /
touch / most-recent / reserved-slug / bad-slug / empty-name /
invalid-spec.

Coexists with t-139 (in-flight on noether's other branch) and
t-138 (shipped) without coordination commits — RunSpec uses the
existing visibility predicate that t-139's migration 055 will
extend with derivation. Approval-request source delegates to
ApprovalService.ListPendingForApprover / ListSubmittedByUser
(both already extended for derived_peer authority in t-139 Phase 3).

Files: 15 changed, 3134 insertions. Build clean. Tests green.
2026-05-07 12:51:37 +02:00
m
956ff10e4d design(t-paliad-144): m signed off + Q4 correction (3 shapes, not 4)
m's lock-in 2026-05-07: agree with all recommendations on Q1-Q18 and §10
Q19-Q27, with one correction on Q4: "activity" is a content selection
(sources + filters), not a render shape. Folded into `list` shape with
density: "compact" + actor/time columns. Shape ⊥ source — any source can
render in any shape.

Render shapes for v1: list / cards / calendar (3, was 4).

PR split decision (delegated to inventor): A1 backend substrate + API
(no UI change, ~1800 LoC, smoke via curl) → main → A2 frontend Custom
Views UI (~1600 LoC, additive on A1) → main.

Status flipped DRAFT → LOCKED. Inventor → coder transition initiated.
2026-05-07 12:36:05 +02:00
m
5c263102e3 design(t-paliad-144): data display model — additive Custom Views + render-shape switcher
Inventor pass on m's data-display rethink (m/paliad#5). Three coordinated
sub-designs in one doc, scoped to m's locked direction (additive, subsume
unified inbox, sidebar Meine Sichten group, in-page render-shape switcher,
paliad-only).

Recommended substrate: 4-source ViewService (deadline + appointment +
project_event + approval_request) returning discriminated ViewRow.
Recommended filter grammar: structured JSON spec with server-side validator.
Recommended render shapes for v1: list / cards / calendar / activity (defer
kanban, connections-graph, distinct-timeline). Recommended persistence:
new paliad.user_views table (migration 056), RLS-bounded to caller, code-
resident system defaults (no is_system flag).

Phasing: Phase A ships substrate + Custom Views standalone (~3400 LoC,
no system page changes); Phase B refactors /agenda/events/inbox/dashboard
internals onto the substrate later. Coexists transparently with t-139
(hierarchy aggregation in flight on noether's other branch) and t-138
(approvals shipped).

27 questions answered (18 from issue body §1–§5 + 9 inventor follow-ups
in §10 for m's call before coder shift).
2026-05-06 17:25:44 +02:00
m
f44ee0af0f Merge: t-paliad-143 — derived members per-unit role + multi-unit Herkunft (admin UI dropdown + array_agg in DerivationService) 2026-05-06 17:17:05 +02:00
31 changed files with 6132 additions and 1 deletions

View File

@@ -159,6 +159,7 @@ func main() {
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).

View File

@@ -0,0 +1,888 @@
# Design: Data display model — additive Custom Views layer + unified inbox subsume + render-shape switcher
**Task:** t-paliad-144
**Issue:** m/paliad#5
**Author:** noether (inventor)
**Date:** 2026-05-06
**Status:** LOCKED 2026-05-07 — m signed off on all recommendations + §10 follow-ups, with one correction (Q4 narrowed from 4 shapes → 3; "activity" is a filter/source choice, not a render shape — folded into `list` shape with density config). Inventor → coder transition initiated. PR split chosen: A1 backend substrate, A2 frontend Custom Views.
**Branch:** `mai/noether/inventor-data-display`
**Builds on:** t-paliad-109 (events unification, shipped) + t-paliad-138 (approvals, shipped) + t-paliad-139 (hierarchy aggregation, all 3 phases on `mai/noether/inventor-project` awaiting merge gate)
---
## 0. Premise check (read this first)
The issue body asks for a unified data-display model. Three premises in the brief that I verified against the live tree on this worktree before designing on top of them:
| Premise | Live state | Verdict |
|---|---|---|
| `EventService` is already a 2-source union over `paliad.deadlines` + `paliad.appointments` | `internal/services/event_service.go` lines 40193 — `ListVisibleForUser` runs the deadline path then the appointment path then merges in Go, sorted by `event_date` | **confirmed**; substrate exists in miniature today |
| `/agenda` is a separate timeline service, not the same code path | `internal/services/agenda_service.go` lines 78128 — `AgendaService.List` independently joins deadlines + appointments. Different SQL, different projection (`AgendaItem` vs `EventListItem`), different urgency annotation. | **confirmed**; we have *two* substrates already, both 2-source. Generalising means picking one and retiring the other (or keeping both temporarily). |
| `/inbox` is a 4-eye approval surface, not a generic activity feed | `frontend/src/inbox.tsx` (61 lines) + `internal/services/approval_service.go` lines 730810 — two-tab UI ("Zur Genehmigung", "Meine Anfragen") backed by `ListPendingForApprover` / `ListSubmittedByUser`. | **confirmed**; today's `/inbox` is approval-only, not the unified-inbox concept m's brainstorm describes |
| t-paliad-139 Phase 2 schema (migration 055) is incoming but not on main | Migration file exists at `internal/db/migrations/055_hierarchy_aggregation.up.sql`; per noether's prior memory, all 3 phases are stacked on `mai/noether/inventor-project` awaiting merge gate. | **confirmed**; this design must compose on top of 055's `paliad.project_partner_units` + `derive_grants_authority` model without forcing 139 to re-land |
| `paliad.project_events` carries audit kinds (`project_created`, `status_changed`, `project_archived`, `project_reparented`, …) | `internal/services/project_service.go` lines 491805 — five `insertProjectEvent` call-sites today; `event_type` column is free-text. | **confirmed**; `project_events` is the natural fourth data source for "what happened on my projects?" |
So the premises that anchor the design are sound. One correction to the issue body itself worth flagging:
> the issue body lists `paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.approval_requests` as the four current data tables.
That is right, but `event_service.go` only unions the **first two**. The Verlauf surface on `/projects/{id}` (project_events) and the inbox surface (approval_requests) are *each* their own bespoke endpoint today. The design below makes all four first-class `data_source` values in the substrate; flagging that the existing `EventService` will need to grow, not stay frozen.
---
## 1. m's intent (as I read it)
> "Custom views with saving them. […] If they could customize their view like 'myVerySpecialAgenda' with criteria and view options (filters, type of view — calendar vs cards vs list) and turn on parts — and then those views would be shown in the sidenavbar under a separate button. And on the page, the user can select all kinds of visuals."
Plus the locked direction of 2026-05-06 16:42:
- **Additive.** Fixed defaults stay; Custom Views ship alongside.
- **Subsume the unified inbox.** Approval candidates + project activity + new cases + status changes — all viewable through the same substrate, with configurable granularity.
- **Sidebar layout:** separate "Meine Sichten" group.
- **In-page render-shape switcher.**
- **paliad-only scope.**
Three design pieces fall out of this:
1. **A substrate** — one read API that returns rows from N data sources, filterable by one shared grammar.
2. **A render layer** — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape.
3. **A persistence + sidebar story**`paliad.user_views` + a "Meine Sichten" group + URL contract `/views/{slug}`.
§§35 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift.
---
## 2. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Substrate shape** | One `ViewService` (new) that union-loads from 4 data sources: `deadline`, `appointment`, `project_event` (audit), `approval_request`. Returns a discriminated `[]ViewRow` keyed by `kind`. | Single virtual SQL `view_row` table with UNION ALL across all 4 — too many polymorphic columns; harder to evolve per-source filters. |
| **Filter grammar** | Structured JSON spec validated server-side (`FilterSpec`). UI builds it via affordance widgets; the JSON is also human-editable for power users. | SQL DSL (security risk + complexity); UI-only (forces every dimension to have a widget). |
| **Render shapes for v1** | `list`, `cards`, `calendar` (3). Activity-feed appearance is achieved by source/filter choice (`sources: ["project_event", …]`) rendered through `list` shape with `density: "compact"` + actor/time columns — *not* a separate shape. Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship 4+ shapes including a dedicated "activity" — m's correction (2026-05-07): activity is content selection, not visualisation. Shape ⊥ source. |
| **Persistence** | New table `paliad.user_views` (id, user_id, slug, name, filter_spec jsonb, render_spec jsonb, sort_order, icon, last_used_at, …). RLS = caller's own rows only. | Per-user JSON column on `paliad.users` — kills the sidebar count badge query path (`SELECT count(*) WHERE user_id`); also no indexed sort. |
| **System defaults — code or DB?** | **Code.** Defaults stay as their own pages (`/dashboard`, `/agenda`, `/events`, `/inbox`); they are *built using the same render components* the custom-view system uses. No `is_system=true` row in `user_views`. | Seed system rows per user — drifts on schema bumps; new users miss bumps; `is_system=true` is a synonym for "config-as-data when config-as-code is cleaner". |
| **Sidebar** | New "Meine Sichten" group between "Arbeit" and "Werkzeuge". Each saved view appears as one nav entry (icon + name). One trailing "+ Neue Sicht" entry. | "Meine Sichten" as a single sidebar entry expanding to a panel — extra click cost on every navigation. |
| **In-page render-shape switcher** | A 4-button switcher on every view page (system + custom). Same component already exists on `/events` (cards/list/calendar). Generalise + add `activity`. | Per-route hardcoded shape — fights m's intent ("user can select all kinds of visuals"). |
| **URL contract** | `/views/{slug}` for custom views (slug is user-scoped). System views keep their existing URLs. Filter overrides via query params, transient (don't mutate stored spec). | UUID URLs (`/views/{uuid}`) — unsharable, unbookmarkable. |
| **`/inbox` page** | Stays as a fixed sidebar entry at the same URL. **Internally** refactored to use the new substrate as its read path, but the UI + URL stay. | Refactor /inbox away — needless break for users + email links. The locked direction is "subsume the inbox concept", which I read as substrate sharing, not URL retirement. |
| **Approval-candidate visibility** | Approval requests are their own `data_source`; an inbox-shaped view picks `sources: ["approval_request"]`. Pending pills on entity rows are a separate concern (already shipped via `entity.approval_status='pending'`). | Predicate-only — collapses two genuinely-different shapes (the request row vs the entity row). |
| **Migration / coexistence** | **Phase A:** ship substrate + render components + Custom Views + `paliad.user_views`. Existing pages untouched. **Phase B (later, separate task):** refactor system pages internally to use the substrate. | Refactor system pages in the same PR — bigger blast radius; harder to roll back. |
| **Performance v1** | Run on every load. Cursor pagination (`event_date` + `id` tiebreaker). No materialised views. Add per-source row caps later if telemetry says so. | Materialised view per saved view — refresh complexity, drift risk, doesn't help the first load. |
The rest of this doc is the detail behind those rows.
---
## 3. Section A — Substrate: data sources + filter grammar (Q1Q3, Q13)
### Q1 — What's the fundamental row?
**Recommendation: discriminated `ViewRow` projection over an explicit data-source registry.**
```go
// internal/services/view_service.go (new)
type DataSource string
const (
SourceDeadline DataSource = "deadline"
SourceAppointment DataSource = "appointment"
SourceProjectEvent DataSource = "project_event" // audit / Verlauf
SourceApprovalRequest DataSource = "approval_request" // 4-eye inbox
)
// ViewRow is the union shape served by the substrate. The shape is
// projection-stable: every source fills the common header fields; type-
// specific fields hang off `Detail` as a discriminated payload.
type ViewRow struct {
Kind DataSource `json:"kind"` // discriminator
ID uuid.UUID `json:"id"` // source-row id
Title string `json:"title"` // display title
Subtitle *string `json:"subtitle,omitempty"` // short context line
EventDate time.Time `json:"event_date"` // canonical sort key
// Project context — every row in paliad has a project (approval_requests
// and project_events are project-attached by definition; deadlines and
// appointments may be personal but inherit project context when set).
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
// Actor — who created this row (deadline/appointment) or who acted
// on it (project_event author, approval_request requester).
ActorID *uuid.UUID `json:"actor_id,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
// Detail carries the source-specific payload the render layer reads
// when it needs more than the header (e.g. cards render the deadline
// status pill, the calendar renders the appointment time range, the
// activity feed renders the audit description).
Detail json.RawMessage `json:"detail"` // shape determined by `kind`
}
```
`Detail` is a per-source typed Go struct (`DeadlineDetail`, `AppointmentDetail`, `ProjectEventDetail`, `ApprovalRequestDetail`) marshalled via `json.RawMessage` so the row stays a single struct on the wire. The frontend type-narrows on `kind`.
Why a registry over a single virtual SQL view:
- The four source tables have **truly disjoint columns** — deadline has `due_date` and `rule_code`, appointment has `start_at`/`end_at`/`location`, project_event has `event_type` (free text) + `metadata jsonb`, approval_request has `lifecycle_event` + `requested_at`. A `UNION ALL` materialised view ends up with ~40 nullable columns, half of them per row.
- Per-source filtering is fundamentally different — deadline filters look at `status`, appointment filters look at `appointment_type`, project_event filters look at `event_type`, approval_request filters look at `lifecycle_event` + `status`. Translating those into one CHECK-style filter grammar is harder than running per-source SQL paths and merging.
- The substrate already exists in miniature today — `event_service.go` line 114 union-loads two sources and merges in Go. Generalising to four sources is the same shape, more code, no new architectural concept.
### Q2 — Filter grammar shape
**Recommendation: structured JSON spec, validated server-side, exposed to the UI as predicates.**
```json
{
"version": 1,
"sources": ["deadline", "appointment", "project_event", "approval_request"],
"scope": {
"projects": "all_visible",
"personal_only": false
},
"time": {
"horizon": "next_30d",
"field": "auto"
},
"predicates": {
"deadline": {
"status": ["pending"],
"approval_status": ["approved", "pending", "legacy"],
"event_types": [],
"include_untyped": true
},
"appointment": {
"approval_status": ["approved", "pending", "legacy"],
"appointment_types": []
},
"project_event": {
"event_types": [
"project_created", "status_changed", "project_archived",
"deadline_created", "appointment_created", "approval_decided"
]
},
"approval_request": {
"viewer_role": "approver_eligible",
"status": ["pending"],
"entity_types": ["deadline", "appointment"]
}
}
}
```
The shape:
- **`sources`** — one or more `DataSource` values. Drives which per-source SQL paths run.
- **`scope.projects`** — `"all_visible"` (default — RLS-bounded) | `"my_subtree"` (semantic: caller's direct/derived staffing tree) | `[<uuid>...]` (explicit list, RLS still applies).
- **`scope.personal_only`** — narrows deadline + appointment to caller-created rows; ignored for project_event + approval_request (where actor scoping is already implicit).
- **`time.horizon`** — `"any"` | `"next_7d"` | `"next_30d"` | `"next_90d"` | `"past_30d"` | `"past_90d"` | `"all"` | `{from, to}` literal range. `"auto"` for the date field means each source picks: deadline → `due_date`, appointment → `start_at`, project_event → `created_at`, approval_request → `requested_at` (or `decided_at` if status is decided).
- **`predicates.<source>`** — per-source narrowing (status, types, eligibility). Empty / missing = no narrowing.
Validation lives in Go: a `ValidateFilterSpec(spec)` function rejects unknown fields, unknown enum values, conflicting combos (`personal_only=true` + explicit `projects` list → error). The UI never sends raw user-typed JSON; it composes the spec from widget state. A "Show JSON" reveal is available in the editor for power users — but the same validator runs on POST.
Three options considered:
| Option | Power | Risk | Verdict |
|---|---|---|---|
| **JSON predicate spec (recommended)** | High — every dimension addressable | Schema drift → validator bug | ✅ |
| SQL-fragment DSL (`WHERE status='pending' AND …`) | Highest | Injection, RLS-bypass risk; needs a parser | ✗ |
| UI-only, no spec language | Lowest | Every new dimension = UI work + DB migration | ✗ |
### Q3 — Granularity dimensions
m's brainstorm called out: my projects / specific projects / newly added cases / newly added events / changes to events / approved-vs-unapproved / time horizon / event type / role-perspective.
The full dimension set, mapped to the spec:
| Dimension | Where it lives in `FilterSpec` | UI affordance | Notes |
|---|---|---|---|
| My projects | `scope.projects = "my_subtree"` | toggle | semantic, resolved at query time via t-139 derivation predicate |
| Specific projects | `scope.projects = [...]` | multi-select | RLS still applies; rows from inaccessible projects are silently filtered (Q17) |
| Personal-only | `scope.personal_only = true` | toggle | mutually exclusive with `projects` (server enforces) |
| Newly added cases | `sources: ["project_event"]` + `predicates.project_event.event_types: ["project_created"]` + `time.horizon` | source toggle + event-type chip group | same shape captures status_changed, project_archived |
| Newly added events | `sources: ["deadline","appointment"]` + `time.horizon` + `time.field = "created_at"` | source toggles + time-field selector | the `created_at` rather than `due_date`/`start_at` view |
| Changes to events | `sources: ["project_event"]` + `predicates.project_event.event_types: ["deadline_*","appointment_*"]` | event-type chips | project_events already audit deadline + appointment lifecycle (verified via existing emit sites) |
| Approval status of entities | `predicates.deadline.approval_status` + `predicates.appointment.approval_status` | tri-state chip | reflects the entity-side `approval_status` column |
| Approval lifecycle (the requests themselves) | `sources: ["approval_request"]` + `predicates.approval_request.status` + `predicates.approval_request.viewer_role` | source toggle + role chip | Q13 — the inbox shape |
| Time horizon | `time.horizon` + optional `{from, to}` | range chips + date pickers | shared across all sources |
| Event type (deadline) | `predicates.deadline.event_types` | multi-select | reuses existing `paliad.event_types` registry |
| Appointment type | `predicates.appointment.appointment_types` | multi-select | hearing/meeting/consultation/deadline_hearing |
| Project event kind | `predicates.project_event.event_types` | multi-select | free-text today; we'll need a curated list (§10 Q19) |
| Role-perspective | implicit — every query is "from caller's viewpoint" | n/a | not a filter; visibility predicate is the user identity |
Hidden defaults vs UI affordances:
- **Hidden** — `version`, `time.field` (`"auto"` is the default), per-source `include_untyped`, validator branches.
- **First-class UI** — sources, scope, time horizon, status, event_type/appointment_type/project_event_kind, approval status.
- **Power-only** (revealed in JSON editor) — explicit `{from, to}` ranges beyond the chip set, `time.field` override.
### Q13 — Approval candidates: predicate or source?
**Recommendation: source (`approval_request`).**
Reasoning: the approval_requests table has fundamentally different columns (`lifecycle_event`, `pre_image`, `payload`, `requested_by`, `decision_kind`, `decided_at`) than deadline/appointment, and the inbox UI renders different things (requester avatar, "Approve / Reject" buttons, decision history). Forcing this into a predicate on deadline/appointment rows means either:
- (a) hiding the request rows entirely — but then "show me pending approvals" is impossible to express, or
- (b) hydrating every deadline row with its pending-request payload — bloats the row shape, kills the "approval_status pill" abstraction.
By making it a source:
- `sources: ["approval_request"]` is the *inbox shape* — list of pending requests, decided requests, etc.
- `predicates.deadline.approval_status: ["pending"]` is the *entity shape* — list of deadlines that have a pending request (good for "show me my deadlines that are blocked on someone else's approval").
These are genuinely two views; the substrate exposes both.
---
## 4. Section B — Render shapes + view authoring UX (Q4Q6, Q11Q12, Q16)
### Q4 — Which render shapes are first-class for v1?
**Recommendation: `list`, `cards`, `calendar` — three shapes.**
m's correction (2026-05-07): activity is a content selection (sources + filters), not a render shape. The "compact one-line stream with type icons" appearance is `list` shape with `density: "compact"` + an actor/time column set — same component, different config. Shape is orthogonal to source: any source can render in any shape.
| Shape | Status today | What it does | Source bias |
|---|---|---|---|
| **`list`** | shipped on `/events` (table), `/inbox` (`<ul class="inbox-list">`), `/dashboard` activity feed | One row per result; columns vary per source. Table for desktop, stacked card-rows on mobile. Density modes: `comfortable` (default, full table) / `compact` (one-line stream — the activity-feed look). | source-agnostic |
| **`cards`** | shipped on `/agenda` (day-grouped timeline) | Day-grouped chronological cards; primary date drives grouping. The unified-inbox-feel m described — *when fed activity-style content*. | source-agnostic |
| **`calendar`** | shipped on `/events?view=calendar` | Month grid (toggleable to week). Shows up to N pills per day. Click → popup with the day's rows. | works best for time-bound sources (deadline, appointment, project_event) |
How "activity feed" is expressed in this model:
- **Filter side**: `sources: ["project_event", "approval_request"]`, `time.horizon: past_30d`, `time.field: created_at`.
- **Render side**: `shape: "list"`, `list.density: "compact"`, `list.columns: ["time", "actor", "title", "project"]`.
That same `list` shape — with `density: "comfortable"` + the deadline column set — also powers `/events`. One component, two configs. Same logic for `cards`: the day-grouped Verlauf on `/projects/{id}` and a "newest cases this week" card view share the component.
Defer to v2: `kanban` (no obvious column axis across mixed sources), `connections-graph` (the events↔files visualisation referenced in the issue body — that's specifically about graph rendering, which is a 5x bigger component and works better as its own page than as a saved-view shape), `timeline-distinct-from-cards` (a horizontal Gantt would be the natural shape but adds a lot for marginal value at v1).
Why these three and not all six: each shape is a real frontend component with empty states, error states, layout, density toggles, mobile behaviour. We have three already shipped today, generalising them costs little. Adding `kanban` + `graph` is each its own component-week. Better to ship 3 polished than 6 half-baked.
### Q5 — Per-shape config
**Recommendation: shape config lives alongside filter spec in `render_spec`, keyed by shape.**
```json
{
"shape": "list",
"list": { "columns": ["date", "title", "project", "status"], "sort": "date_asc", "density": "comfortable" },
"cards": { "group_by": "day", "sort": "date_asc", "show_empty_days": false },
"calendar": { "default_view": "month", "show_weekends": true }
}
```
The user picks one `shape`; the matching config block is read at render time. Other shape configs are kept (so flipping back to a previously-used shape preserves its tweaks).
UI: the shape switcher is a **3-button row** at the top of every view page. Right of it, a small "Shape settings" gear opens a modal with the per-shape knobs. Most users never touch the gear.
Default values per shape:
- `list.columns` = source-determined (deadline view = date/title/rule/status; appointment view = date/title/location/type; activity-feel view = time/actor/title — auto-selected when sources are activity-flavoured)
- `list.density` = `"comfortable"` for entity sources, `"compact"` when sources include project_event or approval_request
- `list.sort` = `"date_asc"` for forward-looking views, `"date_desc"` for retrospective
- `cards.group_by` = `"day"`
- `calendar.default_view` = `"month"`
### Q6 — Empty state per view
**Recommendation: filter-aware empty states. Render component receives the resolved `FilterSpec` and produces a guidance line.**
Generic shape:
> **Keine Einträge gefunden.**
> Sicht: *{view name}* — {N} Filter aktiv (*Zeitraum: nächste 7 Tage, Status: offen*).
> Vorschläge: [Zeitraum erweitern] [Filter zurücksetzen]
The component derives the human-readable filter summary from the spec. For specific known patterns:
- All-empty across sources + horizon `next_7d` → "Nichts in den nächsten 7 Tagen — versuchen Sie 30 Tage."
- Sources picked but all 0 in 90d → "Keine Daten für diese Quellen — Sicht eventuell zu eng."
- Project filter set but project has no team → already handled at API layer (Q17).
Empty-state strings live in i18n; the view name + filter summary are interpolated at render time.
### Q11 — Where do you create a view?
**Recommendation: both, with the inline path as primary.**
Two creation paths:
1. **Inline "save current filters as a Sicht"** (primary) — on any view page (system or existing custom), once the user has tweaked the filter spec away from the saved baseline, a "Speichern als Sicht" button appears in the toolbar. Click → modal asks for name + icon + sidebar position + render shape (defaults to current). Save → POST `/api/user-views` → sidebar refreshes → user is now on the new view. The same modal on an existing custom view shows a "Save changes / Save as new" pair.
2. **Full editor at `/views/new`** (secondary) — for the power case where the user wants to build a Sicht from a blank slate. Same modal fields, plus a JSON view of the filter spec for power users. Edit existing at `/views/{slug}/edit`.
Why both:
- The inline path covers the 90% case ("I tweaked the inbox to show only my projects, save it") with one click.
- The full editor covers the 10% case where the user knows what they want but isn't currently looking at the right starting point ("I want a view of all approval-decided rows in the last 90 days").
Critically, **the inline path teaches the full editor** — both render the same form component.
### Q12 — Default-first onboarding
**Recommendation: empty + tutorial card on the first visit. No seeded examples.**
When a user with zero saved views clicks "Meine Sichten" or visits `/views`, they see:
> **Eigene Sichten — was ist das?**
> Eine Sicht ist eine gespeicherte Filterkombination — z.B. "Fristen meiner Projekte in den nächsten 14 Tagen". Sichten erscheinen als eigene Buttons in der Sidebar.
> [Beispiel-Sicht erstellen ▶] [Aus aktueller Seite speichern ▶]
The first button drops the user into the editor pre-populated with a sensible starter (e.g. "Activity feed for my subtree, last 30 days"). The second is contextual — only appears if the user has been on a system page recently (tracked client-side).
Why no seeded rows: seeded examples become orphan-confusion later ("did I make this Freitag-Stand thing? when?"). A dismissible tutorial card is cheaper to maintain and clearer about ownership.
### Q16 — URL contract
**Recommendation: `/views/{slug}` for custom views, slug user-scoped. System views keep their existing URLs.**
- **`/views/{slug}`** — slug is unique per `(user_id, slug)`. Slug is friendly: `freitag-stand`, `approvals-pending-mine`, `siemens-aktivitaet`. No UUIDs in URLs.
- **`/views/new`** — creation editor.
- **`/views/{slug}/edit`** — edit existing.
Filter overrides via query params:
- `/views/freitag-stand?from=2026-05-10&to=2026-05-17` — overrides the saved time horizon for this load only. Doesn't mutate the stored spec.
- `/views/freitag-stand?shape=calendar` — overrides the saved render shape for this load only.
Override params follow the same validator as the stored spec; unknown params are ignored.
System views — `/dashboard`, `/agenda`, `/events`, `/inbox` — keep their URLs. They never become `/views/dashboard` (a slug collision the validator must reject — slug `dashboard` is reserved).
---
## 5. Section C — Persistence + sidebar + system-vs-user-view shape (Q7Q10, Q14, Q15, Q17, Q18)
### Q7 — Schema for `paliad.user_views`
**Recommendation:**
```sql
CREATE TABLE paliad.user_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Stable user-facing identifier. Goes into the URL. Validated:
-- ^[a-z0-9][a-z0-9-]{0,62}$ with reserved-list rejection (dashboard,
-- agenda, events, inbox, new, edit, …).
slug text NOT NULL,
-- Display name. Free-form; no enforced i18n (the user picks the language
-- they think in). Sidebar renders it verbatim; no fallback or translation.
name text NOT NULL,
-- One of a fixed set of icon keys (see frontend/src/components/Sidebar.tsx
-- icon registry). NULL → default icon (folder).
icon text,
-- Filter spec (§3 Q2). Validated on write.
filter_spec jsonb NOT NULL,
-- Render spec (§4 Q5). Validated on write.
render_spec jsonb NOT NULL,
-- Sidebar ordering. Lower-first. Server defaults to MAX+1 on insert so
-- new views land at the bottom; the editor lets the user drag-reorder.
sort_order int NOT NULL DEFAULT 0,
-- Show a row-count badge on the sidebar entry (like /inbox today).
-- Costs one COUNT(*) per saved view per badge refresh; opt-in.
show_count boolean NOT NULL DEFAULT false,
-- "Most-recently-used" landing (Q10). PATCH on every view-load (cheap).
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, slug)
);
CREATE INDEX user_views_owner_idx
ON paliad.user_views (user_id, sort_order);
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_views_owner_all
ON paliad.user_views FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- updated_at autoset trigger reusing existing paliad.set_updated_at().
CREATE TRIGGER user_views_updated_at
BEFORE UPDATE ON paliad.user_views
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
```
Notes on the shape:
- **No `is_system` flag** — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned.
- **`filter_spec`/`render_spec` as `jsonb`** — Postgres validates only structural well-formedness; the application layer (`ValidateFilterSpec` + `ValidateRenderSpec`) enforces semantic constraints at write time. Storing the parsed shapes as columns would force a schema migration per new dimension.
- **No cross-user sharing column** — explicit `OUT OF SCOPE` per the issue body. If sharing lands later, add a separate `user_view_shares (view_id, target_user_id, can_edit)` table.
- **Slug uniqueness scoped to user** — two users can both have a view called `freitag-stand`; URL is `/views/freitag-stand` and resolves against `auth.uid()`.
Migration shape: new file `056_user_views.up.sql`. Standalone — no dependencies on 055's schema beyond `paliad.users` (which is in 002). 056 can land before 055 lands on main if needed.
### Q8 — System views: code or DB?
**Recommendation: code-resident.** Defaults stay as their own pages; their handlers continue to render their existing TSX shells; their data path is the substrate.
```go
// internal/services/system_views.go (new)
// SystemView is a code-resident view definition. Used by the substrate
// when a system page (dashboard, agenda, events, inbox) needs to resolve
// its data through the unified pipeline.
type SystemView struct {
Slug string // "dashboard" | "agenda" | "events" | "inbox" — matches URL
Filter FilterSpec // canonical spec the page resolves to today
Render RenderSpec // canonical render shape
Reserved bool // if true, slug is unavailable for user views (true for all 4)
}
func DashboardSystemView() SystemView { /* …multi-section, special-cased… */ }
func AgendaSystemView() SystemView { /* sources: deadline+appointment, shape: cards, horizon: 30d */ }
func EventsSystemView() SystemView { /* sources: deadline+appointment, shape: list, configurable */ }
func InboxSystemView() SystemView { /* sources: approval_request, viewer_role: approver_eligible, shape: list */ }
```
Tradeoff (config-as-code vs config-as-data):
| Axis | Code (recommended) | DB seed |
|---|---|---|
| Ships with releases | ✅ atomic with code | ✗ requires per-user backfill |
| New users get latest | ✅ always | ✗ depends on seed timing |
| User-editable | ✗ — system views deliberately frozen | ✅ — but then "system" is meaningless |
| Drift risk | none | high (schema bump → seeded rows go stale) |
| Validator complexity | one path | two paths (code path + seed path) |
The locked direction is "additive — fixed defaults stay alongside Custom Views". I read that as: defaults are *not* user-editable; the user can build a custom view that mimics a default if they want a tweaked version. Config-as-code matches that intent exactly.
Dashboard is the awkward one — it's not a single saved view, it's a multi-section page (5-bucket summary + matter card + 2-column lists + activity feed). The recommendation is: keep `/dashboard` as a bespoke page composed of *several* internal queries, each of which can resolve to a `SystemView` later. Don't try to express the dashboard as one SystemView; that's the wrong abstraction.
### Q9 — Sidebar layout
**Recommendation:** new "Meine Sichten" group between "Arbeit" and "Werkzeuge".
```
Übersicht:
Dashboard
Agenda
Inbox [3]
Team
Arbeit:
Projekte
Fristen
Termine
Meine Sichten: ← new group
Freitag-Stand [12]
Approval-Pending-Mine
Siemens-Aktivität
+ Neue Sicht ← always-last entry
Werkzeuge: …
Wissen: …
Ressourcen: …
Einstellungen: …
Admin: …
```
Layout decisions:
- **Position**: between Arbeit and Werkzeuge — close to the work flow, before the tools/knowledge sections. m's brainstorm placed it as "a separate button" but didn't pin top vs bottom; this position keeps it in the work-context band.
- **Group label**: "Meine Sichten" / "My Views" — i18n key `nav.group.user_views`.
- **Empty group**: if the user has zero saved views, the group still renders, with only the "+ Neue Sicht" entry inside. That makes the feature discoverable; the alternative (hide empty group) buries it.
- **Per-entry icon**: from a fixed registry of ~20 icons (folder, calendar, clock, bell, files, users, …) reused from the existing sidebar SVG set. Default = folder.
- **Per-entry badge**: shown when `show_count=true` on the saved view. Server returns the count via `/api/user-views?include_count=true`; the same client refresh interval as `/api/inbox/count` (~60s). Badge is the count of currently-matching rows — same shape as the inbox bell today.
- **Drag-reorder**: the editor lets users drag entries; click-to-edit on hover.
- **Mobile**: the bottom-nav shows fixed entries only (Übersicht items) — saved views are accessible via the burger drawer. Otherwise the bottom-nav fills up the moment a power user has 5 saved views.
### Q10 — Default landing
**Recommendation: most-recently-used.**
When the user clicks "Meine Sichten" (the group label, not a specific entry), they navigate to `/views`, which resolves to:
- If `last_used_at` is set on any view → 302 to that view's URL.
- If no view has `last_used_at` → render the onboarding card (Q12).
`last_used_at` is updated on every view-load via a fire-and-forget PATCH `/api/user-views/{id}/touch`. Cheap; no UI latency.
Alternative (always-default to first by sort_order) was considered — feels less helpful (the user sorted by what they want to see *most easily*, but might not be visiting *most often*). Most-recently-used reflects actual workflow.
### Q14 — `/inbox` page
**Recommendation: stays as a fixed sidebar entry. Internally refactored to use the substrate.**
Three paths considered:
| Path | Pros | Cons |
|---|---|---|
| Keep `/inbox` as today, no internal change | zero migration risk | duplicate read path; "subsume" goal not met |
| **Refactor `/inbox` to use the substrate (recommended)** | one read path; future enhancements lift everyone | small migration effort |
| Retire `/inbox`, ship as a Custom View | cleanest concept | breaks every email link; users with the URL bookmarked get 404 |
The recommendation refactors `/inbox` internally but keeps the URL + sidebar entry. Concretely:
- The two-tab UI ("Zur Genehmigung" / "Meine Anfragen") on `/inbox` becomes two `SystemView` definitions:
- `InboxApproverView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "approver_eligible", status: ["pending"]}`, `render.shape: "list"`.
- `InboxRequesterView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "self_requested"}`, `render.shape: "list"`.
- The `/inbox` handler resolves to one of these depending on the active tab; the data path goes through `ViewService.Run(ctx, userID, spec)`.
- The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's `list` shape with `kind="approval_request"` knows how to render approval rows including approve/reject buttons).
- The `nav.inbox` sidebar entry stays; the bell badge keeps reading from `ApprovalService.PendingCountForUser`.
This satisfies the "subsume the unified-inbox concept" goal: any user can build a Custom View that picks `approval_request` as one source plus `project_event` as another, and gets the unified-inbox feel m's brainstorm described — without losing the dedicated `/inbox` shortcut.
### Q15 — Existing fixed pages: reroute or stay independent?
**Recommendation: phased.** Phase A (this design's implementation) leaves system pages independent; Phase B (separate later task) refactors them to use the substrate.
| Phase | Scope | Risk | Locked direction fit |
|---|---|---|---|
| **A — substrate + Custom Views ship; defaults untouched** | new code: ViewService, FilterSpec, RenderSpec, view_service handlers, /views/* pages, paliad.user_views | low — additive | exactly matches m's "additive" framing |
| **B — refactor /agenda, /events, /dashboard, /inbox internals to use ViewService** | rip out parallel read paths; defaults become SystemView-resolved | medium — touches every default page | optional; ship when A is stable |
Why phase A is enough on its own to ship value: the user gets Custom Views, the unified-inbox-shape becomes available, every system page keeps working untouched. Phase B is a clean-up — eliminating duplicate read paths — and can wait until A's substrate is exercised.
If we tried to do A+B in one shot, the PR would be:
- 1× new substrate (~1500 LoC across services + handlers + frontend)
- 4× system page refactors (~800 LoC each = ~3200 LoC)
- = ~4700 LoC, 4 surfaces moving simultaneously
That's a 2-week change and a much higher rollback-cost. Phasing means A is shippable in ~1500 LoC and B can be tackled per-page later.
### Q17 — Auth + RLS + lost project access
**Recommendation: fail open with attribution.**
Behaviour:
- A saved view's `filter_spec.scope.projects` may include UUIDs the user no longer has team access to.
- The substrate query JOINs through `paliad.projects p` with the visibility predicate (`paliad.can_see_project(p.id)` per t-139). RLS naturally hides rows from inaccessible projects.
- The view loads. The user sees the rows they *can* see; the inaccessible ones are absent.
- A one-time toast surfaces: "1 Projekt in dieser Sicht ist nicht mehr sichtbar" (count derived server-side: requested-IDs minus visible-IDs).
- The toast offers a "Sicht bearbeiten" link → opens the editor with the inaccessible IDs prefilled in a "Entfernen?" section.
Alternatives considered:
| Alternative | Why rejected |
|---|---|
| Fail closed (whole view 403) | Too aggressive — a 50-project view shouldn't black out because 1 was archived. |
| Silently drop with no surface | Confuses the user; "why is my view empty today?" |
| Auto-prune on first load | Mutates stored data without consent. |
Failing open + attributing matches the "transparent honesty" principle from t-139 (derived membership annotated, not silent).
### Q18 — Materialisation & performance
**Recommendation: no materialisation v1. Cursor pagination + per-source row caps.**
Performance shape:
- **Substrate runs on every load.** Each source contributes one SQL path; merge happens in Go (small per-page result set). No precomputation.
- **Pagination** is cursor-based: `(event_date DESC, id DESC)` for retrospective views, `(event_date ASC, id ASC)` for forward-looking. Cursor = base64-encoded `{date, id}`. Default page size 100; cap 200.
- **Time horizon is mandatory.** Default is `next_30d` for forward-looking views, `past_30d` for retrospective. The validator rejects `time.horizon = "all"` *unless* `scope.projects` is set to a non-empty explicit list (capping the row pool).
- **Per-source LIMIT** inside each SQL path (default 500; configurable per-source). Caps the worst case where one source dominates the union.
What this looks like for the worst case the issue body raised — "all events from all my projects in the next 90 days, sorted by due_date":
- 50 projects × thousands of rows each = ~150k rows, theoretical. In practice, paliad data today has dozens-to-low-hundreds per project; even at 50 projects, the *date-bounded* result is in the hundreds-low-thousands range.
- Each per-source query has the visibility predicate (RLS is via `EXISTS` against `project_teams` + path-walk) — t-124 confirmed this scales with depth, not row count.
- Even at 5k merged rows, in-memory sort + 100-row paginated slice is a few ms.
We add materialisation only if telemetry says we need to. Concretely: a request-duration histogram on `/api/views/{slug}/run` with p99 alarm at 1s. If p99 climbs past 500ms, we add per-source materialised rollups (e.g. `mv_user_view_counts_daily`) and short-circuit summary cards through them.
The substrate's `count` endpoint (used by the sidebar badge for `show_count=true` views) is a lighter shape — it returns one integer per source. That can hit a lighter path (no JOINs to projects beyond the RLS predicate). If a user has 10 saved views with `show_count=true` × 60s refresh = 10 COUNT(*) queries per minute per logged-in user. That's the first scale wall and is the candidate for caching in Phase B.
---
## 6. Section D — Cross-cutting concerns
### 6.1 Coexistence with t-139 (hierarchy aggregation, in flight)
t-139 adds `paliad.project_partner_units` + `derive_grants_authority` + an extended `can_see_project()` predicate. The substrate uses `can_see_project()` (or equivalent positional helpers like `visibilityPredicate("p")` already does) — so derived membership transparently widens what shows up in saved views, just like it widens what shows up on `/agenda` today.
**No coordination commit required.** If t-139 lands first, this design's substrate inherits derivation for free. If this design lands first (unlikely given the merge order), the substrate works against the pre-139 visibility predicate; t-139's later landing widens results without code change here.
The `scope.projects = "my_subtree"` semantic resolves through `DerivationService.EffectiveProjectRole` (added by t-139 Phase 2). Until t-139 lands, "my_subtree" falls back to "direct + descendant" (via `projectDescendantPredicate` from t-124). The frontend chip label stays the same; only the resolved set widens.
### 6.2 Coexistence with t-138 (approvals, shipped)
t-138 added `paliad.approval_requests` + `entity.approval_status` + the inbox SQL. The substrate uses `approval_requests` as `data_source = "approval_request"` directly — same RLS, same JOIN against `paliad.users` for requester/decider names. The substrate's approval-side filter `predicates.approval_request.viewer_role = "approver_eligible"` resolves via `ApprovalService.ListPendingForApprover` (its existing SQL).
The entity-side pill (`approval_status='pending'`) on deadline/appointment rows in the substrate is unchanged — `EventListItem.ApprovalStatus` is already populated in `event_service.go`.
### 6.3 Existing `EventService` — extend or replace?
**Recommendation: extend.** Rename `EventService``ViewService` (or keep `EventService` as the type and add a `ListVisibleAsViewRows` method that returns `[]ViewRow` instead of `[]EventListItem`). The existing `ListVisibleForUser([]EventListItem, …)` callers (`/api/events`, `/api/events/summary`) keep working unchanged.
Two-source → four-source generalisation:
- Add `loadProjectEventRows(ctx, userID, spec)` → similar to `loadAppointments` shape, queries `paliad.project_events` JOIN `paliad.projects` with visibility predicate.
- Add `loadApprovalRequestRows(ctx, userID, spec)` → wraps `ApprovalService.ListPendingForApprover` / `ListSubmittedByUser` and projects to `ViewRow`.
- The merge step in `ListVisibleForUser` becomes "merge N source results sorted by event_date".
`AgendaService` is the second substrate today (timeline-shaped). Phase B can retire it (Agenda becomes a SystemView with `shape: "cards"`); Phase A leaves it untouched.
### 6.4 i18n
User-facing strings:
- "Meine Sichten" / "My Views" (sidebar group label)
- "Neue Sicht" / "New View" (creation entry)
- "Speichern als Sicht" / "Save as View"
- "Sicht bearbeiten" / "Edit View"
- shape labels: "Liste / List", "Karten / Cards", "Kalender / Calendar"
- per-source labels: "Fristen / Deadlines", "Termine / Appointments", "Projekt-Verlauf / Project history", "Genehmigungen / Approvals"
- empty-state composition strings (filter summary)
- error toast for inaccessible-project case
Total estimate: ~80 new keys, DE + EN.
### 6.5 Bottom nav (mobile)
The bottom nav today shows 4 fixed entries (Übersicht-band). It does NOT extend with saved views — that would balloon to N+4 at every saved view. Saved views remain accessible via the sidebar drawer.
If telemetry shows mobile users routinely hitting saved views, consider a "Pin to bottom-nav" toggle on individual views (max 1 pinned view added between Übersicht and the burger).
---
## 7. Section E — Implementation phasing (PR shape)
### PR split decision (2026-05-07)
m delegated the split call to the inventor. Phase A is split into **two stacked PRs**:
- **A1 — Backend substrate + Custom Views API.** Migration 056, FilterSpec/RenderSpec types + validators, ViewService 4-source extension, UserViewService CRUD, SystemView registry, all `/api/*` endpoints, full backend test coverage. *No user-visible change.* Smoke-testable via curl. ~1800 LoC.
- **A2 — Frontend Custom Views UI.** Generic view shell (`/views/{slug}`), view editor (`/views/new`, `/views/{slug}/edit`), 3 render-shape components (list/cards/calendar), sidebar "Meine Sichten" group, i18n, CSS. Builds on A1's API. ~1600 LoC.
Why split: A1 is mergeable + deployable in isolation (additive, no UI risk), exercises the validator surface, lets A2 build on a stable contract. A2 is purely additive once A1 lands. Each PR fits in a normal review window.
A1 → main → A2 → main is the merge order.
### Phase A — substrate + Custom Views (this task's locked scope)
| Step | Files | Approx. LoC | Notes |
|---|---|---|---|
| 1. Migration `056_user_views` | `internal/db/migrations/056_user_views.up.sql` (+ down) | 60 | table + indexes + RLS + trigger |
| 2. Filter/Render spec types + validator | `internal/services/filter_spec.go`, `render_spec.go` | 350 | Go structs + JSON marshalling + `Validate*` |
| 3. ViewService — extend EventService | `internal/services/view_service.go` (rename + extend) | 500 | add 2 source loaders; merge N sources |
| 4. UserViewService — CRUD | `internal/services/user_view_service.go` | 300 | List/Get/Create/Update/Delete/Touch |
| 5. SystemView registry | `internal/services/system_views.go` | 150 | 4 SystemView definitions + reserved-slug list |
| 6. HTTP handlers | `internal/handlers/views.go` (new) + adjust `events.go`, `agenda.go`, `inbox.go` minimally | 400 | `/api/user-views/*`, `/api/views/{slug}/run`, `/views/*` page handlers |
| 7. Frontend — generic view shell | `frontend/src/views.tsx` + `client/views.ts` | 500 | renders any FilterSpec + RenderSpec; powers `/views/*` |
| 8. Frontend — render shape components | `frontend/src/views/{list,cards,calendar,activity}.ts` | 600 | shared by system + custom |
| 9. Frontend — view editor | `frontend/src/views-editor.tsx` + client | 400 | inline-save modal + full editor |
| 10. Sidebar — Meine Sichten group | `frontend/src/components/Sidebar.tsx` + sidebar.ts | 150 | render saved views from /api/user-views; badge refresh |
| 11. i18n | `frontend/src/i18n.ts` | ~80 keys | DE + EN |
| 12. Tests | `*_test.go` for spec validators + ViewService | 400 | spec round-trip, RLS, source merge ordering |
| **Total** | | ~3400 | one PR |
Phase A ships standalone — no defaults are touched, no existing pages move.
### Phase B — refactor system pages onto substrate (separate task)
Per-page refactor: `/agenda` (substrate-shape `cards`), `/events` (substrate-shape `list`/`calendar`), `/inbox` (substrate-shape `list` + tab tied to viewer_role), `/dashboard` (composes multiple SystemViews into its sections). Each is its own PR. Total estimate: ~2000 LoC across all four. Ships any time after A is stable.
### Phase C — sharing + advanced shapes (future)
Cross-user sharing (`user_view_shares`), connections-graph render shape, kanban shape, real-time push updates. None of these are in scope for the current task; called out so the v1 spec doesn't paint us into a corner.
---
## 8. Section F — Worked examples
### 8.1 The unified-inbox m described
m's brainstorm: "approval candidates + project activity + new cases + status changes + everything that happened on my projects."
`FilterSpec`:
```json
{
"version": 1,
"sources": ["approval_request", "project_event", "deadline", "appointment"],
"scope": { "projects": "my_subtree" },
"time": { "horizon": "past_30d", "field": "auto" },
"predicates": {
"approval_request": { "viewer_role": "approver_eligible", "status": ["pending"] },
"project_event": { "event_types": ["project_created", "status_changed", "deadline_created", "appointment_created", "approval_decided", "project_archived"] },
"deadline": { "approval_status": ["approved","pending","legacy"], "status": ["pending"] },
"appointment": { }
}
}
```
`RenderSpec`:
```json
{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title", "project"], "sort": "date_desc" } }
```
(The "activity-feed feel" comes from `density: "compact"` + the actor/time column set, not from a separate shape — m's correction 2026-05-07.)
User saves as `meine-aktivitaet`. URL: `/views/meine-aktivitaet`. Sidebar entry under "Meine Sichten" with the bell icon. show_count=true → badge shows count of pending approvals + new audit events in past 30d.
### 8.2 The "myVerySpecialAgenda"
```json
{
"version": 1,
"sources": ["deadline", "appointment"],
"scope": { "projects": [<project-uuid-1>, <project-uuid-2>] },
"time": { "horizon": "next_14d" },
"predicates": {
"deadline": { "status": ["pending"], "event_types": [<litigation-event-type-uuid>] },
"appointment": { "appointment_types": ["hearing", "deadline_hearing"] }
}
}
```
`RenderSpec`: `{ "shape": "calendar", "calendar": { "default_view": "week" } }`
### 8.3 "Was hat sich auf Siemens AG geändert?"
```json
{
"version": 1,
"sources": ["project_event"],
"scope": { "projects": [<siemens-client-uuid>] },
"time": { "horizon": "past_90d" },
"predicates": { "project_event": { "event_types": ["status_changed", "project_reparented", "deadline_completed"] } }
}
```
`RenderSpec`: `{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title"], "sort": "date_desc" } }`
(`scope.projects` referencing a top-level Client UUID + the path-walk visibility predicate naturally pulls all descendants — this is exactly the t-139 aggregation, surfaced through the substrate.)
---
## 9. Section G — Trade-offs flagged
1. **Substrate complexity vs default-page simplicity.** The substrate is meaningfully more complex than today's `EventService`. The win is that *every future "show me X across my work"* request maps to the same code path. Without it, every new viewpoint is a new bespoke handler — t-138's inbox is the most recent precedent (~900 LoC).
2. **JSON spec discoverability.** Power users will appreciate the JSON-spec affordance; casual users may never see it. The risk is that the affordance attracts feature-creep ("can we just add a `like_pattern` predicate?"). Mitigation: `version: 1` field + strict validator + a "spec changes go through inventor" rule documented in `docs/`.
3. **Storage cost of `paliad.user_views`.** Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible.
4. **Sidebar growth.** Heavy users may end up with 10+ saved views in the sidebar group. The drag-reorder editor is the relief valve; if pain emerges, add a "Collapse group" affordance.
5. **`show_count` query load.** Each show_count=true view = 1 COUNT(*) per refresh. If users go count-happy, this becomes a real load. Mitigation: cap show_count=true to 5 per user; cache counts for 30s server-side.
6. **System pages staying independent (Phase A).** Two read paths during the A→B window. Drift risk if the substrate gains behaviour the system pages miss. Mitigation: feature flag the new `/views/*` for power users until B is in flight.
7. **Slug collisions with future system URLs.** Reserve a static list (`dashboard`, `agenda`, `events`, `inbox`, `new`, `edit`, `tools`, `admin`, `settings`, `login`, `logout`, `projects`, `team`, `courts`, `glossary`, `links`, `downloads`, `checklists`, `views`). Validator rejects on write. Future URLs added → migration script renames any user views that crash.
8. **Mobile UX of in-page render-shape switcher.** Calendar shape on a phone is cramped. Mitigation: when viewport width < 600px, calendar shape auto-falls back to cards (with a notice). Same pattern as `/events` today.
---
## 10. Section H — Open questions for m
**Status: LOCKED 2026-05-07.** m signed off on all Q19Q27 recommendations.
Inventor has made recommendations on every Q1Q18 from the issue body. The questions below are points where m's call would specifically refine the design before coder shift starts. Numbered fresh (Q19+) so they don't collide with the issue body's numbering.
**Q19. Curated `project_event` event-type list.**
The audit table today has free-text `event_type` strings (`project_created`, `status_changed`, `deadline_created`, `approval_decided`, …). The substrate's filter dropdown needs a curated list. Should I:
- (a) ship a hardcoded list of ~12 known kinds (verified via grep on `insertProjectEvent` callsites), or
- (b) ship a `paliad.project_event_kinds` registry table seeded with the same list, future-extensible by admins?
Recommendation: (a). Free-text `event_type` is a code-resident constant; new kinds appear when code emits them, so a registry table would just shadow the code.
**Q20. Sidebar group position.**
I placed "Meine Sichten" between Arbeit and Werkzeuge. Three other reasonable positions:
- top of the sidebar (above Übersicht most-used-first)
- inside Übersicht (mixed with Dashboard/Agenda but blurs the system/user distinction)
- between Übersicht and Arbeit (saved views are *overviews* by intent)
Pick one the implementation is identical in all four placements.
**Q21. Bottom-nav inclusion.**
Mobile bottom-nav today has 4 fixed entries. The recommendation is to **not** extend it with saved views (sidebar drawer fills the gap). Confirm or reject. If reject: should pinned views be a per-view setting (max 1 pinned), or auto-pin the most-recently-used?
**Q22. Show-count default.**
Per-view `show_count` defaults to false (recommendation §5 Q7). Confirm alternative is default true with an explicit opt-out. The cost of true-default is more COUNT(*) queries.
**Q23. Reserved slugs.**
List of forbidden user-view slugs 9 trade-off 7). Anything to add or remove?
**Q24. Phase A surface area in coder shift.**
Phase A is ~3400 LoC. Confirm one PR is the right shape, or split into A1 (substrate + spec types + system view refactor of /events only) + A2 (Custom Views CRUD + sidebar + editor)?
**Q25. View deletion confirmation.**
A user deleting a saved view: should I require a "type the view name to confirm" pattern (matching admin deletes elsewhere in paliad), or a single Yes/No modal?
**Q26. Time-horizon mandatory clamp.**
The validator rejects `time.horizon = "all"` unless `scope.projects` is non-empty (perf safeguard, §5 Q18). Does this feel right, or should `"all"` always be allowed (and we trust the per-source LIMIT to bound things)?
**Q27. Render-spec live preview in editor.**
The editor today (proposed) saves on submit. Should the editor render a *live preview* of the current spec (running the substrate against the in-progress filter) useful but adds a query per keystroke? Default-debounced (500ms) or explicit "Vorschau" button?
---
## 11. Out of scope (v1)
Per the issue body quoted for traceability:
- Replacing the fixed pages (they stay; can be removed later if usage warrants).
- Cross-user view sharing.
- Public / read-only links to views.
- Real-time push updates ("inbox row appears when someone files an approval").
- Cross-project rollups (rolling rows across unrelated projects).
- Themes / per-view colour palettes.
Adding from inventor analysis:
- Connections-graph render shape (deferred per §4 Q4 its own page later).
- Kanban shape (no obvious column axis across mixed sources).
- "Pin to bottom-nav" mobile affordance.
- Materialised view/cache layer (deferred per §5 Q18 telemetry-driven).
---
## 12. Files the implementer will touch (Phase A)
Backend:
- `internal/db/migrations/056_user_views.up.sql` + `.down.sql` (new)
- `internal/services/filter_spec.go` (new) types + validator
- `internal/services/render_spec.go` (new) types + validator
- `internal/services/view_service.go` (new extends/renames `event_service.go`)
- `internal/services/user_view_service.go` (new) CRUD
- `internal/services/system_views.go` (new) 4 SystemView definitions
- `internal/services/event_service.go` update callers (or alias for back-compat)
- `internal/handlers/views.go` (new) `/api/user-views/*`, `/api/views/{slug}/run`, page handlers for `/views/*`
- `internal/handlers/handlers.go` wire the new routes
- `internal/handlers/inbox.go` (light touch) refactor read path to `ViewService` (Phase B candidate; can stay independent in Phase A if we want to minimize blast radius)
Frontend:
- `frontend/src/views.tsx` (new) generic view shell (`/views/{slug}` and `/views`)
- `frontend/src/views-editor.tsx` (new) full editor at `/views/new`, `/views/{slug}/edit`
- `frontend/src/client/views/list.ts`, `cards.ts`, `calendar.ts`, `activity.ts` (new) render shape components
- `frontend/src/client/views.ts` (new) view shell glue + shape switcher
- `frontend/src/client/views-editor.ts` (new) editor logic
- `frontend/src/components/Sidebar.tsx` add Meine Sichten group + render from `window.__PALIAD_USER_VIEWS__`
- `frontend/src/client/sidebar.ts` fetch/cache user views; badge refresh
- `frontend/src/i18n.ts` ~80 new keys DE+EN
- `frontend/src/styles/global.css` view-shell + render-shape switcher styles
Tests:
- `internal/services/filter_spec_test.go` validator (happy + edge cases + reject paths)
- `internal/services/render_spec_test.go` same
- `internal/services/view_service_test.go` 4-source merge ordering, RLS bounded
- `internal/services/user_view_service_test.go` CRUD + RLS
- `frontend/src/client/views/*.test.ts` (if frontend testing infra exists; otherwise smoke via Playwright)
Build infra: none uses existing `golang-migrate` + Bun pipelines.
---
## 13. Inventor stays parked
This design needs m's go on §10 (Q19Q27) before coder shift starts. After m's call, the head routes the implementer (recommendation: noether or fresh coder; Phase A is mechanical-substantial but pattern-fluent t-139's hierarchy substrate is the closest precedent in the codebase).
NOT cronus per m's directive (2026-05-06: cronus retired from paliad).
`mai report completed "DESIGN READY FOR REVIEW: data display model — additive Custom Views + 4-source substrate + 4 render shapes + paliad.user_views. 27 questions answered (18 from issue body + 9 follow-ups in §10). Awaiting m's go/no-go before coder shift."`

View File

@@ -30,6 +30,8 @@ import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderInbox } from "./src/inbox";
import { renderViews } from "./src/views";
import { renderViewsEditor } from "./src/views-editor";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
@@ -250,6 +252,8 @@ async function build() {
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/inbox.ts"),
join(import.meta.dir, "src/client/views.ts"),
join(import.meta.dir, "src/client/views-editor.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
@@ -363,6 +367,8 @@ async function build() {
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "inbox.html"), renderInbox());
await Bun.write(join(DIST, "views.html"), renderViews());
await Bun.write(join(DIST, "views-editor.html"), renderViewsEditor());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());

View File

@@ -1739,6 +1739,100 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.policies.no_approval": "Keine Genehmigung erforderlich",
"approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen",
"approvals.policies.set_all_associate": "Alle auf Associate setzen",
// t-paliad-144 — Custom Views
"nav.group.user_views": "Meine Sichten",
"nav.user_views.new": "Neue Sicht",
"views.title": "Sichten — Paliad",
"views.heading": "Sichten",
"views.subtitle": "Eigene Sichten über Ihre Daten — Filter und Darstellung speicherbar.",
"views.loading": "Lädt …",
"views.shape.list": "Liste",
"views.shape.cards": "Karten",
"views.shape.calendar": "Kalender",
"views.save_as": "Als Sicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
"views.error.back": "Zurück zur Sichten-Übersicht",
"views.error.not_found": "Sicht nicht gefunden.",
"views.error.network": "Netzwerkfehler — bitte erneut versuchen.",
"views.toast.inaccessible_one": "1 Projekt in dieser Sicht ist nicht mehr sichtbar.",
"views.toast.inaccessible_n": "{n} Projekte in dieser Sicht sind nicht mehr sichtbar.",
"views.calendar.mobile_fallback": "Kalender-Ansicht ist auf grossen Bildschirmen am besten.",
"views.onboarding.title": "Eigene Sichten — was ist das?",
"views.onboarding.body": "Eine Sicht ist eine gespeicherte Filterkombination — z.B. „Fristen meiner Projekte in den nächsten 14 Tagen“. Sichten erscheinen als eigene Buttons in der Sidebar.",
"views.onboarding.create": "Beispiel-Sicht erstellen",
"views.source.deadline": "Fristen",
"views.source.appointment": "Termine",
"views.source.project_event": "Projekt-Verlauf",
"views.source.approval_request": "Genehmigungen",
"views.kind.deadline": "Frist",
"views.kind.appointment": "Termin",
"views.kind.project_event": "Verlauf",
"views.kind.approval_request": "Genehmigung",
"views.scope.all_visible": "Alle sichtbaren",
"views.scope.my_subtree": "Mein Teilbaum",
"views.scope.explicit": "Bestimmte Projekte",
"views.scope.personal_only": "Nur persönliche",
"views.horizon.next_7d": "Nächste 7 Tage",
"views.horizon.next_30d": "Nächste 30 Tage",
"views.horizon.next_90d": "Nächste 90 Tage",
"views.horizon.past_30d": "Letzte 30 Tage",
"views.horizon.past_90d": "Letzte 90 Tage",
"views.horizon.any": "Beliebig",
"views.horizon.all": "Komplett (alle Daten)",
"views.horizon.custom": "Benutzerdefiniert",
"views.density.comfortable": "Bequem",
"views.density.compact": "Kompakt",
"views.col.date": "Datum",
"views.col.time": "Wann",
"views.col.title": "Titel",
"views.col.project": "Projekt",
"views.col.actor": "Akteur",
"views.col.status": "Status",
"views.col.rule": "Regel",
"views.col.event_type": "Typ",
"views.col.location": "Ort",
"views.col.appointment_type": "Termin-Typ",
"views.col.approval_status": "Genehmigung",
"views.col.decided_by": "Entschieden von",
"views.col.kind": "Art",
"views.editor.title": "Sicht bearbeiten — Paliad",
"views.editor.heading.new": "Neue Sicht",
"views.editor.heading.edit": "Sicht bearbeiten",
"views.editor.subtitle": "Wählen Sie Quellen, Filter und Darstellung. Änderungen speichern Sie unten.",
"views.editor.section.identity": "Bezeichnung",
"views.editor.section.sources": "Quellen",
"views.editor.section.scope": "Geltungsbereich",
"views.editor.section.time": "Zeitraum",
"views.editor.section.render": "Darstellung",
"views.editor.field.name": "Name",
"views.editor.field.slug": "Slug (URL)",
"views.editor.field.icon": "Icon",
"views.editor.field.show_count": "Treffer-Anzahl in der Sidebar anzeigen",
"views.editor.field.scope_mode": "Projekte",
"views.editor.field.personal_only": "Nur persönliche",
"views.editor.field.horizon": "Horizont",
"views.editor.field.shape": "Form",
"views.editor.field.density": "Dichte",
"views.editor.hint.slug": "Kleinbuchstaben, Ziffern und Bindestriche — nicht reservierte Wörter.",
"views.editor.hint.sources": "Welche Datenarten zeigt diese Sicht?",
"views.editor.icon.default": "Standard (Ordner)",
"views.editor.icon.clock": "Uhr",
"views.editor.icon.calendar": "Kalender",
"views.editor.icon.bell": "Glocke",
"views.editor.icon.folder": "Ordner",
"views.editor.icon.users": "Personen",
"views.editor.icon.building": "Gebäude",
"views.editor.save": "Speichern",
"views.editor.cancel": "Abbrechen",
"views.editor.delete": "Löschen",
"views.editor.confirm_delete": "Diese Sicht wirklich löschen?",
"views.editor.error.name_required": "Name ist erforderlich.",
"views.editor.error.slug_format": "Slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen.",
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
"views.editor.error.load_failed": "Sicht konnte nicht geladen werden.",
"views.editor.error.delete_failed": "Sicht konnte nicht gelöscht werden.",
},
en: {
@@ -3452,6 +3546,100 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.policies.no_approval": "No approval needed",
"approvals.policies.copy_parent": "Copy from parent project",
"approvals.policies.set_all_associate": "Set all to Associate",
// t-paliad-144 — Custom Views
"nav.group.user_views": "My Views",
"nav.user_views.new": "New view",
"views.title": "Views — Paliad",
"views.heading": "Views",
"views.subtitle": "Saved views over your data — filters and shape preserved.",
"views.loading": "Loading …",
"views.shape.list": "List",
"views.shape.cards": "Cards",
"views.shape.calendar": "Calendar",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
"views.error.back": "Back to views",
"views.error.not_found": "View not found.",
"views.error.network": "Network error — please retry.",
"views.toast.inaccessible_one": "1 project in this view is no longer visible to you.",
"views.toast.inaccessible_n": "{n} projects in this view are no longer visible to you.",
"views.calendar.mobile_fallback": "Calendar view works best on a wide screen.",
"views.onboarding.title": "Saved views — what are they?",
"views.onboarding.body": "A view is a saved filter combination — e.g. \"Deadlines in my projects in the next 14 days\". Views appear as their own buttons in the sidebar.",
"views.onboarding.create": "Create example view",
"views.source.deadline": "Deadlines",
"views.source.appointment": "Appointments",
"views.source.project_event": "Project history",
"views.source.approval_request": "Approvals",
"views.kind.deadline": "Deadline",
"views.kind.appointment": "Appointment",
"views.kind.project_event": "History",
"views.kind.approval_request": "Approval",
"views.scope.all_visible": "All visible",
"views.scope.my_subtree": "My subtree",
"views.scope.explicit": "Specific projects",
"views.scope.personal_only": "Personal only",
"views.horizon.next_7d": "Next 7 days",
"views.horizon.next_30d": "Next 30 days",
"views.horizon.next_90d": "Next 90 days",
"views.horizon.past_30d": "Last 30 days",
"views.horizon.past_90d": "Last 90 days",
"views.horizon.any": "Any",
"views.horizon.all": "All-time",
"views.horizon.custom": "Custom",
"views.density.comfortable": "Comfortable",
"views.density.compact": "Compact",
"views.col.date": "Date",
"views.col.time": "When",
"views.col.title": "Title",
"views.col.project": "Project",
"views.col.actor": "Actor",
"views.col.status": "Status",
"views.col.rule": "Rule",
"views.col.event_type": "Type",
"views.col.location": "Location",
"views.col.appointment_type": "Appointment type",
"views.col.approval_status": "Approval",
"views.col.decided_by": "Decided by",
"views.col.kind": "Kind",
"views.editor.title": "Edit view — Paliad",
"views.editor.heading.new": "New view",
"views.editor.heading.edit": "Edit view",
"views.editor.subtitle": "Pick sources, filters, and shape. Save to confirm.",
"views.editor.section.identity": "Identity",
"views.editor.section.sources": "Sources",
"views.editor.section.scope": "Scope",
"views.editor.section.time": "Time",
"views.editor.section.render": "Display",
"views.editor.field.name": "Name",
"views.editor.field.slug": "Slug (URL)",
"views.editor.field.icon": "Icon",
"views.editor.field.show_count": "Show count badge in sidebar",
"views.editor.field.scope_mode": "Projects",
"views.editor.field.personal_only": "Personal only",
"views.editor.field.horizon": "Horizon",
"views.editor.field.shape": "Shape",
"views.editor.field.density": "Density",
"views.editor.hint.slug": "Lowercase letters, digits, hyphens — no reserved words.",
"views.editor.hint.sources": "Which data sources should this view include?",
"views.editor.icon.default": "Default (folder)",
"views.editor.icon.clock": "Clock",
"views.editor.icon.calendar": "Calendar",
"views.editor.icon.bell": "Bell",
"views.editor.icon.folder": "Folder",
"views.editor.icon.users": "People",
"views.editor.icon.building": "Building",
"views.editor.save": "Save",
"views.editor.cancel": "Cancel",
"views.editor.delete": "Delete",
"views.editor.confirm_delete": "Delete this view permanently?",
"views.editor.error.name_required": "Name is required.",
"views.editor.error.slug_format": "Slug must be lowercase, start with a letter or digit, contain only letters, digits, and hyphens.",
"views.editor.error.sources_required": "Pick at least one source.",
"views.editor.error.load_failed": "Could not load this view.",
"views.editor.error.delete_failed": "Could not delete this view.",
},
};

View File

@@ -72,6 +72,7 @@ export function initSidebar() {
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
@@ -400,6 +401,122 @@ function initThemeToggle(): void {
render();
}
// t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the
// caller's saved views and renders one nav item per view between the
// group label and the "+ Neue Sicht" trailing entry. Optional count
// badge per view (when show_count=true on the row). The "+ Neue Sicht"
// entry stays in the DOM unconditionally so the group has something
// to show even for first-time users.
interface UserViewLite {
id: string;
slug: string;
name: string;
icon?: string;
show_count: boolean;
}
function initUserViewsGroup(): void {
const items = document.getElementById("sidebar-views-items");
if (!items) return;
// Skip on auth-anon pages (/login, landing) — /api/user-views would 401.
if (!document.body.classList.contains("has-sidebar")) return;
fetch("/api/user-views", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((views: UserViewLite[] | null) => {
if (!views) return;
const currentPath = window.location.pathname;
items.innerHTML = "";
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {
void refreshUserViewCount(view);
}
}
})
.catch(() => {
// Silent — sidebar already shows "+ Neue Sicht" even on failure.
});
}
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
a.href = `/views/${encodeURIComponent(view.slug)}`;
const active = currentPath === a.pathname;
a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`;
a.dataset.slug = view.slug;
a.dataset.viewId = view.id;
const iconWrap = document.createElement("span");
iconWrap.className = "sidebar-icon";
iconWrap.innerHTML = userViewIconSvg(view.icon);
a.appendChild(iconWrap);
const label = document.createElement("span");
label.className = "sidebar-label";
label.textContent = view.name;
a.appendChild(label);
if (view.show_count) {
const badge = document.createElement("span");
badge.className = "sidebar-badge sidebar-user-view-badge";
badge.id = `sidebar-user-view-badge-${view.id}`;
badge.style.display = "none";
badge.setAttribute("aria-hidden", "true");
a.appendChild(badge);
}
return a;
}
async function refreshUserViewCount(view: UserViewLite): Promise<void> {
try {
const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) return;
const data = (await r.json()) as { rows: unknown[] };
const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`);
if (!badge) return;
if (data.rows.length > 0) {
badge.textContent = String(data.rows.length);
badge.style.display = "";
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
}
// userViewIconSvg picks an SVG from a small fixed registry. Falls back
// to the folder icon for unknown / missing keys. Inline SVGs are used
// elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset
// here rather than re-exporting because client TS doesn't import from
// JSX-emitting modules.
function userViewIconSvg(icon?: string): string {
switch (icon) {
case "clock":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
case "calendar":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="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>';
case "bell":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
case "users":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
case "building":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>';
case "folder":
default:
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
}
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -0,0 +1,282 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import {
defaultFilterSpec,
defaultRenderSpec,
type DataSource,
type FilterSpec,
type RenderShape,
type RenderSpec,
type ScopeMode,
type TimeHorizon,
type UserView,
} from "./views/types";
// View editor — /views/new (create) and /views/{slug}/edit (modify).
// The form has a small fixed set of widgets (no full predicate JSON
// editor in v1 — that's a follow-up if power users ask). Saves via
// POST/PATCH /api/user-views.
initI18n();
initSidebar();
interface EditorState {
mode: "new" | "edit";
// Set in edit mode after the existing view is fetched.
existing?: UserView;
}
let state: EditorState = { mode: "new" };
document.addEventListener("DOMContentLoaded", () => {
state = detectMode();
bindShapeToggle();
bindForm();
bindDelete();
if (state.mode === "edit") {
void loadExisting();
const heading = document.getElementById("editor-heading");
if (heading) heading.textContent = t("views.editor.heading.edit");
const del = document.getElementById("editor-delete");
if (del) del.hidden = false;
} else {
seedDefaults();
}
});
function detectMode(): EditorState {
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
if (m) return { mode: "edit" };
return { mode: "new" };
}
function editSlugFromPath(): string | null {
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
return m ? decodeURIComponent(m[1]) : null;
}
async function loadExisting(): Promise<void> {
const slug = editSlugFromPath();
if (!slug) return;
const r = await fetch("/api/user-views", { credentials: "include" });
if (!r.ok) {
showFeedback("error", t("views.editor.error.load_failed"));
return;
}
const list = (await r.json()) as UserView[];
const view = list.find((v) => v.slug === slug);
if (!view) {
showFeedback("error", t("views.error.not_found"));
return;
}
state.existing = view;
populateForm(view);
}
function populateForm(view: UserView): void {
setInputValue("editor-name", view.name);
setInputValue("editor-slug", view.slug);
setSelectValue("editor-icon", view.icon ?? "");
setCheckboxValue("editor-show-count", view.show_count);
for (const src of ["deadline", "appointment", "project_event", "approval_request"] as DataSource[]) {
setCheckboxValue(`source-${src}`, view.filter_spec.sources.includes(src), { name: "source", value: src });
}
setSelectValue("editor-scope-mode", view.filter_spec.scope.projects.mode);
setCheckboxValue("editor-personal-only", view.filter_spec.scope.personal_only ?? false);
setSelectValue("editor-time-horizon", view.filter_spec.time.horizon);
setSelectValue("editor-shape", view.render_spec.shape);
setSelectValue("editor-list-density", view.render_spec.list?.density ?? "comfortable");
// Hide list-density when shape isn't list.
toggleListDensityVisibility(view.render_spec.shape);
}
function seedDefaults(): void {
// Seed the form with a useful blank-slate spec.
const filter = defaultFilterSpec();
const render = defaultRenderSpec();
for (const src of filter.sources) {
setCheckboxValue(`source-${src}`, true, { name: "source", value: src });
}
setSelectValue("editor-scope-mode", filter.scope.projects.mode);
setSelectValue("editor-time-horizon", filter.time.horizon);
setSelectValue("editor-shape", render.shape);
setSelectValue("editor-list-density", render.list?.density ?? "comfortable");
toggleListDensityVisibility(render.shape);
}
function bindShapeToggle(): void {
const shapeSelect = document.getElementById("editor-shape") as HTMLSelectElement | null;
if (!shapeSelect) return;
shapeSelect.addEventListener("change", () => {
toggleListDensityVisibility(shapeSelect.value as RenderShape);
});
}
function toggleListDensityVisibility(shape: RenderShape): void {
const group = document.getElementById("editor-list-density-group");
if (!group) return;
group.style.display = shape === "list" ? "" : "none";
}
function bindForm(): void {
const form = document.getElementById("editor-form") as HTMLFormElement | null;
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm();
if (!payload) return; // collectForm already shows feedback
if (state.mode === "edit" && state.existing) {
await save("PATCH", `/api/user-views/${state.existing.id}`, payload);
} else {
await save("POST", `/api/user-views`, payload);
}
});
}
function bindDelete(): void {
const btn = document.getElementById("editor-delete") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!state.existing) return;
if (!confirm(t("views.editor.confirm_delete"))) return;
const r = await fetch(`/api/user-views/${state.existing.id}`, {
method: "DELETE",
credentials: "include",
});
if (!r.ok) {
showFeedback("error", t("views.editor.error.delete_failed"));
return;
}
window.location.href = "/views";
});
}
interface CreatePayload {
slug: string;
name: string;
icon?: string;
filter_spec: FilterSpec;
render_spec: RenderSpec;
show_count: boolean;
}
function collectForm(): CreatePayload | null {
const name = getInputValue("editor-name").trim();
const slug = getInputValue("editor-slug").trim();
const iconRaw = getSelectValue("editor-icon");
const icon = iconRaw === "" ? undefined : iconRaw;
const showCount = getCheckboxValue("editor-show-count");
if (!name) {
showFeedback("error", t("views.editor.error.name_required"));
return null;
}
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) {
showFeedback("error", t("views.editor.error.slug_format"));
return null;
}
const sources: DataSource[] = (["deadline", "appointment", "project_event", "approval_request"] as DataSource[])
.filter((s) => getCheckboxValue(`source-${s}`));
if (sources.length === 0) {
showFeedback("error", t("views.editor.error.sources_required"));
return null;
}
const scopeMode = getSelectValue("editor-scope-mode") as ScopeMode;
const personalOnly = getCheckboxValue("editor-personal-only");
const horizon = getSelectValue("editor-time-horizon") as TimeHorizon;
const shape = getSelectValue("editor-shape") as RenderShape;
const listDensity = getSelectValue("editor-list-density") as "comfortable" | "compact";
const filter: FilterSpec = {
version: 1,
sources,
scope: {
projects: { mode: scopeMode },
personal_only: personalOnly,
},
time: { horizon, field: "auto" },
};
const render: RenderSpec = {
shape,
list: shape === "list" ? { density: listDensity, sort: "date_asc" } : undefined,
cards: shape === "cards" ? { group_by: "day", sort: "date_asc" } : undefined,
calendar: shape === "calendar" ? { default_view: "month" } : undefined,
};
return { slug, name, icon, filter_spec: filter, render_spec: render, show_count: showCount };
}
async function save(method: "POST" | "PATCH", url: string, payload: CreatePayload): Promise<void> {
const r = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
showFeedback("error", body.error || `${r.status}: ${r.statusText}`);
return;
}
// Saved — go to the saved view.
window.location.href = `/views/${encodeURIComponent(payload.slug)}`;
}
// ----- DOM helpers -----
function getInputValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | null;
return el?.value ?? "";
}
function setInputValue(id: string, value: string): void {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) el.value = value;
}
function getSelectValue(id: string): string {
const el = document.getElementById(id) as HTMLSelectElement | null;
return el?.value ?? "";
}
function setSelectValue(id: string, value: string): void {
const el = document.getElementById(id) as HTMLSelectElement | null;
if (el) el.value = value;
}
function getCheckboxValue(id: string): boolean {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) return el.checked;
// Fallback: lookup by name+value (for the source-* checkbox group).
const m = id.match(/^source-(.+)$/);
if (m) {
const cb = document.querySelector<HTMLInputElement>(`input[name="source"][value="${m[1]}"]`);
return !!cb?.checked;
}
return false;
}
function setCheckboxValue(id: string, value: boolean, fallback?: { name: string; value: string }): void {
let el = document.getElementById(id) as HTMLInputElement | null;
if (!el && fallback) {
el = document.querySelector<HTMLInputElement>(`input[name="${fallback.name}"][value="${fallback.value}"]`);
}
if (el) el.checked = value;
}
function showFeedback(kind: "success" | "error", text: string): void {
const el = document.getElementById("editor-feedback");
if (!el) return;
el.textContent = text;
el.classList.remove("form-msg-success", "form-msg-error");
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
el.hidden = false;
}

View File

@@ -0,0 +1,251 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
initI18n();
initSidebar();
interface ViewMeta {
// For saved views: identifies the row for touch/edit/delete.
user_view_id?: string;
// Display name + slug.
name: string;
slug: string;
// Filter + render specs (may be overridden by slug detection).
filter: FilterSpec;
render: RenderSpec;
// Whether this is a code-resident SystemView.
is_system: boolean;
}
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
bindToastClose();
void hydrate();
});
async function hydrate(): Promise<void> {
const slug = pathSlug();
if (!slug) {
// /views with no slug → empty / onboarding state.
const onboarding = document.getElementById("views-onboarding");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = true;
if (onboarding) onboarding.hidden = false;
return;
}
// Resolve the view: try system first, then user.
const meta = await resolveMeta(slug);
if (!meta) {
showError(t("views.error.not_found"));
return;
}
currentMeta = meta;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
}
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
// Try the system view list first — cheap, code-resident.
try {
const r = await fetch("/api/views/system", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
const sys = list.find((sv) => sv.Slug === slug);
if (sys) {
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
}
}
} catch (_e) {
// fall through to user lookup
}
// Try a saved user view.
try {
const r = await fetch("/api/user-views", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as UserView[];
const v = list.find((uv) => uv.slug === slug);
if (v) {
return {
user_view_id: v.id,
name: v.name,
slug: v.slug,
filter: v.filter_spec,
render: v.render_spec,
is_system: false,
};
}
}
} catch (_e) { /* noop */ }
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
const loading = document.getElementById("views-loading");
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
}
return;
}
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
}
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
switch (shape) {
case "list":
renderListShape(host, rows, render);
break;
case "cards":
renderCardsShape(host, rows, render);
break;
case "calendar":
renderCalendarShape(host, rows, render);
break;
}
}
function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});
});
}
function updateHeader(meta: ViewMeta): void {
const heading = document.getElementById("views-heading");
if (heading) heading.textContent = meta.name;
const subtitle = document.getElementById("views-subtitle");
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
const actions = document.getElementById("views-header-actions");
if (actions) {
actions.innerHTML = "";
if (!meta.is_system && meta.user_view_id) {
const editLink = document.createElement("a");
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
editLink.className = "btn-secondary btn-small";
editLink.textContent = t("views.action.edit");
actions.appendChild(editLink);
}
}
}
function filterSummary(filter: FilterSpec): string {
const parts: string[] = [];
// Sources
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
// Time
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
// Scope
if (filter.scope.personal_only) {
parts.push(t("views.scope.personal_only"));
} else if (filter.scope.projects.mode !== "all_visible") {
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
}
return parts.join(" · ");
}
function showError(message: string): void {
const loading = document.getElementById("views-loading");
const errorEl = document.getElementById("views-error");
const msg = document.getElementById("views-error-message");
if (loading) loading.hidden = true;
if (errorEl) errorEl.hidden = false;
if (msg) msg.textContent = message;
}
function showInaccessibleToast(count: number): void {
const toast = document.getElementById("views-toast");
const text = document.getElementById("views-toast-text");
if (!toast || !text) return;
text.textContent = count === 1
? t("views.toast.inaccessible_one")
: t("views.toast.inaccessible_n").replace("{n}", String(count));
toast.hidden = false;
}
function bindToastClose(): void {
const close = document.getElementById("views-toast-close");
const toast = document.getElementById("views-toast");
if (!close || !toast) return;
close.addEventListener("click", () => { toast.hidden = true; });
}
function pathSlug(): string | null {
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
if (!m) return null;
return decodeURIComponent(m[1]);
}
function fireAndForget(url: string, method: string): void {
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
}

View File

@@ -0,0 +1,129 @@
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}`;
}

View File

@@ -0,0 +1,118 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.cards ?? {};
const groupBy = cfg.group_by ?? "day";
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
return;
}
const groups = groupRows(sorted, groupBy);
for (const [key, items] of groups) {
const section = document.createElement("section");
section.className = "views-cards-day";
const heading = document.createElement("h2");
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
const head = document.createElement("div");
head.className = "views-card-head";
const kind = document.createElement("span");
kind.className = "views-card-kind";
kind.textContent = t(("views.kind." + row.kind) as I18nKey);
head.appendChild(kind);
const title = document.createElement("h3");
title.className = "views-card-title";
title.textContent = row.title;
head.appendChild(title);
li.appendChild(head);
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
proj.textContent = row.project_title;
meta.appendChild(proj);
}
if (row.actor_name) {
const actor = document.createElement("span");
actor.className = "views-card-actor";
actor.textContent = row.actor_name;
meta.appendChild(actor);
}
li.appendChild(meta);
if (row.subtitle) {
const sub = document.createElement("p");
sub.className = "views-card-subtitle";
sub.textContent = row.subtitle;
li.appendChild(sub);
}
ul.appendChild(li);
}
return ul;
}
function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, ViewRow[]]> {
const map = new Map<string, ViewRow[]>();
for (const row of rows) {
const key = bucketKey(row.event_date, groupBy);
const arr = map.get(key);
if (arr) arr.push(row);
else map.set(key, [row]);
}
return Array.from(map.entries());
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
const yearStart = new Date(Date.UTC(monday.getFullYear(), 0, 1));
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
}

View File

@@ -0,0 +1,181 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
// is just density=compact + actor/time columns — see Q4 lock-in
// 2026-05-07 (3 shapes; no separate "activity").
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const list = render.list ?? {};
const density = list.density ?? "comfortable";
const sort = list.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows)));
}
}
function renderCompact(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-list views-list--compact";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-list-row views-list-row--${row.kind}`;
const time = document.createElement("span");
time.className = "views-list-time";
time.textContent = formatRelative(row.event_date);
li.appendChild(time);
const kindIcon = document.createElement("span");
kindIcon.className = "views-list-kind";
kindIcon.textContent = kindLabel(row.kind);
li.appendChild(kindIcon);
const title = document.createElement("span");
title.className = "views-list-title";
title.textContent = row.title;
li.appendChild(title);
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-list-project";
proj.textContent = row.project_title;
li.appendChild(proj);
}
if (row.actor_name) {
const actor = document.createElement("span");
actor.className = "views-list-actor";
actor.textContent = row.actor_name;
li.appendChild(actor);
}
if (row.subtitle) {
const sub = document.createElement("span");
sub.className = "views-list-subtitle";
sub.textContent = row.subtitle;
li.appendChild(sub);
}
ul.appendChild(li);
}
return ul;
}
function renderTable(rows: ViewRow[], columns: string[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "entity-table-wrap";
const table = document.createElement("table");
table.className = "entity-table views-list views-list--table entity-table--readonly";
const thead = document.createElement("thead");
const trHead = document.createElement("tr");
for (const col of columns) {
const th = document.createElement("th");
th.textContent = t(("views.col." + col) as I18nKey);
trHead.appendChild(th);
}
thead.appendChild(trHead);
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
tr.className = `views-table-row views-table-row--${row.kind}`;
for (const col of columns) {
const td = document.createElement("td");
td.textContent = formatColumn(row, col);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrap.appendChild(table);
return wrap;
}
function defaultColumns(rows: ViewRow[]): string[] {
// Pick a sensible default column set from the kinds present in the
// result. Keeps the UI honest when a user lands on a saved view that
// has no explicit list.columns.
const kinds = new Set(rows.map((r) => r.kind));
if (kinds.has("project_event") || kinds.has("approval_request")) {
return ["time", "actor", "title", "project"];
}
if (kinds.has("appointment")) {
return ["date", "title", "project", "location"];
}
return ["date", "title", "project", "status"];
}
function formatColumn(row: ViewRow, col: string): string {
switch (col) {
case "date":
return formatDate(row.event_date);
case "time":
return formatRelative(row.event_date);
case "title":
return row.title;
case "project":
return row.project_title ?? "—";
case "actor":
return row.actor_name ?? "—";
case "status": {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":
return (row.detail.location as string | undefined) ?? "—";
case "appointment_type":
return (row.detail.appointment_type as string | undefined) ?? "—";
case "approval_status":
return (row.detail.approval_status as string | undefined) ?? "—";
case "decided_by":
return (row.detail.decider_name as string | undefined) ?? "—";
case "kind":
return kindLabel(row.kind);
default:
return "";
}
}
function kindLabel(kind: string): string {
return t(("views.kind." + kind) as I18nKey);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function formatRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
}

View File

@@ -0,0 +1,159 @@
// Shared TypeScript types for the Custom Views frontend.
//
// These mirror the Go shapes in internal/services/{filter_spec,
// render_spec,view_service,user_view_service}.go. Keep field names + enum
// values in sync — the substrate's validator will reject anything else.
export type DataSource = "deadline" | "appointment" | "project_event" | "approval_request";
export type ScopeMode = "all_visible" | "my_subtree" | "explicit";
export interface ScopeProjects {
mode: ScopeMode;
ids?: string[];
}
export interface ScopeSpec {
projects: ScopeProjects;
personal_only?: boolean;
}
export type TimeHorizon =
| "next_7d" | "next_30d" | "next_90d"
| "past_30d" | "past_90d"
| "any" | "all" | "custom";
export type TimeField = "auto" | "created_at";
export interface TimeSpec {
horizon: TimeHorizon;
field?: TimeField;
from?: string; // ISO 8601
to?: string;
}
export interface DeadlinePredicates {
status?: string[];
approval_status?: string[];
event_types?: string[];
include_untyped?: boolean;
}
export interface AppointmentPredicates {
approval_status?: string[];
appointment_types?: string[];
}
export interface ProjectEventPredicates {
event_types?: string[];
}
export interface ApprovalRequestPredicates {
viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
status?: string[];
entity_types?: string[];
}
export interface Predicates {
deadline?: DeadlinePredicates;
appointment?: AppointmentPredicates;
project_event?: ProjectEventPredicates;
approval_request?: ApprovalRequestPredicates;
}
export interface FilterSpec {
version: number;
sources: DataSource[];
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
}
export type RenderShape = "list" | "cards" | "calendar";
export interface ListConfig {
columns?: string[];
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
}
export interface CardsConfig {
group_by?: "day" | "week" | "none";
sort?: "date_asc" | "date_desc";
show_empty_days?: boolean;
}
export interface CalendarConfig {
default_view?: "month" | "week";
show_weekends?: boolean;
}
export interface RenderSpec {
shape: RenderShape;
list?: ListConfig;
cards?: CardsConfig;
calendar?: CalendarConfig;
}
// ViewRow — the discriminated row shape from ViewService.RunSpec.
export interface ViewRow {
kind: DataSource;
id: string;
title: string;
subtitle?: string;
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
project_type?: string;
actor_id?: string;
actor_name?: string;
detail: Record<string, unknown>;
}
export interface ViewRunResult {
rows: ViewRow[];
inaccessible_project_ids?: string[];
}
// UserView — the persisted shape from /api/user-views.
export interface UserView {
id: string;
user_id: string;
slug: string;
name: string;
icon?: string;
filter_spec: FilterSpec;
render_spec: RenderSpec;
sort_order: number;
show_count: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
}
// SystemView — code-resident definition from /api/views/system.
export interface SystemView {
Slug: string;
Name: string;
Filter: FilterSpec;
Render: RenderSpec;
}
export const SPEC_VERSION = 1;
export function defaultFilterSpec(): FilterSpec {
return {
version: SPEC_VERSION,
sources: ["deadline", "appointment"],
scope: { projects: { mode: "all_visible" } },
time: { horizon: "next_30d", field: "auto" },
};
}
export function defaultRenderSpec(): RenderSpec {
return {
shape: "list",
list: { sort: "date_asc", density: "comfortable" },
};
}

View File

@@ -125,6 +125,19 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
)}
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
client/sidebar.ts from /api/user-views on mount. The
"+ Neue Sicht" entry is always present so first-time
users have an obvious way in. */}
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
<div className="sidebar-views-items" id="sidebar-views-items" />
<a href="/views/new" className="sidebar-item sidebar-views-new">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
<span className="sidebar-label" data-i18n="nav.user_views.new">Neue Sicht</span>
</a>
</div>
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +

View File

@@ -1294,6 +1294,7 @@ export type I18nKey =
| "nav.group.einstellungen"
| "nav.group.ressourcen"
| "nav.group.uebersicht"
| "nav.group.user_views"
| "nav.group.werkzeuge"
| "nav.group.wissen"
| "nav.home"
@@ -1306,6 +1307,7 @@ export type I18nKey =
| "nav.soon.tooltip"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
| "notes.cancel"
| "notes.delete"
| "notes.delete.confirm"
@@ -1616,4 +1618,94 @@ export type I18nKey =
| "unit_role.lead"
| "unit_role.pa"
| "unit_role.paralegal"
| "unit_role.senior_pa";
| "unit_role.senior_pa"
| "views.action.edit"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"
| "views.col.approval_status"
| "views.col.date"
| "views.col.decided_by"
| "views.col.event_type"
| "views.col.kind"
| "views.col.location"
| "views.col.project"
| "views.col.rule"
| "views.col.status"
| "views.col.time"
| "views.col.title"
| "views.density.comfortable"
| "views.density.compact"
| "views.editor.cancel"
| "views.editor.confirm_delete"
| "views.editor.delete"
| "views.editor.error.delete_failed"
| "views.editor.error.load_failed"
| "views.editor.error.name_required"
| "views.editor.error.slug_format"
| "views.editor.error.sources_required"
| "views.editor.field.density"
| "views.editor.field.horizon"
| "views.editor.field.icon"
| "views.editor.field.name"
| "views.editor.field.personal_only"
| "views.editor.field.scope_mode"
| "views.editor.field.shape"
| "views.editor.field.show_count"
| "views.editor.field.slug"
| "views.editor.heading.edit"
| "views.editor.heading.new"
| "views.editor.hint.slug"
| "views.editor.hint.sources"
| "views.editor.icon.bell"
| "views.editor.icon.building"
| "views.editor.icon.calendar"
| "views.editor.icon.clock"
| "views.editor.icon.default"
| "views.editor.icon.folder"
| "views.editor.icon.users"
| "views.editor.save"
| "views.editor.section.identity"
| "views.editor.section.render"
| "views.editor.section.scope"
| "views.editor.section.sources"
| "views.editor.section.time"
| "views.editor.subtitle"
| "views.editor.title"
| "views.empty.title"
| "views.error.back"
| "views.error.network"
| "views.error.not_found"
| "views.heading"
| "views.horizon.all"
| "views.horizon.any"
| "views.horizon.custom"
| "views.horizon.next_30d"
| "views.horizon.next_7d"
| "views.horizon.next_90d"
| "views.horizon.past_30d"
| "views.horizon.past_90d"
| "views.kind.appointment"
| "views.kind.approval_request"
| "views.kind.deadline"
| "views.kind.project_event"
| "views.loading"
| "views.onboarding.body"
| "views.onboarding.create"
| "views.onboarding.title"
| "views.save_as"
| "views.scope.all_visible"
| "views.scope.explicit"
| "views.scope.my_subtree"
| "views.scope.personal_only"
| "views.shape.calendar"
| "views.shape.cards"
| "views.shape.list"
| "views.source.appointment"
| "views.source.approval_request"
| "views.source.deadline"
| "views.source.project_event"
| "views.subtitle"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -10540,3 +10540,249 @@ dialog.quick-add-sheet::backdrop {
min-width: 180px;
}
/* ============================================================================
* t-paliad-144 Phase A2 — Custom Views shell + Meine Sichten sidebar group
* + render-shape components (list / cards / calendar).
* ============================================================================ */
/* Sidebar — Meine Sichten group. Mirrors .sidebar-admin-group spacing. */
.sidebar-views-group .sidebar-views-items {
display: flex;
flex-direction: column;
}
.sidebar-views-group .sidebar-views-new {
color: var(--text-muted);
font-style: italic;
}
.sidebar-views-group .sidebar-user-view-item {
/* Inherits .sidebar-item; nothing override-worthy yet. */
}
.sidebar-user-view-badge {
/* Reuses .sidebar-badge styles; this keeps the selector available
for future tweaks. */
}
/* Views shell page — toolbar + states. */
.views-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
flex-wrap: wrap;
}
.views-toolbar-spacer {
flex: 1 1 auto;
}
.views-loading,
.views-error,
.views-empty,
.views-onboarding {
margin: 24px 0;
padding: 16px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
border-radius: 8px;
background: var(--surface-subtle, rgba(0,0,0,0.02));
}
.views-onboarding-actions {
margin-top: 12px;
}
.views-toast {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
padding: 10px 14px;
background: #fff8db;
border: 1px solid #f3d27a;
border-radius: 6px;
color: #5b4304;
}
.views-toast-close {
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: inherit;
}
.views-shape-host {
margin-top: 16px;
}
/* Header actions — edit / delete buttons. */
.views-header-actions {
display: flex;
gap: 8px;
}
/* shape=list (compact density). */
.views-list--compact {
list-style: none;
margin: 0;
padding: 0;
}
.views-list-row {
display: grid;
grid-template-columns: 90px 110px 1fr auto auto;
gap: 12px;
align-items: baseline;
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
font-size: 14px;
}
.views-list-row:hover {
background: var(--surface-hover, rgba(0,0,0,0.03));
}
.views-list-time {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.views-list-kind {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.views-list-title {
font-weight: 500;
}
.views-list-project,
.views-list-actor {
font-size: 13px;
color: var(--text-muted);
}
.views-list-subtitle {
grid-column: 3 / -1;
color: var(--text-muted);
font-size: 13px;
}
/* shape=cards. */
.views-cards-day {
margin-top: 24px;
}
.views-cards-day-heading {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.views-cards-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.views-card {
padding: 14px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
border-radius: 8px;
background: var(--surface, #fff);
}
.views-card-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.views-card-kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.views-card-title {
font-size: 16px;
margin: 0;
}
.views-card-meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
}
.views-card-meta > * + *::before {
content: "·";
margin-right: 8px;
color: var(--text-muted);
}
.views-card-subtitle {
margin: 8px 0 0 0;
font-size: 13px;
color: var(--text-muted);
}
/* shape=calendar. */
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-cell {
min-height: 80px;
padding: 6px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
border-radius: 4px;
background: var(--surface, #fff);
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--border-subtle, rgba(0,0,0,0.04));
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.views-calendar-pills {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.views-calendar-pill {
font-size: 11px;
padding: 2px 4px;
border-radius: 3px;
background: var(--surface-subtle, rgba(0,0,0,0.04));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-pill--more {
color: var(--text-muted);
text-align: center;
background: transparent;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -0,0 +1,153 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Custom Views editor (t-paliad-144 Phase A2). Powers /views/new (blank
// slate) and /views/{slug}/edit (mode chosen at hydration via path
// inspection). One TSX, one bundle (client/views-editor.ts).
export function renderViewsEditor(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="views.editor.title">Sicht bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/views" />
<BottomNav currentPath="/views" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="editor-heading" data-i18n="views.editor.heading.new">Neue Sicht</h1>
<p className="tool-subtitle" data-i18n="views.editor.subtitle">
W&auml;hlen Sie Quellen, Filter und Darstellung. &Auml;nderungen speichern Sie unten.
</p>
</div>
<form id="editor-form" className="entity-form" novalidate>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.identity">Bezeichnung</legend>
<div className="form-field">
<label htmlFor="editor-name" data-i18n="views.editor.field.name">Name</label>
<input id="editor-name" type="text" required maxlength={200} />
</div>
<div className="form-field">
<label htmlFor="editor-slug" data-i18n="views.editor.field.slug">Slug (URL)</label>
<input id="editor-slug" type="text" required pattern="^[a-z0-9][a-z0-9-]{0,62}$" maxlength={63} />
<small className="form-hint" data-i18n="views.editor.hint.slug">
Kleinbuchstaben, Ziffern und Bindestriche &mdash; nicht reservierte W&ouml;rter.
</small>
</div>
<div className="form-field">
<label htmlFor="editor-icon" data-i18n="views.editor.field.icon">Icon</label>
<select id="editor-icon">
<option value="" data-i18n="views.editor.icon.default">Standard (Ordner)</option>
<option value="clock" data-i18n="views.editor.icon.clock">Uhr</option>
<option value="calendar" data-i18n="views.editor.icon.calendar">Kalender</option>
<option value="bell" data-i18n="views.editor.icon.bell">Glocke</option>
<option value="folder" data-i18n="views.editor.icon.folder">Ordner</option>
<option value="users" data-i18n="views.editor.icon.users">Personen</option>
<option value="building" data-i18n="views.editor.icon.building">Geb&auml;ude</option>
</select>
</div>
<div className="form-field form-field-checkbox">
<label>
<input id="editor-show-count" type="checkbox" />
<span data-i18n="views.editor.field.show_count">Treffer-Anzahl in der Sidebar anzeigen</span>
</label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.sources">Quellen</legend>
<p className="form-hint" data-i18n="views.editor.hint.sources">Welche Datenarten zeigt diese Sicht?</p>
<div className="form-field form-field-checkbox-group">
<label><input type="checkbox" name="source" value="deadline" /> <span data-i18n="views.source.deadline">Fristen</span></label>
<label><input type="checkbox" name="source" value="appointment" /> <span data-i18n="views.source.appointment">Termine</span></label>
<label><input type="checkbox" name="source" value="project_event" /> <span data-i18n="views.source.project_event">Projekt-Verlauf</span></label>
<label><input type="checkbox" name="source" value="approval_request" /> <span data-i18n="views.source.approval_request">Genehmigungen</span></label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.scope">Geltungsbereich</legend>
<div className="form-field">
<label htmlFor="editor-scope-mode" data-i18n="views.editor.field.scope_mode">Projekte</label>
<select id="editor-scope-mode">
<option value="all_visible" data-i18n="views.scope.all_visible">Alle sichtbaren</option>
<option value="my_subtree" data-i18n="views.scope.my_subtree">Mein Teilbaum</option>
</select>
</div>
<div className="form-field form-field-checkbox">
<label>
<input id="editor-personal-only" type="checkbox" />
<span data-i18n="views.editor.field.personal_only">Nur pers&ouml;nliche</span>
</label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.time">Zeitraum</legend>
<div className="form-field">
<label htmlFor="editor-time-horizon" data-i18n="views.editor.field.horizon">Horizont</label>
<select id="editor-time-horizon">
<option value="next_7d" data-i18n="views.horizon.next_7d">N&auml;chste 7 Tage</option>
<option value="next_30d" data-i18n="views.horizon.next_30d">N&auml;chste 30 Tage</option>
<option value="next_90d" data-i18n="views.horizon.next_90d">N&auml;chste 90 Tage</option>
<option value="past_30d" data-i18n="views.horizon.past_30d">Letzte 30 Tage</option>
<option value="past_90d" data-i18n="views.horizon.past_90d">Letzte 90 Tage</option>
<option value="any" data-i18n="views.horizon.any">Beliebig</option>
</select>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.render">Darstellung</legend>
<div className="form-field">
<label htmlFor="editor-shape" data-i18n="views.editor.field.shape">Form</label>
<select id="editor-shape">
<option value="list" data-i18n="views.shape.list">Liste</option>
<option value="cards" data-i18n="views.shape.cards">Karten</option>
<option value="calendar" data-i18n="views.shape.calendar">Kalender</option>
</select>
</div>
<div className="form-field" id="editor-list-density-group">
<label htmlFor="editor-list-density" data-i18n="views.editor.field.density">Dichte</label>
<select id="editor-list-density">
<option value="comfortable" data-i18n="views.density.comfortable">Bequem</option>
<option value="compact" data-i18n="views.density.compact">Kompakt</option>
</select>
</div>
</fieldset>
<div className="entity-form-feedback" id="editor-feedback" hidden />
<div className="entity-form-actions">
<button type="submit" className="btn-primary btn-cta-lime" id="editor-save" data-i18n="views.editor.save">
Speichern
</button>
<a href="/views" className="btn-secondary" data-i18n="views.editor.cancel">Abbrechen</a>
<button type="button" className="btn-danger" id="editor-delete" hidden data-i18n="views.editor.delete">
L&ouml;schen
</button>
</div>
</form>
</div>
</section>
<Footer />
</main>
<script src="/assets/views-editor.js" defer />
</body>
</html>
);
}

105
frontend/src/views.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Custom Views shell (t-paliad-144 Phase A2). One TSX powers /views (the
// landing) and /views/{slug} (a specific view). The client bundle reads
// window.location.pathname to decide which mode to render.
//
// Hydration: client/views.ts loads the saved or system view via /api/views
// and dispatches to the matching render-shape component (list / cards /
// calendar — Q4 lock-in 2026-05-07: 3 shapes, no separate "activity").
export function renderViews(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="views.title">Sichten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/views" />
<BottomNav currentPath="/views" />
<main>
<section className="tool-page">
<div className="container">
{/* Header — populated by client/views.ts from the loaded view name. */}
<div className="tool-header" id="views-header">
<div className="entity-header-row">
<div>
<h1 id="views-heading" data-i18n="views.heading">Sichten</h1>
<p className="tool-subtitle" id="views-subtitle" data-i18n="views.subtitle">
Eigene Sichten &uuml;ber Ihre Daten &mdash; Filter und Darstellung speicherbar.
</p>
</div>
<div className="views-header-actions" id="views-header-actions">
{/* Edit + delete buttons inserted by client/views.ts when on a custom view. */}
</div>
</div>
</div>
{/* Toolbar — shape switcher (3 shapes per Q4 lock-in). */}
<div className="views-toolbar" id="views-toolbar" hidden>
<div className="agenda-chip-row" role="tablist" id="views-shape-chips" aria-label="Form">
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
</div>
<div className="views-toolbar-spacer" />
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
Als Sicht speichern
</a>
</div>
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Sichten &mdash; was ist das?</h2>
<p data-i18n="views.onboarding.body">
Eine Sicht ist eine gespeicherte Filterkombination &mdash; z.&thinsp;B. &bdquo;Fristen meiner Projekte in den n&auml;chsten 14 Tagen&ldquo;.
Sichten erscheinen als eigene Buttons in der Sidebar.
</p>
<div className="views-onboarding-actions">
<a href="/views/new" className="btn-primary btn-cta-lime" data-i18n="views.onboarding.create">
Beispiel-Sicht erstellen
</a>
</div>
</div>
{/* Inaccessible-projects toast (Q17 attribution). */}
<div className="views-toast" id="views-toast" hidden>
<span className="views-toast-text" id="views-toast-text" />
<button type="button" className="views-toast-close" id="views-toast-close" aria-label="Close">&times;</button>
</div>
{/* Loading + error + empty states (mutually exclusive). */}
<div className="views-loading" id="views-loading" data-i18n="views.loading">L&auml;dt &hellip;</div>
<div className="views-error" id="views-error" hidden>
<p id="views-error-message" />
<a href="/views" className="btn-secondary btn-small" data-i18n="views.error.back">Zur&uuml;ck zur Sichten-&Uuml;bersicht</a>
</div>
<div className="views-empty" id="views-empty" hidden>
<p data-i18n="views.empty.title">Keine Eintr&auml;ge gefunden.</p>
<p className="views-empty-hint" id="views-empty-hint" />
</div>
{/* Render targets — only the active shape is visible. */}
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
</div>
</section>
<Footer />
</main>
<script src="/assets/views.js" defer />
</body>
</html>
);
}

View File

@@ -0,0 +1,3 @@
-- Reverse of 056_user_views.up.sql.
DROP TABLE IF EXISTS paliad.user_views;

View File

@@ -0,0 +1,77 @@
-- t-paliad-144 Phase A1: Custom Views — paliad.user_views.
--
-- Design: docs/design-data-display-model-2026-05-06.md (noether,
-- m-locked 2026-05-07).
--
-- Stores per-user saved view definitions. A view is a `(filter_spec,
-- render_spec, sidebar metadata)` tuple. RLS scopes every operation
-- to the calling user — there is no cross-user visibility in v1.
--
-- System defaults (dashboard / agenda / events / inbox) stay code-
-- resident in internal/services/system_views.go. They never appear
-- as rows in this table; the slugs are reserved and rejected at write
-- time by the application layer.
--
-- Sections:
-- 1. CREATE paliad.user_views (with RLS).
-- 2. Indexes.
-- ============================================================================
-- 1. paliad.user_views
-- ============================================================================
CREATE TABLE paliad.user_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Stable user-facing identifier. Goes into the URL.
-- Application-layer validator enforces ^[a-z0-9][a-z0-9-]{0,62}$ +
-- a reserved-list rejection (dashboard, agenda, events, inbox, …).
slug text NOT NULL,
-- Display name. Free-form; user picks the language they think in.
-- Rendered verbatim in the sidebar; no fallback or translation.
name text NOT NULL,
-- One of a fixed set of icon keys (see Sidebar.tsx icon registry).
-- NULL → default icon (folder). Validator caps length to keep the
-- column sane even if the registry is bypassed.
icon text,
-- Filter spec — see internal/services/filter_spec.go FilterSpec.
-- Validated on write; jsonb here for forward-compat without
-- migrations as new dimensions land.
filter_spec jsonb NOT NULL,
-- Render spec — see internal/services/render_spec.go RenderSpec.
render_spec jsonb NOT NULL,
-- Sidebar ordering. Lower-first. New views land at MAX+1 server-side
-- so they sort to the bottom; the editor lets users drag-reorder.
sort_order integer NOT NULL DEFAULT 0,
-- Show a row-count badge on the sidebar entry. Costs one COUNT(*)
-- per refresh; opt-in (default false) so casual users don't pay.
show_count boolean NOT NULL DEFAULT false,
-- Most-recently-used landing on /views (Q10). Updated by a fire-
-- and-forget PATCH on every view-load.
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, slug)
);
CREATE INDEX user_views_owner_idx
ON paliad.user_views (user_id, sort_order);
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
-- Owner-only access. No global_admin override: views are personal
-- working state, not auditable infrastructure.
CREATE POLICY user_views_owner_all
ON paliad.user_views FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -64,6 +64,7 @@ type Services struct {
Courts *services.CourtService
Approval *services.ApprovalService
Derivation *services.DerivationService
UserView *services.UserViewService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -100,6 +101,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
courts: svc.Courts,
approval: svc.Approval,
derivation: svc.Derivation,
userView: svc.UserView,
}
}
@@ -403,6 +405,30 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
}
// t-paliad-144 Phase A1+A2 — Custom Views (substrate + user_views CRUD
// + page shells). API endpoints register when the substrate services are
// wired; page shells register unconditionally so /views itself stays
// reachable for the empty-state onboarding.
if svc != nil && svc.UserView != nil && svc.Event != nil {
// API
protected.HandleFunc("GET /api/user-views", handleListUserViews)
protected.HandleFunc("POST /api/user-views", handleCreateUserView)
protected.HandleFunc("GET /api/user-views/{id}", handleGetUserView)
protected.HandleFunc("PATCH /api/user-views/{id}", handleUpdateUserView)
protected.HandleFunc("DELETE /api/user-views/{id}", handleDeleteUserView)
protected.HandleFunc("POST /api/user-views/{id}/touch", handleTouchUserView)
protected.HandleFunc("POST /api/views/run", handleRunAdhocView)
protected.HandleFunc("POST /api/views/{slug}/run", handleRunSavedView)
protected.HandleFunc("GET /api/views/system", handleListSystemViews)
// Page shells (A2)
protected.HandleFunc("GET /views", gateOnboarded(handleViewsLandingPage))
protected.HandleFunc("GET /views/new", gateOnboarded(handleViewsNewPage))
protected.HandleFunc("GET /views/{slug}/edit", gateOnboarded(handleViewsEditPage))
protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage))
}
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.

View File

@@ -44,6 +44,7 @@ type dbServices struct {
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
}
var dbSvc *dbServices

393
internal/handlers/views.go Normal file
View File

@@ -0,0 +1,393 @@
package handlers
// HTTP handlers for the Custom Views feature (t-paliad-144 Phase A1).
//
// Endpoints:
// GET /api/user-views — list saved views
// POST /api/user-views — create
// GET /api/user-views/{id} — fetch one
// PATCH /api/user-views/{id} — partial update
// DELETE /api/user-views/{id} — delete
// POST /api/user-views/{id}/touch — bump last_used_at
//
// POST /api/views/run — run an ad-hoc spec
// POST /api/views/{slug}/run — run a saved view by slug
// GET /api/views/system — list system view definitions
//
// All endpoints require authentication. Paliad's RLS scopes user_views
// rows to auth.uid(); the handler layer also AND-joins userID for
// defense-in-depth.
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// requireUserViews returns true when the user-view + substrate services
// are wired. Calls writeJSON 503 + returns false otherwise.
func requireUserViews(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.userView == nil || dbSvc.event == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "views not configured",
})
return false
}
return true
}
// ============================================================================
// /api/user-views — CRUD
// ============================================================================
// GET /api/user-views — list the caller's saved views.
func handleListUserViews(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
views, err := dbSvc.userView.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, views)
}
// userViewCreatePayload mirrors services.CreateUserViewInput on the wire.
type userViewCreatePayload struct {
Slug string `json:"slug"`
Name string `json:"name"`
Icon *string `json:"icon,omitempty"`
FilterSpec services.FilterSpec `json:"filter_spec"`
RenderSpec services.RenderSpec `json:"render_spec"`
ShowCount bool `json:"show_count,omitempty"`
}
// POST /api/user-views — create.
func handleCreateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p userViewCreatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
created, err := dbSvc.userView.Create(r.Context(), uid, services.CreateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// GET /api/user-views/{id} — fetch one.
func handleGetUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
view, err := dbSvc.userView.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, view)
}
// userViewUpdatePayload accepts every field as optional. `null` icon
// clears the field (matching service-side semantic of *string{""} → clear).
type userViewUpdatePayload struct {
Slug *string `json:"slug,omitempty"`
Name *string `json:"name,omitempty"`
Icon *string `json:"icon,omitempty"`
FilterSpec *services.FilterSpec `json:"filter_spec,omitempty"`
RenderSpec *services.RenderSpec `json:"render_spec,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
ShowCount *bool `json:"show_count,omitempty"`
}
// PATCH /api/user-views/{id} — partial update.
func handleUpdateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var p userViewUpdatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
updated, err := dbSvc.userView.Update(r.Context(), uid, id, services.UpdateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
SortOrder: p.SortOrder,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
if updated == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, updated)
}
// DELETE /api/user-views/{id} — delete.
func handleDeleteUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
deleted, err := dbSvc.userView.Delete(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if !deleted {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/user-views/{id}/touch — bump last_used_at. Fire-and-forget
// from the page handler (Q10 most-recently-used landing).
func handleTouchUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.userView.Touch(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// /api/views — substrate execution
// ============================================================================
// runRequest wraps the optional spec override for /api/views/{slug}/run.
// When body is empty / fields are zero-valued, the saved spec is used as-is.
type runRequest struct {
Filter *services.FilterSpec `json:"filter,omitempty"`
Render *services.RenderSpec `json:"render,omitempty"` // currently informational; substrate ignores
}
// POST /api/views/run — execute an ad-hoc FilterSpec without persisting.
//
// Used by the editor's live-preview (Q27) and by the inbox/agenda
// system pages internally (Phase B will route them here; Phase A1
// leaves the wiring as a no-op for those pages).
func handleRunAdhocView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p runRequest
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if p.Filter == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filter is required"})
return
}
if err := p.Filter.Validate(); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, *p.Filter, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/views/{slug}/run — run a saved view (or a system view by slug).
//
// Optional body: { filter: <override> } overrides the saved spec for
// this run only (transient — doesn't mutate the stored row). Used for
// query-param overrides in the URL contract (Q16).
func handleRunSavedView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if slug == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug is required"})
return
}
// System view first — code-resident; doesn't need DB read.
if sys := lookupSystemView(slug); sys != nil {
spec := sys.Filter
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
return
}
// User view.
view, err := dbSvc.userView.GetBySlug(r.Context(), uid, slug)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "view not found"})
return
}
spec, err := services.UnmarshalFilterSpec(view.FilterSpec)
if err != nil {
writeServiceError(w, err)
return
}
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// GET /api/views/system — list system view definitions. Used by the
// editor to seed "start from a system view as a template".
func handleListSystemViews(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
writeJSON(w, http.StatusOK, services.AllSystemViews())
}
// ============================================================================
// helpers
// ============================================================================
// lookupSystemView returns a SystemView whose slug matches, or nil.
func lookupSystemView(slug string) *services.SystemView {
for _, sv := range services.AllSystemViews() {
if sv.Slug == slug {
view := sv
return &view
}
}
return nil
}
// maybeOverrideSpec replaces `spec` with body.filter when the request
// body parses as a runRequest with a non-nil Filter. Empty body / no
// override → no-op. The override is validated.
func maybeOverrideSpec(spec *services.FilterSpec, body io.Reader) error {
var p runRequest
dec := json.NewDecoder(body)
if err := dec.Decode(&p); err != nil {
// Empty body is fine — no override.
return nil
}
if p.Filter == nil {
return nil
}
if err := p.Filter.Validate(); err != nil {
return err
}
*spec = *p.Filter
return nil
}
// writeUserViewError adds slug-taken handling on top of writeServiceError.
func writeUserViewError(w http.ResponseWriter, err error) {
if err == nil {
return
}
if errors.Is(err, services.ErrUserViewSlugTaken) {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
}

View File

@@ -0,0 +1,61 @@
package handlers
// Page handlers for the Custom Views shell (t-paliad-144 Phase A2).
//
// Three URLs:
// GET /views — landing; redirects to most-recently-used
// saved view, or shows the empty/onboarding
// card.
// GET /views/{slug} — render a saved or system view.
// GET /views/new — view editor (blank slate).
// GET /views/{slug}/edit — view editor (edit existing).
//
// Each route serves the static dist HTML; the client bundle (views.ts /
// views-editor.ts) hydrates via /api/* on load.
import (
"net/http"
)
// GET /views — landing.
//
// Behaviour matches design Q10 most-recently-used:
// - If the caller has a saved view with last_used_at set → 302 to it.
// - Otherwise serve the onboarding shell (the views.html dist file
// handles the empty state in JS).
func handleViewsLandingPage(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.userView != nil {
mr, err := dbSvc.userView.MostRecent(r.Context(), uid)
if err == nil && mr != nil {
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
return
}
}
http.ServeFile(w, r, "dist/views.html")
}
// GET /views/{slug} — saved or system view shell.
//
// The handler doesn't validate the slug here — the client bundle calls
// POST /api/views/{slug}/run and lets the API surface the 404 with a
// proper empty-state. This keeps the page surface trivially cacheable.
func handleViewsShellPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views.html")
}
// GET /views/new — editor with a blank slate.
func handleViewsNewPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views-editor.html")
}
// GET /views/{slug}/edit — editor for an existing saved view.
func handleViewsEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views-editor.html")
}

View File

@@ -0,0 +1,393 @@
package services
// FilterSpec is the structured filter description that drives the substrate
// (ViewService.RunSpec). Same shape lives in paliad.user_views.filter_spec
// (jsonb) and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 Q2.
//
// Validation rules live alongside the type. Every public mutation path
// (POST/PUT /api/user-views, /api/views/{slug}/run with override params)
// runs FilterSpec.Validate() before touching the DB or executing a query.
import (
"encoding/json"
"fmt"
"slices"
"time"
"github.com/google/uuid"
)
// DataSource identifies one of the substrate's source rails.
type DataSource string
const (
SourceDeadline DataSource = "deadline"
SourceAppointment DataSource = "appointment"
SourceProjectEvent DataSource = "project_event"
SourceApprovalRequest DataSource = "approval_request"
)
// AllSources lists every supported source. Used by the validator and by
// the "all sources" UI affordance.
var AllSources = []DataSource{
SourceDeadline,
SourceAppointment,
SourceProjectEvent,
SourceApprovalRequest,
}
// SpecVersion is the on-the-wire schema version. Bumped when the shape
// changes incompatibly. Validator rejects unknown versions so we get a
// clear error instead of silent misinterpretation.
const SpecVersion = 1
// FilterSpec is the top-level filter description.
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
// time:
// - "all_visible" (default): no extra narrowing; RLS bounds to projects
// the caller can see (direct + descendant + derived per t-paliad-139).
// - "my_subtree": narrows to the caller's direct/descendant/derived
// staffing tree only. Equivalent to "all_visible" today (visibility
// == subtree); reserved for future when global_admin or shared
// visibility models add a wider tier.
// - explicit slice: exact project IDs. RLS still applies — IDs the
// caller can't see contribute zero rows (Q17 fail-open).
//
// PersonalOnly narrows deadline + appointment to caller-created rows.
// Mutually exclusive with explicit Projects (validator enforces). When
// PersonalOnly is true, project_event + approval_request sources still
// run with the standard visibility predicate — the "personal" framing
// only meaningfully applies to entity rows.
type ScopeSpec struct {
Projects ScopeProjects `json:"projects"`
PersonalOnly bool `json:"personal_only,omitempty"`
}
// ScopeProjects is the project-narrowing payload. Either a sentinel string
// ("all_visible" / "my_subtree") or an explicit []uuid.UUID.
//
// Encoded as a tagged shape on the wire so we don't have to teach a custom
// JSON unmarshaller a polymorphic field:
//
// {"mode": "all_visible"}
// {"mode": "my_subtree"}
// {"mode": "explicit", "ids": ["uuid", …]}
type ScopeProjects struct {
Mode ScopeMode `json:"mode"`
IDs []uuid.UUID `json:"ids,omitempty"`
}
type ScopeMode string
const (
ScopeAllVisible ScopeMode = "all_visible"
ScopeMySubtree ScopeMode = "my_subtree"
ScopeExplicit ScopeMode = "explicit"
)
// TimeSpec is the time-window narrowing. Horizon picks a relative window;
// when Horizon == "custom", From/To are honoured as an absolute window.
//
// Field selects which date column to filter against per source:
// - "auto" (default): each source picks — deadline → due_date,
// appointment → start_at, project_event → created_at,
// approval_request → requested_at.
// - "created_at": every source uses its own created_at column. Useful
// for "newly added events" views.
type TimeSpec struct {
Horizon TimeHorizon `json:"horizon"`
Field TimeField `json:"field,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
}
type TimeHorizon string
const (
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
)
type TimeField string
const (
FieldAuto TimeField = "auto"
FieldCreatedAt TimeField = "created_at"
)
// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all".
type Predicates struct {
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
ProjectEvent *ProjectEventPredicates `json:"project_event,omitempty"`
ApprovalRequest *ApprovalRequestPredicates `json:"approval_request,omitempty"`
}
// DeadlinePredicates narrows the deadline rail.
type DeadlinePredicates struct {
Status []string `json:"status,omitempty"` // "pending" | "completed"
ApprovalStatus []string `json:"approval_status,omitempty"` // "approved" | "pending" | "legacy"
EventTypeIDs []uuid.UUID `json:"event_types,omitempty"`
IncludeUntyped bool `json:"include_untyped,omitempty"`
}
// AppointmentPredicates narrows the appointment rail.
type AppointmentPredicates struct {
ApprovalStatus []string `json:"approval_status,omitempty"`
AppointmentTypes []string `json:"appointment_types,omitempty"` // hearing/meeting/consultation/deadline_hearing
}
// ProjectEventPredicates narrows the audit/Verlauf rail. EventTypes is the
// curated list of project-event kinds (project_created, status_changed,
// deadline_created, …) — see KnownProjectEventKinds.
type ProjectEventPredicates struct {
EventTypes []string `json:"event_types,omitempty"`
}
// ApprovalRequestPredicates narrows the approval-inbox rail.
//
// ViewerRole shapes the per-row visibility:
// - "approver_eligible": rows where the caller is qualified to approve
// (the t-paliad-138 inbox path, extended by t-paliad-139 derivation).
// - "self_requested": rows the caller submitted.
// - "any_visible": every approval_request the caller can see via RLS
// (includes both of the above plus already-decided rows on visible
// projects). Used for retrospective/audit views.
type ApprovalRequestPredicates struct {
ViewerRole string `json:"viewer_role,omitempty"`
Status []string `json:"status,omitempty"` // "pending" | "approved" | "rejected" | "revoked"
EntityTypes []string `json:"entity_types,omitempty"` // "deadline" | "appointment"
}
// KnownProjectEventKinds is the curated set of project_event.event_type
// values the substrate exposes via the filter UI. Code-resident per
// design Q19.
//
// Mirrors the strings emitted by insertProjectEvent / insertProjectEventWithMeta
// in internal/services/*. New kinds appear here as they're added in code.
var KnownProjectEventKinds = []string{
"project_created",
"project_archived",
"project_reparented",
"project_type_changed",
"status_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
}
// validApprovalStatuses are the legal values for entity-side approval_status
// filters and request-side status filters respectively.
var (
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
validApprovalEntityTypes = []string{"deadline", "appointment"}
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
validDeadlineStatuses = []string{"pending", "completed"}
validAppointmentTypes = []string{"hearing", "meeting", "consultation", "deadline_hearing"}
)
// Validate runs the full FilterSpec contract. Returns a wrapped
// ErrInvalidInput on the first violation.
//
// The validator is the single gate that protects the DB from malformed
// jsonb. Both POST /api/user-views and runtime-override params on
// GET /api/views/{slug}/run go through it.
func (s *FilterSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: filter_spec is required", ErrInvalidInput)
}
if s.Version != SpecVersion {
return fmt.Errorf("%w: filter_spec version %d not supported (expected %d)", ErrInvalidInput, s.Version, SpecVersion)
}
if len(s.Sources) == 0 {
return fmt.Errorf("%w: at least one source must be selected", ErrInvalidInput)
}
seen := make(map[DataSource]bool, len(s.Sources))
for _, src := range s.Sources {
if !isKnownSource(src) {
return fmt.Errorf("%w: unknown source %q", ErrInvalidInput, src)
}
if seen[src] {
return fmt.Errorf("%w: duplicate source %q", ErrInvalidInput, src)
}
seen[src] = true
}
if err := s.Scope.validate(); err != nil {
return err
}
if err := s.Time.validate(s.Scope); err != nil {
return err
}
for src, preds := range s.Predicates {
if !isKnownSource(src) {
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
}
if !seen[src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
}
if err := preds.validate(); err != nil {
return err
}
}
return nil
}
func (s *ScopeSpec) validate() error {
switch s.Projects.Mode {
case ScopeAllVisible, ScopeMySubtree:
if len(s.Projects.IDs) > 0 {
return fmt.Errorf("%w: scope.projects.ids must be empty when mode=%q", ErrInvalidInput, s.Projects.Mode)
}
case ScopeExplicit:
if len(s.Projects.IDs) == 0 {
return fmt.Errorf("%w: scope.projects.ids must be non-empty when mode=%q", ErrInvalidInput, ScopeExplicit)
}
if s.PersonalOnly {
return fmt.Errorf("%w: scope.personal_only cannot be combined with explicit projects", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown scope.projects.mode %q", ErrInvalidInput, s.Projects.Mode)
}
return nil
}
func (t *TimeSpec) validate(scope ScopeSpec) error {
switch t.Horizon {
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
HorizonPast30d, HorizonPast90d, HorizonAny:
// fine
case HorizonAll:
// Q26: reject "all" unless scope.projects is explicit. Performance
// safeguard — an unbounded substrate query across every visible
// project is the worst case and we want it gated by intent.
if scope.Projects.Mode != ScopeExplicit {
return fmt.Errorf("%w: time.horizon=%q requires scope.projects.mode=%q", ErrInvalidInput, HorizonAll, ScopeExplicit)
}
case HorizonCustom:
if t.From == nil || t.To == nil {
return fmt.Errorf("%w: time.horizon=%q requires both from and to", ErrInvalidInput, HorizonCustom)
}
if !t.To.After(*t.From) {
return fmt.Errorf("%w: time.to must be strictly after time.from", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown time.horizon %q", ErrInvalidInput, t.Horizon)
}
switch t.Field {
case "", FieldAuto, FieldCreatedAt:
// fine
default:
return fmt.Errorf("%w: unknown time.field %q", ErrInvalidInput, t.Field)
}
return nil
}
func (p *Predicates) validate() error {
if p.Deadline != nil {
if err := validateStringEnum("deadline.status", p.Deadline.Status, validDeadlineStatuses); err != nil {
return err
}
if err := validateStringEnum("deadline.approval_status", p.Deadline.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
}
if p.Appointment != nil {
if err := validateStringEnum("appointment.approval_status", p.Appointment.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
if err := validateStringEnum("appointment.appointment_types", p.Appointment.AppointmentTypes, validAppointmentTypes); err != nil {
return err
}
}
if p.ProjectEvent != nil {
if err := validateStringEnum("project_event.event_types", p.ProjectEvent.EventTypes, KnownProjectEventKinds); err != nil {
return err
}
}
if p.ApprovalRequest != nil {
if r := p.ApprovalRequest.ViewerRole; r != "" {
if err := validateStringEnum("approval_request.viewer_role", []string{r}, validApprovalViewerRoles); err != nil {
return err
}
}
if err := validateStringEnum("approval_request.status", p.ApprovalRequest.Status, validRequestStatuses); err != nil {
return err
}
if err := validateStringEnum("approval_request.entity_types", p.ApprovalRequest.EntityTypes, validApprovalEntityTypes); err != nil {
return err
}
}
return nil
}
func validateStringEnum(field string, values, allowed []string) error {
for _, v := range values {
if !slices.Contains(allowed, v) {
return fmt.Errorf("%w: unknown %s value %q", ErrInvalidInput, field, v)
}
}
return nil
}
func isKnownSource(s DataSource) bool {
return slices.Contains(AllSources, s)
}
// DefaultFilterSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultFilterSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
// MarshalFilterSpec is a convenience wrapper used by handlers when storing
// the spec into the jsonb column. Pre-validates so a malformed spec never
// reaches the DB.
func MarshalFilterSpec(s FilterSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalFilterSpec parses + validates a stored / submitted spec.
func UnmarshalFilterSpec(b []byte) (FilterSpec, error) {
var s FilterSpec
if err := json.Unmarshal(b, &s); err != nil {
return FilterSpec{}, fmt.Errorf("%w: filter_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return FilterSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,263 @@
package services
// Pure-Go tests for FilterSpec — no DB touch. Cover happy path, every
// reject branch, and the cross-field constraints (Q26 horizon-clamp,
// scope mode/IDs invariants).
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
func validBaseSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
func TestFilterSpec_HappyPath(t *testing.T) {
s := validBaseSpec()
if err := s.Validate(); err != nil {
t.Fatalf("base spec should validate: %v", err)
}
}
func TestFilterSpec_DefaultIsValid(t *testing.T) {
s := DefaultFilterSpec()
if err := s.Validate(); err != nil {
t.Fatalf("DefaultFilterSpec must validate: %v", err)
}
}
func TestFilterSpec_Version(t *testing.T) {
s := validBaseSpec()
s.Version = 2
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown version must reject with ErrInvalidInput, got %v", err)
}
}
func TestFilterSpec_NoSources(t *testing.T) {
s := validBaseSpec()
s.Sources = nil
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty sources must reject, got %v", err)
}
}
func TestFilterSpec_UnknownSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{"bogus"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown source must reject, got %v", err)
}
}
func TestFilterSpec_DuplicateSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceDeadline}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("duplicate source must reject, got %v", err)
}
}
func TestFilterSpec_AllSourcesAccepted(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceAppointment, SourceProjectEvent, SourceApprovalRequest}
if err := s.Validate(); err != nil {
t.Fatalf("all four sources together must validate: %v", err)
}
}
func TestFilterSpec_ScopeAllVisibleRejectsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects.IDs = []uuid.UUID{uuid.New()}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ids on all_visible mode must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitNeedsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("explicit mode without ids must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitWithIDsValidates(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
if err := s.Validate(); err != nil {
t.Fatalf("explicit mode + ids must validate: %v", err)
}
}
func TestFilterSpec_PersonalOnlyConflictsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Scope.PersonalOnly = true
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("personal_only + explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllRejectsWithoutExplicit(t *testing.T) {
// Q26 lock-in: horizon=all is rejected unless scope.projects.mode=explicit.
s := validBaseSpec()
s.Time.Horizon = HorizonAll
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("horizon=all without explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllAcceptsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Time.Horizon = HorizonAll
if err := s.Validate(); err != nil {
t.Fatalf("horizon=all with explicit projects must validate: %v", err)
}
}
func TestFilterSpec_HorizonCustomNeedsBothBounds(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("custom horizon without bounds must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomRejectsInvertedRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
earlier := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// to before from
s.Time.From = &later
s.Time.To = &earlier
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("inverted from/to must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s.Time.From = &from
s.Time.To = &to
if err := s.Validate(); err != nil {
t.Fatalf("valid custom range must accept: %v", err)
}
}
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("predicates on unselected source must reject, got %v", err)
}
}
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Predicates = map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown deadline.status must reject, got %v", err)
}
}
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = append(s.Sources, SourceAppointment)
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown appointment_type must reject, got %v", err)
}
}
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceProjectEvent}
s.Predicates = map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown project_event kind must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown viewer_role must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown approval_request.status must reject, got %v", err)
}
}
func TestFilterSpec_RoundTripJSON(t *testing.T) {
original := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceApprovalRequest},
Scope: ScopeSpec{
Projects: ScopeProjects{Mode: ScopeAllVisible},
PersonalOnly: false,
},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{
Status: []string{"pending"},
ApprovalStatus: []string{"approved", "pending"},
}},
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
}
b, err := MarshalFilterSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalFilterSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Version != original.Version {
t.Errorf("version mismatch: %d vs %d", parsed.Version, original.Version)
}
if len(parsed.Sources) != len(original.Sources) {
t.Errorf("sources mismatch: %v vs %v", parsed.Sources, original.Sources)
}
}

View File

@@ -0,0 +1,207 @@
package services
// RenderSpec is the structured render description that drives how a view
// is presented. Same shape lives in paliad.user_views.render_spec (jsonb)
// and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §4 (Q4-Q5).
//
// Q4 lock-in (m, 2026-05-07): three shapes — list, cards, calendar.
// "Activity" is content selection (sources + filters), not visualisation —
// achieved via shape="list", list.density="compact", list.columns=
// ["time","actor","title","project"]. Same component, different config.
import (
"encoding/json"
"fmt"
"slices"
)
// RenderShape identifies which presentation component a view uses.
type RenderShape string
const (
ShapeList RenderShape = "list"
ShapeCards RenderShape = "cards"
ShapeCalendar RenderShape = "calendar"
)
// AllShapes lists every supported shape. Used by the validator and by
// the in-page shape switcher.
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
// RenderSpec is the top-level render description.
//
// Per-shape config blocks are kept on save even when a different shape
// is selected, so flipping back to a previously-used shape preserves
// its tweaks (Q5 design decision).
type RenderSpec struct {
Shape RenderShape `json:"shape"`
List *ListConfig `json:"list,omitempty"`
Cards *CardsConfig `json:"cards,omitempty"`
Calendar *CalendarConfig `json:"calendar,omitempty"`
}
// ListConfig is the per-shape config for shape=list. Powers both the
// /events table look (density=comfortable) and the activity-feed look
// (density=compact + actor/time columns).
type ListConfig struct {
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
type CardsConfig struct {
GroupBy CardsGroupBy `json:"group_by,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
ShowEmptyDays bool `json:"show_empty_days,omitempty"`
}
// CalendarConfig is the per-shape config for shape=calendar.
type CalendarConfig struct {
DefaultView CalendarView `json:"default_view,omitempty"`
ShowWeekends bool `json:"show_weekends,omitempty"`
}
type SortOrder string
const (
SortDateAsc SortOrder = "date_asc"
SortDateDesc SortOrder = "date_desc"
)
type ListDensity string
const (
DensityComfortable ListDensity = "comfortable"
DensityCompact ListDensity = "compact"
)
type CardsGroupBy string
const (
CardsGroupByDay CardsGroupBy = "day"
CardsGroupByWeek CardsGroupBy = "week"
CardsGroupByNone CardsGroupBy = "none"
)
type CalendarView string
const (
CalendarMonth CalendarView = "month"
CalendarWeek CalendarView = "week"
)
// KnownListColumns is the curated set of column keys the list-shape
// renderer understands. Validator rejects anything else so a typo in
// the editor surfaces immediately.
var KnownListColumns = []string{
"date", "time", "title", "project", "actor", "status",
"rule", "event_type", "location", "appointment_type",
"approval_status", "decided_by", "kind",
}
// Validate runs the full RenderSpec contract.
func (s *RenderSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
}
switch s.Shape {
case ShapeList, ShapeCards, ShapeCalendar:
// fine
default:
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
}
if s.List != nil {
if err := s.List.validate(); err != nil {
return err
}
}
if s.Cards != nil {
if err := s.Cards.validate(); err != nil {
return err
}
}
if s.Calendar != nil {
if err := s.Calendar.validate(); err != nil {
return err
}
}
return nil
}
func (c *ListConfig) validate() error {
for _, col := range c.Columns {
if !slices.Contains(KnownListColumns, col) {
return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col)
}
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort)
}
switch c.Density {
case "", DensityComfortable, DensityCompact:
default:
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
}
return nil
}
func (c *CardsConfig) validate() error {
switch c.GroupBy {
case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone:
default:
return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy)
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort)
}
return nil
}
func (c *CalendarConfig) validate() error {
switch c.DefaultView {
case "", CalendarMonth, CalendarWeek:
default:
return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView)
}
return nil
}
// DefaultRenderSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultRenderSpec() RenderSpec {
return RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Sort: SortDateAsc,
Density: DensityComfortable,
},
}
}
// MarshalRenderSpec validates + encodes for storage.
func MarshalRenderSpec(s RenderSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalRenderSpec parses + validates.
func UnmarshalRenderSpec(b []byte) (RenderSpec, error) {
var s RenderSpec
if err := json.Unmarshal(b, &s); err != nil {
return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return RenderSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,103 @@
package services
// Pure-Go tests for RenderSpec.
import (
"errors"
"testing"
)
func TestRenderSpec_HappyPath(t *testing.T) {
s := DefaultRenderSpec()
if err := s.Validate(); err != nil {
t.Fatalf("default render spec must validate: %v", err)
}
}
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
for _, sh := range cases {
t.Run(string(sh), func(t *testing.T) {
s := RenderSpec{Shape: sh}
if err := s.Validate(); err != nil {
t.Fatalf("shape %q must validate: %v", sh, err)
}
})
}
}
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
s := RenderSpec{Shape: "kanban"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown shape must reject, got %v", err)
}
}
func TestRenderSpec_ListColumnEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown list column must reject, got %v", err)
}
}
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
if err := s.Validate(); err != nil {
t.Fatalf("known columns must validate: %v", err)
}
}
func TestRenderSpec_ListSortEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown sort must reject, got %v", err)
}
}
func TestRenderSpec_ListDensityEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown density must reject, got %v", err)
}
}
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown group_by must reject, got %v", err)
}
}
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown default_view must reject, got %v", err)
}
}
func TestRenderSpec_RoundTrip(t *testing.T) {
original := RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Columns: []string{"time", "actor", "title", "project"},
Sort: SortDateDesc,
Density: DensityCompact,
},
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
}
b, err := MarshalRenderSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalRenderSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Shape != original.Shape {
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
}
if parsed.List == nil || parsed.List.Density != DensityCompact {
t.Errorf("list config not preserved: %+v", parsed.List)
}
}

View File

@@ -0,0 +1,208 @@
package services
// SystemView is a code-resident view definition. The four system pages
// (dashboard / agenda / events / inbox) resolve to one of these when
// they want to consume the substrate as if they were a Custom View.
//
// Design: docs/design-data-display-model-2026-05-06.md §5 Q8.
//
// Q8 lock-in: defaults are config-as-code, not seeded rows in
// paliad.user_views. Their slugs are reserved (validator rejects
// matching user-view slugs).
import (
"slices"
)
// SystemView is the in-process projection used by the substrate's
// SystemView callers. It mirrors the persisted user-view shape but
// never round-trips through the DB.
type SystemView struct {
Slug string // matches the system-page URL ("/dashboard" → "dashboard")
Name string // display label (kept English here; UI re-translates via i18n)
Filter FilterSpec // canonical filter the page resolves to today
Render RenderSpec // canonical render shape
}
// DashboardSystemView returns the SystemView definition for /dashboard.
//
// Note: /dashboard is composed of multiple sections (5-bucket summary +
// matter card + two-column lists + activity feed). It does NOT resolve
// to a single FilterSpec/RenderSpec — Phase B will compose several
// SystemView resolutions into the dashboard page. This entry exists so
// the slug is known to the reserved-list and so future composition has
// a stable hook.
func DashboardSystemView() SystemView {
return SystemView{
Slug: "dashboard",
Name: "Dashboard",
// Placeholder filter — the dashboard composes multiple queries
// in Phase B; this single spec covers the activity feed only.
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldCreatedAt},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityCompact,
Sort: SortDateDesc,
Columns: []string{"time", "actor", "title", "project"},
},
},
}
}
// AgendaSystemView returns the SystemView definition for /agenda — a
// day-grouped feed of upcoming deadlines + appointments.
func AgendaSystemView() SystemView {
return SystemView{
Slug: "agenda",
Name: "Agenda",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
},
},
Render: RenderSpec{
Shape: ShapeCards,
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
},
}
}
// EventsSystemView returns the SystemView definition for /events — the
// table view over deadlines + appointments. The legacy URL keeps a
// per-type chip toggle; this SystemView reflects the "all" tab default.
func EventsSystemView() SystemView {
return SystemView{
Slug: "events",
Name: "Events",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
Name: "Inbox (mine)",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "self_requested",
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// AllSystemViews returns every system-defined view in registration order.
// Used by the reserved-slug list and by future Phase B composition.
func AllSystemViews() []SystemView {
return []SystemView{
DashboardSystemView(),
AgendaSystemView(),
EventsSystemView(),
InboxSystemView(),
InboxRequesterSystemView(),
}
}
// reservedUserViewSlugs is the static list of slugs the user-view CRUD
// rejects on create / update. Includes the SystemView slugs plus URLs
// the application owns at the top level (admin, settings, login, …).
//
// Q23 lock-in (m, 2026-05-07): list as drafted.
var reservedUserViewSlugs = []string{
// SystemView slugs:
"dashboard", "agenda", "events", "inbox", "inbox-mine",
// /views/* routes:
"new", "edit",
// Top-level application URLs:
"tools", "admin", "settings", "login", "logout",
"projects", "team", "courts", "glossary", "links",
"downloads", "checklists", "views", "changelog",
}
// IsReservedUserViewSlug returns true if `slug` matches a reserved slug.
// User-view CRUD rejects matches with ErrInvalidInput. Case-folded so
// "Dashboard" is also rejected.
func IsReservedUserViewSlug(slug string) bool {
return slices.Contains(reservedUserViewSlugs, foldSlug(slug))
}
// foldSlug normalises a slug for reserved-list comparison. Slugs are
// already lowercased + dash-only by the validator before this is called,
// but this lets IsReservedUserViewSlug be safe under direct calls.
func foldSlug(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
out = append(out, c+('a'-'A'))
default:
out = append(out, c)
}
}
return string(out)
}

View File

@@ -0,0 +1,47 @@
package services
import "testing"
// Pure-Go tests for the SystemView registry. Each system view's specs
// must self-validate; the slugs must be reserved.
func TestSystemViews_AllValidate(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if err := sv.Filter.Validate(); err != nil {
t.Errorf("%s filter spec invalid: %v", sv.Slug, err)
}
if err := sv.Render.Validate(); err != nil {
t.Errorf("%s render spec invalid: %v", sv.Slug, err)
}
})
}
}
func TestSystemViews_SlugsReserved(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if !IsReservedUserViewSlug(sv.Slug) {
t.Errorf("system slug %q must be reserved against user_views", sv.Slug)
}
})
}
}
func TestReservedSlugs_CaseFolded(t *testing.T) {
if !IsReservedUserViewSlug("Dashboard") {
t.Error("reserved-slug check must be case-insensitive")
}
if !IsReservedUserViewSlug("INBOX") {
t.Error("reserved-slug check must be case-insensitive")
}
}
func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
cases := []string{"freitag-stand", "approval-pending-mine", "siemens", "my-view"}
for _, slug := range cases {
if IsReservedUserViewSlug(slug) {
t.Errorf("user-friendly slug %q must not be reserved", slug)
}
}
}

View File

@@ -0,0 +1,377 @@
package services
// UserViewService is the CRUD layer for paliad.user_views — saved Custom
// View definitions per user.
//
// Design: docs/design-data-display-model-2026-05-06.md §5.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_views_owner_all` on auth.uid() = user_id. The service
// also AND-joins user_id in the SQL for defense-in-depth (RLS can be
// disabled in tests, the code-level check still holds).
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// UserView is the persisted shape of a saved Custom View.
type UserView struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Slug string `db:"slug" json:"slug"`
Name string `db:"name" json:"name"`
Icon *string `db:"icon" json:"icon,omitempty"`
FilterSpec json.RawMessage `db:"filter_spec" json:"filter_spec"`
RenderSpec json.RawMessage `db:"render_spec" json:"render_spec"`
SortOrder int `db:"sort_order" json:"sort_order"`
ShowCount bool `db:"show_count" json:"show_count"`
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// UserViewService manages paliad.user_views.
type UserViewService struct {
db *sqlx.DB
}
// NewUserViewService wires the service to the pool.
func NewUserViewService(db *sqlx.DB) *UserViewService {
return &UserViewService{db: db}
}
// CreateUserViewInput is the payload for Create.
type CreateUserViewInput struct {
Slug string
Name string
Icon *string
FilterSpec FilterSpec
RenderSpec RenderSpec
ShowCount bool
// SortOrder is server-assigned (MAX+1) on create — callers cannot set it.
}
// UpdateUserViewInput is the partial-update payload. All fields are
// optional; nil means "no change".
type UpdateUserViewInput struct {
Slug *string
Name *string
Icon *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear"
FilterSpec *FilterSpec
RenderSpec *RenderSpec
SortOrder *int
ShowCount *bool
}
// slugRE caps slugs to URL-safe lowercase. Same shape as the migration
// comment promises (^[a-z0-9][a-z0-9-]{0,62}$).
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// ErrUserViewSlugTaken signals "slug already exists for this user". The
// HTTP layer maps this to 409.
var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user")
// ListForUser returns the caller's saved views, ordered by sort_order ASC
// then name. Result is the same shape /api/user-views returns to the
// frontend on app load (sidebar hydration).
func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) {
var rows []UserView
err := s.db.SelectContext(ctx, &rows, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
ORDER BY sort_order ASC, name ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list user_views: %w", err)
}
return rows, nil
}
// GetBySlug fetches one view by slug. Returns (nil, nil) when the slug
// is unknown for this user.
func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND slug = $2`, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// GetByID fetches one view by id. Same nil-on-miss semantic.
func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// Create persists a new view for the caller. Server-assigns sort_order
// to MAX(existing)+1 inside the same tx so two parallel creates don't
// collide.
func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) {
if err := validateCreateInput(input); err != nil {
return nil, err
}
filterJSON, err := MarshalFilterSpec(input.FilterSpec)
if err != nil {
return nil, err
}
renderJSON, err := MarshalRenderSpec(input.RenderSpec)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var nextSortOrder int
if err := tx.GetContext(ctx, &nextSortOrder, `
SELECT COALESCE(MAX(sort_order), -1) + 1
FROM paliad.user_views
WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("compute next sort_order: %w", err)
}
var v UserView
err = tx.GetContext(ctx, &v, `
INSERT INTO paliad.user_views
(user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, input.Slug, input.Name, input.Icon,
filterJSON, renderJSON, nextSortOrder, input.ShowCount)
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug)
}
return nil, fmt.Errorf("insert user_view: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit user_view create: %w", err)
}
return &v, nil
}
// Update applies a partial update to an existing view. Returns
// (nil, nil) if the row doesn't exist for this user.
func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if current == nil {
return nil, nil
}
// Coalesce input over current.
slug := current.Slug
if input.Slug != nil {
slug = *input.Slug
}
if err := validateSlug(slug); err != nil {
return nil, err
}
name := current.Name
if input.Name != nil {
name = *input.Name
}
if err := validateName(name); err != nil {
return nil, err
}
icon := current.Icon
if input.Icon != nil {
s := *input.Icon
if s == "" {
icon = nil
} else {
icon = &s
}
}
filterJSON := []byte(current.FilterSpec)
if input.FilterSpec != nil {
b, err := MarshalFilterSpec(*input.FilterSpec)
if err != nil {
return nil, err
}
filterJSON = b
}
renderJSON := []byte(current.RenderSpec)
if input.RenderSpec != nil {
b, err := MarshalRenderSpec(*input.RenderSpec)
if err != nil {
return nil, err
}
renderJSON = b
}
sortOrder := current.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
showCount := current.ShowCount
if input.ShowCount != nil {
showCount = *input.ShowCount
}
var v UserView
err = s.db.GetContext(ctx, &v, `
UPDATE paliad.user_views
SET slug = $3, name = $4, icon = $5,
filter_spec = $6, render_spec = $7,
sort_order = $8, show_count = $9,
updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug)
}
return nil, fmt.Errorf("update user_view: %w", err)
}
return &v, nil
}
// Delete removes a saved view. Single Yes/No modal on the frontend
// (Q25 lock-in); no audit emit (these are personal working state).
// Returns (false, nil) when the row didn't exist.
func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) {
res, err := s.db.ExecContext(ctx, `
DELETE FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return false, fmt.Errorf("delete user_view: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("delete user_view rows affected: %w", err)
}
return n > 0, nil
}
// Touch updates last_used_at to now. Fire-and-forget from the page
// handler — no error surface to the user.
func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
UPDATE paliad.user_views
SET last_used_at = now()
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return fmt.Errorf("touch user_view: %w", err)
}
return nil
}
// MostRecent returns the caller's most-recently-used view, or nil if
// the user has none / has never opened one. Used for the /views landing
// (Q10 most-recently-used default).
func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
AND last_used_at IS NOT NULL
ORDER BY last_used_at DESC
LIMIT 1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("most-recent user_view: %w", err)
}
return &v, nil
}
// ============================================================================
// Validators (slug + name + create input)
// ============================================================================
func validateSlug(slug string) error {
if slug == "" {
return fmt.Errorf("%w: slug is required", ErrInvalidInput)
}
if !slugRE.MatchString(slug) {
return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug)
}
if IsReservedUserViewSlug(slug) {
return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug)
}
return nil
}
func validateName(name string) error {
if name == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
// 1-character names are fine (some users may want 1-letter shortcuts).
// 200 is the codebase-wide cap (matches Notes / Checklists).
if len(name) > 200 {
return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
return nil
}
func validateCreateInput(input CreateUserViewInput) error {
if err := validateSlug(input.Slug); err != nil {
return err
}
if err := validateName(input.Name); err != nil {
return err
}
if input.Icon != nil && len(*input.Icon) > 64 {
return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput)
}
if err := input.FilterSpec.Validate(); err != nil {
return err
}
if err := input.RenderSpec.Validate(); err != nil {
return err
}
return nil
}
// isUniqueViolation is shared with event_type_service.go (defined there).

View File

@@ -0,0 +1,324 @@
package services
// Live-DB tests for UserViewService. Skipped when TEST_DATABASE_URL is
// unset, mirroring the rest of the live-DB test suite.
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type userViewTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *UserViewService
userID uuid.UUID
cleanup func()
}
func setupUserViewTest(t *testing.T) *userViewTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'View Test User', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.user_views WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &userViewTestEnv{
t: t,
pool: pool,
svc: NewUserViewService(pool),
userID: userID,
cleanup: cleanup,
}
}
func goodCreateInput(slug, name string) CreateUserViewInput {
return CreateUserViewInput{
Slug: slug,
Name: name,
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
}
}
func TestUserViewService_CreateAndList(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freitag-stand", "Freitag-Stand"))
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Slug != "freitag-stand" || created.Name != "Freitag-Stand" {
t.Errorf("created shape: %+v", created)
}
if created.SortOrder != 0 {
t.Errorf("first view sort_order = %d, want 0", created.SortOrder)
}
second, err := env.svc.Create(ctx, env.userID, goodCreateInput("siemens", "Siemens-Aktivität"))
if err != nil {
t.Fatalf("second create: %v", err)
}
if second.SortOrder != 1 {
t.Errorf("second view sort_order = %d, want 1", second.SortOrder)
}
list, err := env.svc.ListForUser(ctx, env.userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 views, got %d", len(list))
}
// sort_order ASC ordering
if list[0].Slug != "freitag-stand" || list[1].Slug != "siemens" {
t.Errorf("ordering: %v", []string{list[0].Slug, list[1].Slug})
}
}
func TestUserViewService_GetBySlugAndID(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("test-view", "Test View"))
if err != nil {
t.Fatalf("create: %v", err)
}
bySlug, err := env.svc.GetBySlug(ctx, env.userID, "test-view")
if err != nil || bySlug == nil {
t.Fatalf("GetBySlug: %v / nil", err)
}
if bySlug.ID != created.ID {
t.Errorf("GetBySlug id mismatch")
}
byID, err := env.svc.GetByID(ctx, env.userID, created.ID)
if err != nil || byID == nil {
t.Fatalf("GetByID: %v / nil", err)
}
missing, err := env.svc.GetBySlug(ctx, env.userID, "does-not-exist")
if err != nil {
t.Fatalf("GetBySlug missing: %v", err)
}
if missing != nil {
t.Error("missing slug should return nil")
}
}
func TestUserViewService_SlugUniquenessPerUser(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
if _, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "First")); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "Second"))
if !errors.Is(err, ErrUserViewSlugTaken) {
t.Fatalf("duplicate slug must return ErrUserViewSlugTaken, got %v", err)
}
}
func TestUserViewService_Update(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("orig", "Original"))
if err != nil {
t.Fatalf("create: %v", err)
}
newName := "Updated"
newShowCount := true
updated, err := env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{
Name: &newName,
ShowCount: &newShowCount,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Updated" {
t.Errorf("name not updated: %s", updated.Name)
}
if !updated.ShowCount {
t.Errorf("show_count not updated")
}
// Slug should be unchanged.
if updated.Slug != "orig" {
t.Errorf("slug should be unchanged, got %s", updated.Slug)
}
}
func TestUserViewService_UpdateRejectsReservedSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freely", "Freely"))
if err != nil {
t.Fatalf("create: %v", err)
}
reserved := "dashboard"
_, err = env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{Slug: &reserved})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("update to reserved slug must reject, got %v", err)
}
}
func TestUserViewService_Delete(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("doomed", "Doomed"))
if err != nil {
t.Fatalf("create: %v", err)
}
deleted, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil || !deleted {
t.Fatalf("delete: %v, %v", deleted, err)
}
deletedAgain, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil {
t.Fatalf("second delete: %v", err)
}
if deletedAgain {
t.Error("second delete should report not-deleted")
}
}
func TestUserViewService_TouchAndMostRecent(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
a, _ := env.svc.Create(ctx, env.userID, goodCreateInput("a-view", "A"))
b, _ := env.svc.Create(ctx, env.userID, goodCreateInput("b-view", "B"))
// Before any touch — MostRecent is nil.
mr, err := env.svc.MostRecent(ctx, env.userID)
if err != nil {
t.Fatalf("most_recent: %v", err)
}
if mr != nil {
t.Errorf("MostRecent should be nil before any touch")
}
if err := env.svc.Touch(ctx, env.userID, a.ID); err != nil {
t.Fatalf("touch a: %v", err)
}
if err := env.svc.Touch(ctx, env.userID, b.ID); err != nil {
t.Fatalf("touch b: %v", err)
}
mr, err = env.svc.MostRecent(ctx, env.userID)
if err != nil || mr == nil {
t.Fatalf("most_recent after touch: %v / nil", err)
}
if mr.ID != b.ID {
t.Errorf("most-recent should be b (touched last), got %s", mr.Slug)
}
}
func TestUserViewService_RejectsReservedSlugOnCreate(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("inbox", "Inbox copy"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("reserved slug on create must reject, got %v", err)
}
}
func TestUserViewService_RejectsBadSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("Has Spaces", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("slug with spaces must reject, got %v", err)
}
_, err = env.svc.Create(ctx, env.userID, goodCreateInput("UPPER", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("uppercase slug must reject, got %v", err)
}
}
func TestUserViewService_RejectsEmptyName(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "no-name",
Name: "",
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty name must reject, got %v", err)
}
}
func TestUserViewService_RejectsInvalidSpec(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
bad := DefaultFilterSpec()
bad.Sources = nil
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "bad-spec",
Name: "Bad",
FilterSpec: bad,
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid spec must reject, got %v", err)
}
}

View File

@@ -0,0 +1,719 @@
package services
// ViewService extension on EventService — runs a FilterSpec across the
// 4 substrate sources (deadline, appointment, project_event,
// approval_request) and returns a unified []ViewRow.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
//
// EventService is extended (not renamed) so the existing handlers
// (/api/events, /api/events/summary) keep working unchanged. New
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// ViewRow is the unified row shape returned by RunSpec. Discriminated by
// `Kind`; type-specific fields live under `Detail` as a per-source struct
// marshalled via json.RawMessage.
type ViewRow struct {
Kind DataSource `json:"kind"`
ID uuid.UUID `json:"id"`
Title string `json:"title"`
// Subtitle: one short context line (e.g. "Frist", "Termin",
// "Genehmigung von …"). Optional; UIs render it under the title.
Subtitle *string `json:"subtitle,omitempty"`
// EventDate is the canonical sort key per row. Source-determined:
// - deadline: due_date at 00:00 UTC
// - appointment: start_at
// - project_event: created_at
// - approval_request: requested_at (or decided_at if status decided)
EventDate time.Time `json:"event_date"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ActorID *uuid.UUID `json:"actor_id,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
// Detail is the per-source typed payload as raw JSON. Frontend
// type-narrows on Kind and parses Detail accordingly.
Detail json.RawMessage `json:"detail"`
}
// ViewRunResult is the response shape of RunSpec — rows + a count of
// projects that contributed zero rows because the caller can't see them
// (Q17 fail-open attribution).
type ViewRunResult struct {
Rows []ViewRow `json:"rows"`
InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
}
// RunSpec executes the FilterSpec against the substrate and returns
// merged rows sorted by EventDate (ascending for forward-looking,
// descending if any sort hint says so). Visibility is enforced via
// the per-source RLS predicates already used by the underlying tables;
// `userID` is the caller for context propagation.
//
// Caller has run spec.Validate() before us. We trust the spec.
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
// Approval source requires the approval service. Return a clear
// error rather than silently skipping it — handlers always pass
// the bundle's approval service.
return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
}
rows := make([]ViewRow, 0, 256)
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
for _, src := range spec.Sources {
switch src {
case SourceDeadline:
batch, err := s.runDeadlines(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceAppointment:
batch, err := s.runAppointments(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceProjectEvent:
batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceApprovalRequest:
batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
}
}
// Default sort: ascending. Per-source sort hints don't apply here —
// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
// knob. We give the substrate a stable shape; the renderer flips it.
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].EventDate.Equal(rows[j].EventDate) {
// Tiebreaker: kind alphabetical, then title — deterministic.
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
return rows[i].Title < rows[j].Title
}
return rows[i].EventDate.Before(rows[j].EventDate)
})
out := &ViewRunResult{Rows: rows}
// Q17 fail-open attribution: if the caller specified explicit
// project IDs, surface the ones they couldn't see. We do that with
// one cheap check against can_see_project (via RLS-aware visibility
// predicate), batched per call.
if spec.Scope.Projects.Mode == ScopeExplicit {
inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
if err != nil {
return nil, err
}
if len(inaccessible) > 0 {
out.InaccessibleProjectIDs = inaccessible
}
}
return out, nil
}
// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
type viewSpecBounds struct {
from *time.Time
to *time.Time
}
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
now = now.UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
switch ts.Horizon {
case HorizonNext7d:
from := day
to := day.AddDate(0, 0, 7)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext30d:
from := day
to := day.AddDate(0, 0, 30)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext90d:
from := day
to := day.AddDate(0, 0, 90)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast30d:
from := day.AddDate(0, 0, -30)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast90d:
from := day.AddDate(0, 0, -90)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonAny, HorizonAll:
return viewSpecBounds{}
case HorizonCustom:
return viewSpecBounds{from: ts.From, to: ts.To}
}
return viewSpecBounds{}
}
// runDeadlines projects DeadlineWithProject rows from the existing
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
df := ListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
df.CreatedBy = &uid
}
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
dp := preds.Deadline
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
// If the spec asks for both pending+completed → no narrowing; if
// only pending → DeadlineFilterPending; only completed → Completed.
switch {
case len(dp.Status) == 1 && dp.Status[0] == "pending":
df.Status = DeadlineFilterPending
case len(dp.Status) == 1 && dp.Status[0] == "completed":
df.Status = DeadlineFilterCompleted
default:
df.Status = DeadlineFilterAll
}
df.EventTypeIDs = dp.EventTypeIDs
df.IncludeUntyped = dp.IncludeUntyped
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
// DeadlineService takes one project id; we filter post-load when
// spec selects multiple projects (the visibility predicate already
// bounds to the caller's set, and explicit IDs are a refinement).
}
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
if !inSpecWindow(eventDate, bounds) {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Approval-status narrowing (entity-side pill).
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
continue
}
detail, _ := json.Marshal(map[string]any{
"due_date": r.DueDate.Format("2006-01-02"),
"status": r.Status,
"approval_status": r.ApprovalStatus,
"source": r.Source,
"rule_id": r.RuleID,
"rule_code": r.RuleCode,
"rule_name": r.RuleName,
"event_type_ids": r.EventTypeIDs,
"description": r.Description,
"completed_at": r.CompletedAt,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceDeadline,
ID: r.ID,
Title: r.Title,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runAppointments projects AppointmentWithProject onto ViewRow.
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
af := AppointmentListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
af.CreatedBy = &uid
}
af.From = bounds.from
af.To = bounds.to
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
ap := preds.Appointment
// AppointmentListFilter takes a single Type today; narrow to first
// listed value, fall back to all if multiple.
if len(ap.AppointmentTypes) == 1 {
t := ap.AppointmentTypes[0]
af.Type = &t
}
}
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
allowedTypes := allowedAppointmentTypes(spec)
for _, r := range rows {
if !inSpecWindow(r.StartAt, bounds) {
continue
}
if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
continue
}
if r.ProjectID == nil && allowedProjects != nil {
continue
}
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
continue
}
if allowedTypes != nil {
if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
continue
}
}
detail, _ := json.Marshal(map[string]any{
"start_at": r.StartAt,
"end_at": r.EndAt,
"location": r.Location,
"appointment_type": r.AppointmentType,
"approval_status": r.ApprovalStatus,
"description": r.Description,
})
out = append(out, ViewRow{
Kind: SourceAppointment,
ID: r.ID,
Title: r.Title,
EventDate: r.StartAt,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectReference: r.ProjectReference,
ProjectType: r.ProjectType,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runProjectEvents queries paliad.project_events with the visibility
// predicate. The audit table doesn't have a service wrapper today; we
// run our own SQL bounded by the spec.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
conds := []string{visibilityPredicatePositional("p", 1)}
args := []any{userID}
allowedKinds := allowedProjectEventKinds(spec)
if len(allowedKinds) > 0 {
args = append(args, pq.Array(allowedKinds))
conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
}
if bounds.from != nil {
args = append(args, *bounds.from)
conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
}
if bounds.to != nil {
args = append(args, *bounds.to)
conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
args = append(args, spec.Scope.Projects.IDs)
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
}
q := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_by, pe.created_at,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference,
u.display_name AS actor_name
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = pe.created_by
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pe.created_at DESC
LIMIT 500`
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedBy *uuid.UUID `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
ActorName *string `db:"actor_name"`
}
var dbRows []row
if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
return nil, fmt.Errorf("project_events query: %w", err)
}
out := make([]ViewRow, 0, len(dbRows))
for _, r := range dbRows {
detail, _ := json.Marshal(map[string]any{
"event_type": r.EventType,
"description": r.Description,
"event_date": r.EventDate,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceProjectEvent,
ID: r.ID,
Title: r.Title,
EventDate: r.CreatedAt,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
ActorName: r.ActorName,
Detail: detail,
})
}
return out, nil
}
// runApprovalRequests projects approval_request rows via the existing
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
preds := spec.Predicates[SourceApprovalRequest]
role := "approver_eligible"
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
role = preds.ApprovalRequest.ViewerRole
}
filter := InboxFilter{}
if preds.ApprovalRequest != nil {
// InboxFilter takes a single status today. If the spec says
// only one, narrow; if multiple, leave open.
if len(preds.ApprovalRequest.Status) == 1 {
filter.Status = preds.ApprovalRequest.Status[0]
}
if len(preds.ApprovalRequest.EntityTypes) == 1 {
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
}
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
pid := spec.Scope.Projects.IDs[0]
filter.ProjectID = &pid
}
var rows []ApprovalRequestView
var err error
switch role {
case "approver_eligible":
rows, err = approval.ListPendingForApprover(ctx, userID, filter)
case "self_requested":
rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
case "any_visible":
// any_visible is the broadest read — RLS bounds it. The existing
// ApprovalService doesn't have a "list all visible" call; we
// approximate by running both inbox queries and de-duping. Future
// optimization: dedicated service method.
a, errA := approval.ListPendingForApprover(ctx, userID, filter)
if errA != nil {
return nil, errA
}
b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
if errB != nil {
return nil, errB
}
seen := make(map[uuid.UUID]bool, len(a)+len(b))
for _, r := range a {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
for _, r := range b {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
default:
return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
}
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedStatuses := allowedRequestStatuses(spec)
allowedEntityTypes := allowedRequestEntityTypes(spec)
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
// Spec status filter (when the inbox query received broad results).
if allowedStatuses != nil && !allowedStatuses[r.Status] {
continue
}
if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Sort key: decided_at if decided, else requested_at.
eventDate := r.RequestedAt
if r.DecidedAt != nil {
eventDate = *r.DecidedAt
}
if !inSpecWindow(eventDate, bounds) {
continue
}
title := approvalRowTitle(r)
subtitle := approvalRowSubtitle(r)
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
actorID := r.RequestedBy
actorName := r.RequesterName
pid := r.ProjectID
pt := r.ProjectTitle
out = append(out, ViewRow{
Kind: SourceApprovalRequest,
ID: r.ID,
Title: title,
Subtitle: &subtitle,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ActorID: &actorID,
ActorName: &actorName,
Detail: detail,
})
}
return out, nil
}
// approvalRowTitle returns a one-line title describing the approval
// request — used as the ViewRow.Title.
func approvalRowTitle(r ApprovalRequestView) string {
if r.EntityTitle != nil && *r.EntityTitle != "" {
return *r.EntityTitle
}
return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
}
// approvalRowSubtitle returns a one-line context for the request.
func approvalRowSubtitle(r ApprovalRequestView) string {
switch r.Status {
case "pending":
return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
case "approved":
if r.DeciderName != nil {
return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
}
return "Genehmigt"
case "rejected":
if r.DeciderName != nil {
return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
}
return "Abgelehnt"
case "revoked":
return "Widerrufen"
}
return r.Status
}
// inSpecWindow returns true when ts is within [from, to). nil bounds
// are open-ended.
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
if b.from != nil && ts.Before(*b.from) {
return false
}
if b.to != nil && !ts.Before(*b.to) {
return false
}
return true
}
// explicitProjectSet returns nil when the scope isn't explicit, otherwise
// a set membership map for fast filtering.
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
if spec.Scope.Projects.Mode != ScopeExplicit {
return nil
}
out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
for _, id := range spec.Scope.Projects.IDs {
out[id] = true
}
return out
}
// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
preds, ok := spec.Predicates[src]
if !ok {
return true
}
var allowed []string
switch src {
case SourceDeadline:
if preds.Deadline != nil {
allowed = preds.Deadline.ApprovalStatus
}
case SourceAppointment:
if preds.Appointment != nil {
allowed = preds.Appointment.ApprovalStatus
}
}
if len(allowed) == 0 {
return true
}
return slices.Contains(allowed, rowStatus)
}
// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceAppointment]
if !ok || preds.Appointment == nil {
return nil
}
if len(preds.Appointment.AppointmentTypes) <= 1 {
return nil // single-value already pushed down via AppointmentListFilter.Type
}
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
for _, t := range preds.Appointment.AppointmentTypes {
out[t] = true
}
return out
}
// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
if !ok || preds.ProjectEvent == nil {
return nil
}
if len(preds.ProjectEvent.EventTypes) == 0 {
return nil
}
return preds.ProjectEvent.EventTypes
}
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.Status) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
for _, s := range preds.ApprovalRequest.Status {
out[s] = true
}
return out
}
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
for _, t := range preds.ApprovalRequest.EntityTypes {
out[t] = true
}
return out
}
// filterInaccessibleProjects returns the subset of `requested` that the
// caller cannot see. Implementation: SELECT id FROM paliad.projects
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
// are inaccessible. One DB hit per RunSpec when scope is explicit.
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
if len(requested) == 0 {
return nil, nil
}
q := `SELECT p.id
FROM paliad.projects p
WHERE p.id = ANY($1)
AND ` + visibilityPredicatePositional("p", 2)
var visible []uuid.UUID
if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
return nil, fmt.Errorf("filter inaccessible projects: %w", err)
}
visibleSet := make(map[uuid.UUID]bool, len(visible))
for _, id := range visible {
visibleSet[id] = true
}
out := make([]uuid.UUID, 0)
for _, id := range requested {
if !visibleSet[id] {
out = append(out, id)
}
}
return out, nil
}
// Compile-time guards: the substrate's source loaders read fields off
// known model shapes. If a model rename breaks this, the build fails
// here rather than at runtime in production.
var (
_ = models.DeadlineWithProject{}
_ = models.AppointmentWithProject{}
_ = models.ProjectEvent{}
)