# Projects page redesign — tree-first, filter chips, search-primary, view selector
**Status:** READY FOR REVIEW (godel, inventor, 2026-05-07) — 4 surfaced questions answered by m, see §12
**Issue:** m/paliad#10 (t-paliad-149)
**Branch:** `mai/godel/inventor-projects-page`
## m's locks (2026-05-07 22:08)
m answered the 4 surfaced questions:
- **Q1 default landing:** Last-viewed restore (sessionStorage; first-ever visit falls back to Tree + Alle + top-level only)
- **New-Q20 Cards default content:** Rich (~9 facts)
- **New-Q21 Cards customisation:** Full drag-rearrange + named layouts (needs new `paliad.user_card_layouts` table — migration 061)
- **Q13 search shape:** Both simultaneously (in-place tree/cards filter on page + global Cmd-K palette stays as the from-anywhere shortcut)
These 4 are LOCKED. The other 17 recommendations remain READY-FOR-REVIEW;
m may challenge any in the review pass.
## 0. TL;DR
`/projects` gets **three view modes** (Tree | Flat | Cards), tree-first by default
(rooted at clients, descendants navigable), behind a chip row (Alle / Nur meine /
Angepinnt / Status / Typ / Mit aktiven Fristen) with **AND-across-chips,
OR-within-multi-select** combinatorics, plus a single prominent search input that
filters the active view (in-place for tree; substring filter for cards / flat).
Pinning is a per-user dot table (`paliad.user_pinned_projects`, migration 060) with
a star marker on every row/card + an "Angepinnt" filter chip.
**Cards view** (m's addition 2026-05-07 22:00) is a quick-overview surface:
each card shows configurable facts about one project — title + type + status,
deadline counts, **next 3 events**, **last 3 events / Verlauf entries** — all
hoverable for detail and clickable to drill in. Card content + layout are
user-customisable: v1 ships **two presets (Kompakt / Geräumig) + a small
"shown facts" checklist**; full per-user drag-rearrange deferred to v2.
Per-user prefs live in `localStorage` in v1 (no new table).
The existing `/api/projects/tree` endpoint and `client/project-tree.ts` are
reused verbatim for the Tree rendering primitive — they already do the right
shape (visibility-scoped, deadline-count-aggregated, expand/collapse,
click-to-navigate). This redesign adds **a chip-driven filter overlay**, **a
search filter**, **pinning**, **a mobile drill-in mode**, and **the Cards
view** on top.
**Q15 decision (delegated to inventor by m):** option (a) — bespoke `/projects`
selector with hardcoded system chips. Rationale in §6. Custom Views (t-144)
stays event-shaped; projects are scope, not events; conflating the two would
require a new project DataSource + tree RenderShape that nothing else needs.
**4 surfaced questions for m** (via AskUserQuestion this session):
- Q1 default landing + view mode (Tree | Cards as default?)
- New-Q20 Cards default content (which facts on a card by default)
- New-Q21 Cards customisation scope (presets only | drag-customise | none)
- Q13 search shape (in-place vs Cmd-K modal)
Other 17 issue-body and inventor follow-up questions are answered with
recommendations + rationale below; m can challenge any of them in the review.
## 1. Problem + premises verified live
m's framing 2026-05-07 21:52:
> "The Projects Page needs to become nicer. … default view to be a view of all clients
> — and one can filter very nicely. 'All' / 'Only mine' / Pinned / by status. … The tree
> view is much nicer and more natural than the flat view. The search should be the main
> thing for 'ad-hoc' view, for favourites / pinned or other views we need to have a selector."
### 1.1 What exists today
- `frontend/src/projects.tsx` (140 LoC) — page shell with three filters
(Typ / Status / Ansicht: flach|baum|wurzeln) and a free-text search input.
Default view = flat. Tree is one of three options.
- `frontend/src/client/projects.ts` (238 LoC) — list-page client. Reads
`/api/projects` flat list, filters in JS, renders `
`.
Tree mode delegates to `client/project-tree.ts`.
- `frontend/src/client/project-tree.ts` (289 LoC) — tree renderer. Reads
`/api/projects/tree` once + caches. Top-2-levels open by default; deeper
collapsed. `sessionStorage` persists overrides per-node. Click row → navigate.
Type icons (client/litigation/patent/case/project) defined inline. Deadline
counts (open + overdue) shown as per-node badges (NOT subtree-aggregated).
- `internal/handlers/projects.go:245` — `GET /api/projects/tree` returns the
full nested tree (`ProjectService.BuildTree`).
- `internal/services/project_service.go:319` — `BuildTree` does
visibility-scoped path-ordered SELECT + per-project deadline-count JOIN +
in-memory stitching. Subtree aggregation **not** done in v1; counts are
per-node only.
- `frontend/src/styles/global.css:5563` — `.projekt-tree-*` CSS rules. Chip
patterns already exist as `.fristen-forum-chip` and `.fristen-search-chip`
(reusable shape; rename for projects-page namespace).
### 1.2 What does NOT exist today
- **No pin / favourite table.** Sidebar has a `paliad-sidebar-pinned`
localStorage key but that's the sidebar pin (sticky-open), unrelated to
project pinning.
- **No "mine" semantics endpoint.** Project membership comes from
`paliad.project_teams` (direct) ± inherited via t-139's `can_see_project`.
No service method returns "projects I'm directly on the team of."
- **No subtree-aggregated counts.** Existing tree shows per-node deadline
counts; aggregating up the tree is a TODO in `BuildTree` per the t-139 lock.
- **No Custom Views project source.** `internal/services/filter_spec.go`
declares `AllSources = {deadline, appointment, project_event,
approval_request}`. No `project` source. RenderShape is `{list, cards,
calendar}` — no `tree` shape.
### 1.3 Premises that survive into the design
1. The tree primitive is already correct shape. Reuse it.
2. The flat-list view stays available (issue says so, and a flat list is
still the right call for narrow searches: "give me every Verfahren in
status=open across all clients"). Just stops being the default.
3. Visibility (`paliad.can_see_project`) already extends through ancestors
+ descendants + partner-unit derivation (t-139 shipped). Nothing to add
on the visibility front.
4. Migration tracker is at 059 on main (058 = paliadin_poc t-146, 059 = profession_vs_responsibility t-148, both shipped 2026-05-07). Next available is 060 (PR 1) and 061 (PR 2).
5. The Custom Views substrate (t-144) is **NOT** the right place for projects
— see §6.
## 2. Locked direction (m, 2026-05-07)
- Default view: tree, rooted at clients, with all descendants navigable.
- Chip row at top: Alle / Nur meine / Angepinnt / nach Status / further axes
inventor proposes.
- Search is the primary ad-hoc tool — prominent, not buried.
- A "view selector" for favourites / pinned / saved views (Q15: inventor
decides whether this is bespoke or piggy-backs on Custom Views).
- Tree-first; flat-list view stays available but isn't the default.
## 3. Sub-design 1 — Filter axes + chip set + combinatorics
### 3.1 v1 chip set (Q2)
Recommended chips (left to right in the toolbar):
| Chip | Value(s) | Default |
|---|---|---|
| **Alle** | (resets all chips) | active on first visit |
| **Nur meine** | toggle: on / off | off |
| **Angepinnt** | toggle: on / off | off |
| **Status** | multi-select panel: Aktiv / Archiviert / Abgeschlossen | nothing selected = no narrowing |
| **Typ** | multi-select panel: Mandant / Streitsache / Patent / Verfahren / Projekt | nothing selected = no narrowing |
| **Mit aktiven Fristen** | toggle: on / off | off |
The first 3 are toggles (single click). The next 2 are multi-select dropdowns
(reuse the `EventTypeMultiSelect` component shape from t-088 — popover with
checkbox list + "Alle" / "Ohne" specials). The last is a simple toggle.
Deferred to v2 (worth flagging but not in v1):
- "Bevorstehende Termine" (next 7d) — overlaps "Mit aktiven Fristen" enough
for v1.
- "Mit offenem Approval-Request" — coupling to t-138 not worth a v1 chip.
- "Recent activity" sort — covered as Q16 below; defer.
- "Wo ich Lead bin" — finer-grained than "Nur meine"; rename if m wants it.
### 3.2 Combinatorics (Q3)
**AND across chips.** Each chip narrows. Rationale: every chip is intent-
narrowing language ("only my … pinned … active deadlines …"). OR across chips
would force the user to think in set-union language, which patent paralegals
don't.
**OR within multi-select.** A user picks "Status: Aktiv + Abgeschlossen" to
see both. AND across the multi-select would only work for orthogonal
dimensions; it doesn't make sense within one axis.
**Empty multi-select = no narrowing.** Closing the Status panel without
ticking any box means "I don't care about status" — the chip shows base label
("Status"); ticking ≥1 means "narrow to these" — the chip shows
"Status: 2 ausgewählt" (or single-label if exactly one).
### 3.3 "Nur meine" semantics (the load-bearing one)
**Recommendation: direct membership only** in v1, NOT inherited.
- Direct = there is a row in `paliad.project_teams` where
`(project_id, user_id) = (this, me)`.
- Inherited / derived would include ancestor-staffed + partner-unit-derived
(per t-139). That's the same set as `can_see_project` (visibility), and at
that point "Nur meine" = "visible to me", which is just the default tree.
So "Nur meine" must be **strictly narrower than visibility** to be useful.
Direct-only is the obvious meaning. A user staffed on a `Patent` row sees the
parent `Litigation` and `Client` in their tree (visibility cascades up) but
"Nur meine" filters to the `Patent` row only — the ancestors collapse out.
Edge case: if a user has direct membership only on a deep `Case` row, "Nur
meine" shows that Case as a top-level orphan (no ancestor visible). The
alternative is to keep the ancestor chain visible-but-greyed (so they
understand which Mandant the Case sits under). Recommendation: **keep
ancestors visible-but-greyed** — they're already in the visibility scope,
and the chain provides indispensable context. Greyed = `opacity: 0.55` on
the row, no badges, no pin star, click still navigates.
This needs a new endpoint or a `?scope=mine` parameter on `/api/projects/tree`
that returns:
- All directly-staffed projects.
- Their ancestors (greyed; client returns flag `inherited_visibility: true`).
- No descendants of directly-staffed projects unless they're also directly-
staffed.
### 3.4 Empty-state (Q14)
If chips narrow the tree to zero rows:
> "Keine Projekte für diese Filter. **[Filter zurücksetzen]** **[+ Neues Projekt]**"
Both buttons inline. The first resets all chips (= "Alle" active); the second
goes to `/projects/new`.
## 4. Sub-design 2 — Tree view + UX + pinning
### 4.1 Default landing (Q1) — LOCKED last-viewed restore (m 2026-05-07)
Storage key: `paliad.projects.lastView` in sessionStorage. Shape:
```ts
{
viewMode: "tree" | "cards" | "flat", // last selected view-mode segment
chips: { // last applied chip state
scope: "all" | "mine" | "pinned",
status: string[],
type: string[],
hasOpenDeadlines: boolean,
},
searchQuery: "", // empty = no active search
// tree-mode only:
treeExpanded: string[], // explicit expand overrides per node
// cards-mode only:
cardLayoutId: "uuid", // active named layout (server is source of truth)
// flat-mode only:
flatColumns: string[], // future-proof; v1 unused
}
```
URL params (`?view=…`, `?chips=…`, `?q=…`, `?focus=…`, `?expand=…`) override
the sessionStorage state on load. So:
- Bookmarks / share-links → URL takes precedence.
- Plain navigation back to `/projects` → sessionStorage restores the last view.
**First-ever visit fallback** (no sessionStorage entry, no URL params): Tree
view, "Alle" chip, top-level only expanded (Mandanten visible, descendants
collapsed). This is option (a) from the surfaced question — the predictable
fallback for first-time users.
When sessionStorage is cleared (private mode, browser cleanup), behaviour
falls back to first-ever-visit defaults.
Edge case: if the user's last view was Cards but the active layout has been
deleted from another device, fall back to the user's current default layout.
### 4.2 Tree state persistence (Q4)
Recommendation: **session-only via `sessionStorage` (current behaviour) +
`?expand=` URL param for share-links + `?focus=` for deep-link**.
Rationale: a per-user persistent expand state is one more table-row per
project (`paliad.user_project_expanded`) for very low return — the user has
to learn that expand state survives across sessions, which most users won't
even notice. SessionStorage already fits this need (open Mandant, navigate
into Patent, come back, Mandant is still open). Persistent state can land
in v2 if anyone asks.
The URL param overlay lets share-links and palette-deep-links land at the
right node:
- `/projects?focus=` — opens with ancestors expanded + node scrolled
into view + briefly highlighted (lime accent flash, 600ms).
- `/projects?expand=uuid1,uuid2,uuid3` — explicit subset of expanded nodes.
Useful for "show this exact tree shape to someone."
### 4.3 Deep-link to a node (Q5)
`/projects?focus=` — see above. Also exposed as a programmatic helper
for sidebar links + breadcrumb navigation: anywhere we link to "see this
project's parent in the tree" we use `?focus=`.
### 4.4 Lazy-loading (Q6)
Recommendation: **no lazy-loading in v1.** The current `/api/projects/tree`
returns the full visible tree in one round-trip (`BuildTree`). Live data:
even global_admins see ≤ 200 projects today; the response is < 100 KB.
The tree renders instantly on m's local stack.
If/when paliad scales past ~1000 projects, the cleanest path is:
- Server still returns the tree skeleton (id + parent_id + title + path) in
one shot.
- Per-row counts (open_deadlines, overdue_deadlines, subtree-aggregated)
become separate `/api/projects/tree/counts?ids=…` requests, lazy-fetched
per visible viewport batch.
Not v1.
### 4.5 Inline counts per node (Q7)
Recommendation: **subtree-aggregated counts by default**, with a "nur direkt"
toggle (parallel to t-139's pattern on /projects/{id}).
A Mandant row's badge "12 offene Fristen" reflects the union of its own +
all descendants', not just its own (Mandanten typically have 0 direct
deadlines — they're scaffolding rows). Without aggregation, every Mandant
row reads "0 / 0" which is misleading.
Aggregation pattern from t-139 already exists in `BuildTree`:
- `open_deadlines` and `overdue_deadlines` are per-node today.
- New columns `open_deadlines_subtree` and `overdue_deadlines_subtree`
aggregated in the same query (sum over `path <@ p.path`).
- Display: by default, badge shows subtree count. Hovering the badge tooltip:
"12 (eigene: 0 / aus 4 Unterprojekten: 12)".
- Toggle in toolbar: "[ ] Nur direkte Fristen zählen" — switches both badges
to per-node only.
Same treatment for `Termine` if we add a Termine badge (recommendation: not
in v1; deadline badges are enough; Termine count would clutter rows).
### 4.6 Action affordances per row (Q8)
Recommendation: **always-visible pin star on every row + click-row-to-open
detail + no right-click context menu in v1**.
- **Pin star (⋆ filled / ☆ empty):** small button, far-right of the row, before
the status chip. Click toggles pin. Always visible (not hover-revealed) —
m is on a touch surface ~30% of the time per the PWA strategy, hover is
unreliable.
- **Row click:** navigates to `/projects/{id}` (already implemented).
- **Toggle chevron click:** expands / collapses (already implemented).
- **No right-click menu in v1.** Bulk operations (close, archive, move) are
out of scope per the issue. If a v2 wants per-row actions, an inline `⋯`
button on the right wins over right-click (works on touch, more
discoverable).
Mobile: at ≤ 768px, switch to drill-in mode (Q17). Pin star is still on the
row but the row is full-width and tappable.
### 4.7 Pinning storage (Q9, Q11)
New table `paliad.user_pinned_projects`. Migration 060 (next available).
```sql
CREATE TABLE paliad.user_pinned_projects (
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
pinned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, project_id)
);
CREATE INDEX user_pinned_projects_user_idx
ON paliad.user_pinned_projects (user_id, pinned_at DESC);
ALTER TABLE paliad.user_pinned_projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_pinned_projects_owner_all
ON paliad.user_pinned_projects FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
```
- Per-user (Q11 confirmed). Pinning is personal working state, not project-
team metadata. RLS is symmetric: only the owner reads or writes.
- ON DELETE CASCADE on both sides. Project deletion removes pin rows;
user deletion removes their pins.
- No `display_order` column — pins are sorted by `pinned_at DESC` (most
recent first). User-defined ordering can land in v2 if needed.
API:
- `POST /api/projects/{id}/pin` → 201, idempotent. Body: empty.
- `DELETE /api/projects/{id}/pin` → 204, idempotent.
- Pin state surfaces inline in `/api/projects/tree` (new field `pinned: bool`).
- `GET /api/user-pinned-projects` returns the flat list of pinned IDs (used
by the sidebar widget if we want one — defer to v2).
### 4.8 Pin display (Q10) — surfaced to m
Two reasonable shapes:
- **(a) Star marker only.** Pinned projects get a filled-star marker in the
row; "Angepinnt" filter chip filters the tree to pinned-only (other rows
hidden). Tree shape preserved.
- **(b) Pinned subtree pulled to top.** Above all client subtrees, a pseudo-
group `⋆ Angepinnt (4)` containing flat references to pinned projects (with
a small parent-chain breadcrumb so you know which Mandant). Real client
subtrees follow.
- **(c) Both: star marker on real rows AND a "⋆ Angepinnt" pseudo-group at
top, always visible.**
Recommendation **(a)** — keeps the tree simple, the chip handles the "pinned
only" view. (b)/(c) double-render some projects which is confusing.
But m should pick. Surfacing.
### 4.9 Mobile (Q17)
Recommendation: **drill-in mode at ≤ 768px**.
- Tap a row → push the row's children as a new "page" (animated slide-in).
- Header replaces with a back-arrow + breadcrumb chip ("Siemens AG ›").
- Tap back → pop one level.
- Search and chips stay at the top of the current level.
Indented-list mode (the desktop tree shoehorned onto mobile) is awkward —
deeply nested children get illegible at <600px. Drill-in is what every
mobile file manager / mail-folder app does.
Implementation: client-side only. Server still returns the full tree.
At ≤ 768px, the renderer keeps a `currentNode` pointer; rendering slices
the tree starting at that pointer; back-arrow walks up via `parent_id`.
## 5. Sub-design 3 — Search shape + integration
### 5.1 Search shape (Q13) — LOCKED both simultaneously (m 2026-05-07)
The /projects page input does **in-place filter on the active view**:
- **Tree mode:** non-matching rows hide; ancestors of matches stay visible
(greyed `opacity: 0.55`) so context is preserved; matching substring
highlights in lime on the title.
- **Cards mode:** non-matching cards hide; substring highlights in lime on
the title and parent path.
- **Flat mode:** standard table-row substring filter (existing behaviour).
The global Cmd-K palette stays unchanged — `Cmd-K` / `Ctrl-K` still opens
the universal search-overlay from anywhere in the app, including from
`/projects`. Users get both: the page input narrows the current view; Cmd-K
jumps to anywhere across courts / glossary / projects / tools.
Implementation note: the page input does NOT also open Cmd-K when focused.
Tabbing into the input doesn't trigger the overlay. The two surfaces stay
visually and behaviourally distinct (page input is inline; Cmd-K is overlay).
### 5.2 Search scope (Q12)
Recommendation: **title + parent path + reference + clientmatter**.
- Title is the obvious one.
- Parent path: type "Müller" → match the Case "14-vs-Müller" (already in
title, ✓), AND match every project under a Mandant titled "Müller AG"
(descendant-by-ancestor-title). This is what users mean by "give me Müller
stuff."
- Reference: matches the per-project `reference` field.
- ClientMatter: `client_number.matter_number`.
Backend implementation: server-side, postgres `pg_trgm` similarity query +
ancestor-title boost. Endpoint:
`GET /api/projects/tree?q=` — returns the tree filtered to nodes that
match OR have a matching ancestor OR have a matching descendant.
Client-side echo: the input also does a local substring filter on the cached
tree for instant feedback (debounced 200ms before hitting the server). The
server response trumps the local echo when it arrives.
### 5.3 Empty-state (Q14)
Same as §3.4. "Keine Projekte für diese Filter. [Filter zurücksetzen]
[+ Neues Projekt]." Plus, if the user has typed a search query, an extra
"Suche im Glossar / Gerichten / Glossar?" link to the Cmd-K palette.
### 5.4 Performance (Q19)
Debounce 200ms (standard). Cap server results at 200 nodes for any query;
beyond that, show "200+ Treffer — bitte Suche präzisieren" footer.
## 5b. Sub-design 5 — Project Cards view (m's addition 2026-05-07 22:00)
m's framing:
> "I would also like to have 'Project Cards' View where we can customize how
> we show the project card. Like what facts do we show and how the layout is.
> I could imagine a card where we also see the next three events and the
> last three, hoverable and clickable. So people get a really quick overview."
Cards is a **third view mode** alongside Tree and Flat. It is reached via a
view-mode segment-control at the top of the page (Tree | Cards | Flat). View
mode persists in `localStorage` (`paliad.projects.viewMode`).
### 5b.1 What a card represents
One project per card. Cards is a **flat grid** ordered by:
- Pinned first (within their group) when "Angepinnt" chip is off.
- Then by `last_activity_at DESC` (most-recently-touched first; "last_activity_at"
= `MAX(updated_at across own + descendants' deadlines / appointments /
project_events)` — already computable from existing tables, optionally
cached as `paliad.projects.last_activity_at` updated by triggers if perf
warrants).
Chips narrow the set the same way they narrow Tree / Flat. Search filters cards
by substring in the same fields (title + parent path + reference + clientmatter).
By default, Cards shows **leaf-ish projects only** (Cases, Patents, Verfahren,
Projekte) — Mandanten and Litigation rows are scaffolding and don't have direct
events / deadlines themselves under the t-139 hierarchy model. A toggle
`[ ] Alle Ebenen anzeigen` shows Mandanten / Litigation cards too (with their
subtree-aggregated counts and subtree event preview). Toggle persists in
localStorage.
### 5b.2 Card anatomy — default content
```
┌─────────────────────────────────────────────────┐
│ ⋆ EP 1234567 — Verteidigung ⋯ │ ← Title row (pin star, ⋯ menu, type icon)
│ [Patent] · [Aktiv] · 12345.678 │ ← Type + Status + ClientMatter chips
│ │
│ Müller AG › Müller vs. Apple │ ← Parent path (clickable breadcrumb)
│ │
│ ▸ 3 offene Fristen ▸ 1 überfällig │ ← Deadline counts (subtree-aggregated)
│ │
│ Nächste Termine │
│ ░ 15 Mai · Klageerwiderung (Frist) │ ← Hoverable + clickable event row
│ ░ 22 Mai · Mündliche Verhandlung (Termin) │
│ ░ 03 Jun · Erwiderung (Frist) │
│ │
│ Zuletzt │
│ ▣ heute · Frist erstellt von Anna Schmidt │ ← Verlauf entries from project_events
│ ▣ gestern · Termin verschoben │
│ ▣ 03 Mai · Genehmigung erteilt │
│ │
│ Team: AS · MK · +2 │ ← Avatar / initials chips of staffed team
└─────────────────────────────────────────────────┘
```
Hover on any "Nächste Termine" or "Zuletzt" row → tooltip with the full title,
context, and deep-link metadata. Click → navigate to the deadline / appointment /
project detail (with `?focus=` on the relevant entity).
Default visible facts (all on by default):
1. Title + type icon + pin star + ⋯ menu
2. Type chip
3. Status chip
4. ClientMatter
5. Parent path breadcrumb
6. Deadline counts (subtree)
7. Next 3 events (Fristen + Termine, sorted asc, top 3)
8. Last 3 Verlauf entries (project_events sorted desc, top 3)
9. Team chips (initials, capped at 3 + "+N")
### 5b.3 Customisation scope — LOCKED option (c) full drag-rearrange + named layouts (m 2026-05-07)
m chose **full per-user customisation with named layouts**. Implementation:
**New table — migration 061:**
```sql
CREATE TABLE paliad.user_card_layouts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
name text NOT NULL, -- "Standard", "Kompakt", "Was steht an", …
is_default boolean NOT NULL DEFAULT false,
layout_json jsonb NOT NULL, -- ordered facts list + density + grid + counts
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, name)
);
-- At most one default layout per user (partial unique index)
CREATE UNIQUE INDEX user_card_layouts_default
ON paliad.user_card_layouts (user_id)
WHERE is_default = true;
ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_card_layouts_owner_all
ON paliad.user_card_layouts FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
```
**Layout JSON schema (server-side validated):**
```ts
type CardLayout = {
facts: Array<{
key: "title-row" | "type-chip" | "status-chip" | "client-matter"
| "parent-path" | "deadline-counts" | "next-events"
| "recent-verlauf" | "team-chips" | "reference" | "last-activity-at",
visible: boolean,
// Per-fact extras (only meaningful for some keys):
count?: number, // for next-events / recent-verlauf, 1..5
}>,
density: "compact" | "roomy",
gridColumns: "auto" | 2 | 3 | 4,
showAllLevels: boolean, // include Mandanten / Litigation rows as cards
};
```
The `facts` array's order is the render order top-to-bottom within a card.
Validator on the server rejects:
- Unknown fact keys
- Duplicate keys (each appears at most once)
- `count` outside [1, 5]
- More than one fact occupying the "title-row" slot (it must be the first
visible row)
**Service layer — `CardLayoutService` (~120 LoC):**
- `List(ctx, userID) ([]*CardLayout, error)` — sorted by `name ASC`, default first
- `Get(ctx, userID, id) (*CardLayout, error)`
- `GetDefault(ctx, userID) (*CardLayout, error)` — auto-creates the seed
"Standard" layout (the rich content set) on first call
- `Create(ctx, userID, name, layout) (*CardLayout, error)` — `is_default = false`
unless this is the user's first
- `Update(ctx, userID, id, name?, layout?, is_default?) error` — flipping
`is_default = true` clears the flag on the user's previous default in tx
- `Delete(ctx, userID, id) error` — cannot delete the default; UI gates this
**API surface (PR 2):**
- `GET /api/user-card-layouts` → 200 with `[…]`
- `POST /api/user-card-layouts` → 201 with the new row (body: name + layout)
- `PATCH /api/user-card-layouts/{id}` → 200 (body: any of name / layout / is_default)
- `DELETE /api/user-card-layouts/{id}` → 204
- `POST /api/user-card-layouts/{id}/set-default` → 204 (sugar over PATCH)
**Frontend customisation UI:**
- Top of Cards view: layout dropdown:
```
Layout: [Standard ▾] [+ Neues Layout] [⚙ Bearbeiten]
```
- The dropdown lists all the user's named layouts. Picking one switches
the active layout immediately (POST `/api/user-card-layouts/{id}/set-default`
updates the server-side default; `localStorage` mirrors for instant pickup
on next load).
- "+ Neues Layout" prompts for a name (modal), seeds with the current
active layout's `layout_json`, opens edit mode on the new layout.
- "⚙ Bearbeiten" puts cards into **edit mode**:
- Each card grows a faint outline.
- Each fact within a card grows a drag handle (≡) + visibility toggle (eye/eye-off).
- Drop zones appear between facts — drag a fact up or down to reorder.
- For `next-events` / `recent-verlauf`, a count stepper [-] [3] [+] appears.
- Top-of-page bar: density radio (Kompakt / Geräumig), gridColumns
select (Auto / 2 / 3 / 4), "Alle Ebenen anzeigen" toggle.
- Bottom-of-page: "Speichern" / "Verwerfen" / "Als Standard festlegen"
buttons.
- HTML5 drag-and-drop is enough (Chromium / Safari / Firefox all support
draggable `
` elements with `dataTransfer`). No third-party DnD library.
- Mobile: edit mode shows a long-press-and-drag affordance per fact
(`pointerdown` + 500ms hold).
**v2 escape hatches:** firm-wide layout templates ("Was unsere Senior-PAs
sehen"); per-project-type defaults (different layout for Mandant cards vs
Case cards); cross-user sharing; export/import as JSON.
### 5b.4 Default content — LOCKED option (α) rich (m 2026-05-07)
The seed "Standard" layout (auto-created on first cards-view visit) carries
all 9 facts shown in §5b.2:
1. Title row (title + type icon + pin star + ⋯ menu) — `key: "title-row"`, always-visible & always-first
2. Type chip — `key: "type-chip"`
3. Status chip — `key: "status-chip"`
4. ClientMatter — `key: "client-matter"`
5. Parent path breadcrumb — `key: "parent-path"`
6. Deadline counts (subtree) — `key: "deadline-counts"`
7. Next 3 events — `key: "next-events"`, `count: 3`
8. Last 3 Verlauf entries — `key: "recent-verlauf"`, `count: 3`
9. Team chips — `key: "team-chips"`
Density: `roomy`. Grid: `auto`. `showAllLevels: false`.
The user can hide any fact (except `title-row`), reorder them, change counts,
and create alternative named layouts. The seed itself can be edited — there's
no "factory reset" beyond deleting the seed and creating a fresh one with the
same layout via the editor.
### 5b.5 Event preview implementation
Cards mode needs per-project event rollups. Two approaches:
1. **Embed in `/api/projects/tree` response.** Each node carries
`next_events: [...3]` and `recent_verlauf: [...3]`. Pros: one round-trip;
tree response is already the canonical primitive. Cons: ~6 extra rows ×
N projects = N×6 rows even when the user hasn't switched to Cards yet.
2. **Separate `/api/projects/cards-preview` endpoint, called only on Cards
mode mount.** Lazy. Cons: second round-trip. Pros: doesn't bloat the tree
payload.
Recommendation: **(2) separate endpoint, lazy-fetched on Cards mode**. Tree
mode payload stays lean. The endpoint does a single SQL query (deadlines +
appointments + project_events filtered to visible projects, partitioned by
project_id, top 3 each direction). Cached server-side per user (5min TTL)
because the data doesn't change rapidly.
Endpoint shape:
```
GET /api/projects/cards-preview
→ 200 [
{
project_id: "uuid",
next_events: [ { kind, id, title, event_date, …project_id…, route } …3 ],
recent_verlauf: [ { kind, id, title, event_date, actor_id, actor_name, route } …3 ],
team_initials: ["AS", "MK", …],
team_count: 5,
last_activity_at: "2026-05-07T14:30:00Z",
}, …
]
```
`route` is the navigation target (`/projects/{pid}?focus=` for
deadlines/appointments; `/projects/{pid}?tab=verlauf&focus=` for
project_events). Computed server-side so the client doesn't hardcode URLs.
### 5b.6 Hover affordance
Each event row in a card is `