# 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 `<table.entity-table>`.
  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=<csv>` URL param for share-links + `?focus=<uuid>` 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=<uuid>` — 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=<uuid>` — 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=<uuid>`.

### 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=<term>` — 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 `<li>` 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=<entity-id>` for
deadlines/appointments; `/projects/{pid}?tab=verlauf&focus=<event-id>` for
project_events). Computed server-side so the client doesn't hardcode URLs.

### 5b.6 Hover affordance

Each event row in a card is `<button>` with:
- `title` attribute = "Click for detail" (i18n).
- On hover, a small popover appears (200ms delay) showing:
  - Full event title (no truncation).
  - Date in long form.
  - Status chip (open / overdue / done — for Fristen).
  - Project context if multi-project hover (not common — per-card events are
    project-scoped).
- On click, navigates to `route`.
- Touch: tap shows the popover; second tap navigates. Standard pattern.

### 5b.7 Mobile

At ≤ 600px, cards stack one-per-row. Density auto-flips to "compact" preset
regardless of user preference (override per-screen). Drill-in semantics from
Tree mode don't apply to Cards (cards are flat grid).

### 5b.8 Search and filter integration

- Chip filters narrow the cards set the same way they narrow Tree / Flat.
- Search filters cards by substring in (title + parent path + reference +
  clientmatter). Match-highlight in lime on title.
- "Alle Ebenen anzeigen" toggle decides whether Mandant + Litigation rows
  appear as cards.

### 5b.9 Performance

Cards view targets at most ~100 visible cards before pagination kicks in.
With the existing visibility scope, that's already typical (most users see
<50 leaf-ish projects). For global_admins or large clients, the card grid
gets a "Mehr laden" footer at 100 with a `LIMIT/OFFSET`-style continuation
parameter on `/api/projects/tree?card_offset=100&card_limit=100`.

The cards-preview endpoint is the load-bearing perf concern. Single SQL with
window functions (`ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY
event_date)` capped at top 3 each direction) is sub-100ms for ~200 visible
projects. Cached 5min server-side per user.

## 6. Sub-design 4 — Custom-Views integration decision (Q15) + performance (Q18)

### 6.1 Q15 decision: option (a) — bespoke selector

m wrote: "(b) is more elegant but requires extending t-144's render shapes
(a 4th shape 'tree') and adjusting FilterSpec to express tree-state — that's
significant. (a) is faster, less unification."

Verified live (this session):

- `internal/services/filter_spec.go:34` — `AllSources = {deadline,
  appointment, project_event, approval_request}`. Projects are NOT a source.
- `internal/services/render_spec.go:31` — `AllShapes = {list, cards,
  calendar}`. No tree shape.
- `internal/services/view_service.go` — `RunSpec` runs over events, not over
  projects. The whole substrate is a UNION-of-event-sources, with projects
  as the *scope* dimension (FilterSpec.Scope.Projects).

Adding /projects into Custom Views would require:

1. New `DataSource: SourceProject` (a fundamentally different row shape than
   the existing four — projects don't have an event_date).
2. New `RenderShape: ShapeTree` (substantially different layout from
   list/cards/calendar).
3. New per-shape config in RenderSpec for `tree` (expand state, depth limit,
   subtree-aggregation flag).
4. Predicate set on the project source (status, type, pinned, mine, has-
   active-deadline) — none of which apply to events.

That's not "extending the substrate" — that's adding a parallel substrate
that shares only the `paliad.user_views` table for persistence. The shape ⊥
source invariant Custom Views relies on (any source × any shape) breaks for
projects: projects only ever render as tree (or flat-list, which is just a
degenerate tree), never as cards or calendar. The orthogonality goes away.

**Bespoke is therefore the right call**, and the `paliad.user_views` machinery
is correctly left alone for events.

### 6.2 What replaces "view selector" then?

The chip row + URL state is the view selector. m's mental model in the issue
was probably "saved chip combos persist across sessions and have a name."
v1 strategy:

- **No saved-views table for /projects in v1.** The chip row and search input
  are themselves the selector. URL state (`?chips=mine,pinned&status=open`)
  makes specific combos shareable / bookmarkable.
- **Most-recently-used chips persist in localStorage.** When a user visits
  `/projects` after using "Nur meine" yesterday, the chip stays active. (Tied
  to the Q1 default-landing decision — see §4.1.)
- **v2 escape hatch:** if users ask for "save this chip combo as a named
  view" later, that becomes a small `paliad.user_project_views` table with
  (id, user_id, name, chips jsonb, sort_order). Independent of `paliad.user_views`.

### 6.3 Most-recent-activity sort (Q16)

Recommendation: **defer to v2.** The infra is there (events table has
`created_at`; `BuildTree` could sort the tree by max-event-date), but it
breaks the tree's natural order (path-ordered, alphabetical-within-parent).

A "Recent activity" *flat list* view is plausible v2 — it's a different mental
model (you're not navigating projects, you're scanning what's been touched).
Frankly that overlaps with the existing `/agenda` page and with a Custom View
of `project_event` source. So: don't build it on /projects. If users ask, point
them at /agenda.

### 6.4 Initial load size (Q18)

See §4.4. Full tree fits comfortably in one round-trip for paliad's data
size today. Lazy-loading deferred to when telemetry shows it matters.

## 7. Data model

One new table (migration 060):

```sql
-- 060_user_pinned_projects.up.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);
```

No other schema changes. Subtree-aggregated counts come from extending
`BuildTree`'s SELECT, not from new columns.

## 8. API surface

### 8.1 Extended

`GET /api/projects/tree` gets new query parameters and response fields.

Query params (additive; defaults preserve current behaviour):

- `?scope=all|mine|pinned` (default `all`)
- `?status=open,archived,closed` (CSV; default no narrowing)
- `?type=client,litigation,patent,case,project` (CSV; default no narrowing)
- `?has_open_deadlines=true|false` (default no narrowing)
- `?q=<search-term>` (default no search)
- `?focus=<uuid>` (default none)
- `?expand=<csv-of-uuids>` (default uses sessionStorage)
- `?subtree_counts=true|false` (default `true`, the new behaviour; `false` =
  per-node only, the legacy behaviour)

Response fields per node (additive):

- `pinned: bool` (new)
- `inherited_visibility: bool` (new — true when row is rendered as greyed
  ancestor under "Nur meine")
- `open_deadlines_subtree: int` (new — subtree-aggregated)
- `overdue_deadlines_subtree: int` (new — subtree-aggregated)
- `match_kind: "self" | "ancestor" | "descendant" | null` (new — populated
  only when `?q=…` is present; "self" = direct hit, "ancestor"/"descendant"
  = on the path of a hit)

### 8.2 New

- `POST /api/projects/{id}/pin` → 201 Created, idempotent. Body empty.
- `DELETE /api/projects/{id}/pin` → 204 No Content, idempotent.
- `GET /api/user-pinned-projects` → 200 with `[{project_id, pinned_at}, …]`
  (used by sidebar pin-widget if we add one in v2; not needed for /projects
  page itself since the tree response already carries `pinned`).
- `GET /api/projects/cards-preview` (PR 2 only) → 200 with `[{project_id,
  next_events: [...3], recent_verlauf: [...3], team_initials, team_count,
  last_activity_at}, …]`. Visibility-scoped server-side. Cached 5min per user.
  Optional `?ids=<csv>` to narrow to specific projects (used by
  IntersectionObserver lazy-fetch when a card scrolls into view, batched).

### 8.3 Service layer

`ProjectService.BuildTree` extended:

```go
type BuildTreeOptions struct {
    Scope             TreeScope    // ScopeAll | ScopeMine | ScopePinned
    StatusIn          []string     // empty = no narrowing
    TypeIn            []string     // empty = no narrowing
    HasOpenDeadlines  *bool        // nil = no narrowing
    SearchTerm        string       // empty = no search
    SubtreeCounts     bool         // true = aggregate; false = per-node
}

func (s *ProjectService) BuildTree(ctx, userID, opts) ([]*ProjectTreeNode, error)
```

Existing zero-arg call (no opts) keeps current behaviour via a default-options
shim — no breaking change.

`PinService` (new, ~40 LoC):
- `Pin(ctx, userID, projectID) error`
- `Unpin(ctx, userID, projectID) error`
- `IsPinned(ctx, userID, projectID) (bool, error)` (rarely needed; the tree
  response carries it)
- `ListPinned(ctx, userID) ([]uuid.UUID, error)` (for sidebar widget v2)

Pin/Unpin both gate on `can_see_project` — can't pin what you can't see.

## 9. Frontend changes

### 9.1 `frontend/src/projects.tsx`

Major rewrite. Strip the existing flat-table + 3-select toolbar. Replace with:

- Search input (full-width).
- Chip row: Alle / Nur meine / Angepinnt / Status (multi) / Typ (multi) /
  Mit aktiven Fristen / [⋯ Mehr] (overflow for v2 chips).
- Toolbar trailing: view-mode toggle: Tree | Liste (flat). Default Tree.
  Persists in localStorage.
- Tree container (current `#projekt-tree-container`) becomes the default render
  target. Flat container hidden by default.

### 9.2 `frontend/src/client/projects.ts`

Becomes a thin orchestrator: chip state, search state, view mode (tree|flat).
Delegates to:
- `client/project-tree.ts` for tree render + interactions.
- `client/projects-flat.ts` (new — extracted from current projects.ts table
  rendering).

State shape:
```ts
type ChipState = {
  scope: "all" | "mine" | "pinned";
  status: Set<string>;
  type: Set<string>;
  hasOpenDeadlines: boolean;
};
```

Query string ↔ state via `URLSearchParams`. `popstate` listener so back/forward
work intuitively.

### 9.3 `frontend/src/client/project-tree.ts`

Extended (no rewrite). Additions:
- Parse new response fields (`pinned`, `inherited_visibility`, `*_subtree`).
- Render pin star on every row; click toggles pin via `POST/DELETE /api/projects/{id}/pin`.
- Apply greyed style on `inherited_visibility: true` rows.
- Apply `match-kind` styling when search is active.
- Subtree count badges with hover-tooltip showing "(eigene: N / aus M
  Unterprojekten: K)".
- Mobile drill-in (≤ 768px breakpoint check + alternate render path).
- `?focus` and `?expand` URL-param handling on init.

### 9.4 `frontend/src/styles/global.css`

New `.projects-toolbar`, `.projects-chip-row`, `.projects-chip`,
`.projects-chip--active`, `.projects-search-input` (mostly copy-paste from
`.fristen-forum-chip` and `.fristen-search-chip`). New `.projekt-tree-pin-star`
styles. `.projekt-tree-row.is-inherited` for the greyed-ancestor look. Mobile
drill-in container `.projekt-tree-mobile`.

### 9.5 i18n

~30 new keys DE+EN under `projects.chips.*`, `projects.tree.pin.*`,
`projects.tree.subtree.*`, `projects.search.*`, `projects.empty.filtered.*`.

### 9.6 Sidebar pin widget (deferred)

A compact "Meine Mandanten" pin-list in the sidebar, mirroring the chip,
would be nice but is a v2 follow-on. Out of scope.

## 10. Implementation phasing

**Two-PR split** (added with Cards-view scope):

- **PR 1 — Tree-first redesign + chips + pinning + search.** ~1100-1400 LoC.
  - Migration 060 (`paliad.user_pinned_projects`, ~30 LoC).
  - PinService (~80 LoC) + handlers (~50 LoC).
  - `BuildTree` extension with `BuildTreeOptions` (~250 LoC) — chip filters,
    subtree counts, `?scope=mine` greyed-ancestor branch, `?q=…` server-side.
  - Frontend: `projects.tsx` rewrite (~200 LoC), `projects.ts` orchestrator
    (~250 LoC), `project-tree.ts` extensions (~200 LoC), `projects-flat.ts`
    extracted (~120 LoC), CSS for chips + pin star + greyed-ancestor + mobile
    drill-in (~120 LoC), i18n (~30 keys DE+EN).
  - Tests (~370 LoC).
  - Mergeable + deployable in isolation. Closes 14 of the 17 questions.

- **PR 2 — Project Cards view + drag-rearrange named layouts.** ~1300-1700
  LoC (bumped from ~700-900 by the locked Q21 = full drag-rearrange).
  - Migration 061 (`paliad.user_card_layouts`, ~40 LoC).
  - `CardLayoutService` (CRUD + auto-seed default + tx-flip-default) (~150 LoC).
  - Layout-JSON validator (`layout_spec.go`, fact-key registry, count
    bounds, title-row invariant) (~120 LoC).
  - `/api/projects/cards-preview` endpoint + service method (~200 LoC).
  - 5 `/api/user-card-layouts/*` handlers (~120 LoC).
  - `frontend/src/client/projects-cards.ts` (NEW, card grid render +
    layout dropdown + edit-mode drag-and-drop + IntersectionObserver lazy
    fetch + popover hover) (~600 LoC).
  - View-mode segment-control wiring in `projects.ts` orchestrator (~80 LoC).
  - `projects.tsx` adds card grid container + edit-mode toolbar (~120 LoC).
  - CSS for cards + popovers + edit-mode chrome + responsive grid (~180 LoC).
  - i18n (~50 keys DE+EN).
  - Tests (~250 LoC).

  Optional internal split if PR 2 review feels heavy: **PR 2a** ships cards
  read-only (server-default rich layout, no customisation) — ~600-800 LoC;
  **PR 2b** layers the drag-rearrange + named layouts on top — ~700-900 LoC.
  Decision deferred to coder shift.

Merge order: PR 1 → main → PR 2 (or 2a → 2b) → main. Each fits a normal
review window.

Recommended implementer: pattern-fluent Sonnet coder. Substrate is well-
trodden (project tree, pg_trgm, RLS table with owner-only policy, chip-row
component, IntersectionObserver, popover hover patterns from Fristenrechner).
NOT cronus per memory directive.

## 11. Trade-offs flagged

### 11.1 Subtree-aggregated counts are slightly more work for honest UX

Per-node counts (the current default) are cheap (one JOIN+GROUP BY) but
misleading on Mandant rows (always 0). Subtree counts need a CTE-with-ltree
self-join over `path <@`. Cost on paliad's data size: sub-millisecond. Risk:
on a future client with 500 cases, this becomes ~5ms per tree fetch. Mitigation:
materialised `paliad.project_subtree_counts` view if telemetry warrants. Not
v1.

### 11.2 "Nur meine" with greyed ancestors is slightly more complex than "Nur meine" without

The greyed-ancestor rule preserves context but adds a flag in the tree response
(`inherited_visibility: true`) and CSS. The simpler alternative: "Nur meine"
shows directly-staffed projects only as flat orphans (no ancestor chain).
Risk: users see a Case row with no Mandant context above it. Mitigation: the
ancestor chain is in the breadcrumb when they click into the project, so
context isn't lost — just one click further. Decision: greyed-ancestor wins
for v1; reversible.

### 11.3 In-place search vs Cmd-K modal

In-place keeps the user on the page. Modal is more "dedicated palette" feel.
Cmd-K is the universal palette (already exists), so the page's input being
Cmd-K's twin is redundant. In-place wins by mental model. But m may prefer
modal if "search the tree" is felt to be conceptually distinct from "pick a
project to navigate to."

### 11.4 No saved-views table for /projects in v1

If many users actually want named chip combos, we'll need a small table
later. Risk: small. The chip row + URL bookmarks cover the 90% case.
Reversible (additive new table when asked).

### 11.5 Drill-in mobile vs indented mobile

Drill-in is what every file manager does — discoverable, touch-friendly.
Trade-off: navigating from a deep node back to siblings of an ancestor takes
more taps than indented (one tap per level back). Mitigation: breadcrumb
chips at the top let you jump back to any level in one tap.

### 11.6 Cards view vs Tree view as the "really quick overview" surface

m's mental model puts cards in the lead for "really quick overview." But Tree
+ subtree-aggregated counts is also a quick-overview surface (you scan the tree,
see "12 offene Fristen" on Müller AG, you know it's hot). Cards add a layer
of *content* (the next 3 events) that Tree can't fit per-row.

Both views genuinely solve different needs:
- Tree = navigate and audit ("what's the structure?").
- Cards = scan recent / upcoming activity ("what should I look at?").

Risk: maintaining two views with two render paths. Mitigation: PR 2 builds on
PR 1's foundation (chip filtering, pinning, search) so the parallel surface
inherits 80% of behaviour. Card content is additive on top of project
metadata that Tree already has.

### 11.7 Cards customisation in localStorage vs server table

m chose option (c) — server-side `paliad.user_card_layouts` (migration 061)
with named layouts and drag-rearrange. Layouts follow the user across
devices automatically. Trade-off: more surface to ship, more UX to design
(layout dropdown, edit mode, drag-and-drop, save/discard semantics). Cost
carried because m's framing was "really quick overview" + "customize how we
show the project card" — preset-only / localStorage-only would have been a
weak version of that ask.

### 11.8 Cards-preview server cache TTL

5-minute TTL means a deadline added by another team member won't appear on
the requester's cards for up to 5 minutes. Risk: trivial — Cards is an
overview surface, not a real-time feed. Pull-to-refresh / page reload
invalidates immediately. Cache key includes user_id so per-user filtering
works correctly (different users see different visibility-scoped slices).

### 11.9 Custom Views vs bespoke split (Q15)

Bespoke = one more place that has chips and a tree, parallel to the events-
shaped `paliad.user_views`. The cost is one more component to maintain; the
benefit is shape-honest substrates that don't strain to share machinery.
Reversible if at some point a unifying abstraction does emerge — the data is
trivially portable (chips → FilterSpec).

## 12. Surfaced questions — m's locks (2026-05-07 22:08)

The 4 surfaced questions answered by m via AskUserQuestion this session:

1. **Q1 default landing + view mode → (d) Last-viewed restore.**
   sessionStorage holds last `{viewMode, chips, searchQuery, treeExpanded,
   cardLayoutId}`. URL params override on load. First-ever-visit fallback:
   Tree + "Alle" + top-level only expanded.
2. **New-Q20 Cards default content → (α) Rich (~9 facts).**
   Seed "Standard" layout = title + type + status + clientmatter + parent
   path + deadline counts + next 3 + last 3 + team. See §5b.4.
3. **New-Q21 Cards customisation → (c) Full drag-rearrange + named layouts.**
   New `paliad.user_card_layouts` table (migration 061). HTML5 drag-and-drop
   edit mode. CRUD endpoints. See §5b.3.
4. **Q13 search shape → (c) Both simultaneously.**
   Page input does in-place filter on the active view (Tree / Cards / Flat).
   Global Cmd-K palette stays unchanged. Page input does NOT also open Cmd-K.
   See §5.1.

All other 17 recommendations remain READY-FOR-REVIEW. m may challenge any
in the next pass. Recommendations m hasn't explicitly addressed:
- Q2 chip set (recommended 6 chips: Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen)
- Q3 combinatorics (AND across chips, OR within multi-select)
- Q4 tree state persistence (sessionStorage + ?expand=… URL)
- Q5 deep-link `?focus=<uuid>`
- Q6 lazy-loading (deferred — full tree fits today)
- Q7 inline counts (subtree-aggregated default, "nur direkt" toggle)
- Q8 affordances (always-visible pin star + click-row-open + no context menu)
- Q9 pin storage (paliad.user_pinned_projects, migration 060)
- Q10 pin display (recommended option (a) — star marker only)
- Q11 pins per-user (confirmed)
- Q12 search scope (title + parent path + reference + clientmatter)
- Q14 empty-state ("Filter zurücksetzen" + "Neues Projekt")
- Q15 Custom-Views integration (decision: bespoke /projects, see §6.1, §11.9)
- Q16 most-recent-activity sort (deferred — use /agenda or Custom View)
- Q17 mobile drill-in
- Q18 initial load size (full tree, lazy-load deferred)
- Q19 search responsiveness (200ms debounce, 200-result cap)
- "Nur meine" semantics (direct membership only; ancestors visible-greyed; §3.3)
- Cards behaviour: leaf-ish projects only by default; "Alle Ebenen" toggle (§5b.1)
- Cards-preview cache TTL (5min; §11.8)

## 13. Files implementer will touch

### PR 1 — Tree + chips + pin + search

Backend:
- `internal/db/migrations/060_user_pinned_projects.up.sql` (NEW, ~30 LoC)
- `internal/db/migrations/060_user_pinned_projects.down.sql` (NEW)
- `internal/services/pin_service.go` (NEW, ~80 LoC)
- `internal/services/project_service.go` (extend `BuildTree`, ~250 LoC)
- `internal/handlers/projects.go` (route registration, query-param parsing, ~80 LoC)
- `internal/handlers/pins.go` (NEW, ~50 LoC)
- `internal/handlers/handlers.go` (wiring)
- `cmd/server/main.go` (Services bundle gets PinService)
- `internal/services/project_service_test.go` (extend, ~250 LoC)
- `internal/services/pin_service_test.go` (NEW, ~120 LoC)

Frontend:
- `frontend/src/projects.tsx` (rewrite, ~200 LoC)
- `frontend/src/client/projects.ts` (rewrite as orchestrator, ~250 LoC)
- `frontend/src/client/project-tree.ts` (extend, +200 LoC)
- `frontend/src/client/projects-flat.ts` (NEW — extract current table render, ~120 LoC)
- `frontend/src/client/i18n.ts` (~30 keys DE+EN)
- `frontend/src/styles/global.css` (~120 LoC additions for chip row + pin star + greyed-ancestor + mobile drill-in)

### PR 2 — Cards view + drag-rearrange named layouts

Backend:
- `internal/db/migrations/061_user_card_layouts.up.sql` (NEW, ~40 LoC)
- `internal/db/migrations/061_user_card_layouts.down.sql` (NEW)
- `internal/services/layout_spec.go` (NEW, fact-key registry + JSON validator, ~120 LoC)
- `internal/services/card_layout_service.go` (NEW, CRUD + auto-seed
  default + tx-flip-default, ~150 LoC)
- `internal/services/project_service.go` (add `CardsPreview(ctx, userID,
  projectIDs) ([]*ProjectCardPreview, error)`, ~200 LoC)
- `internal/handlers/projects.go` (add `/api/projects/cards-preview`, ~50 LoC)
- `internal/handlers/card_layouts.go` (NEW, 5 endpoints, ~120 LoC)
- `internal/handlers/handlers.go` + `cmd/server/main.go` (wiring)
- `internal/services/card_layout_service_test.go` (NEW, ~150 LoC)
- `internal/services/layout_spec_test.go` (NEW, ~80 LoC)
- `internal/services/project_service_test.go` (extend, ~150 LoC)

Frontend:
- `frontend/src/client/projects-cards.ts` (NEW, card grid + layout dropdown
  + edit-mode drag-and-drop + IntersectionObserver + popovers, ~600 LoC)
- `frontend/src/projects.tsx` (add card grid container + edit-mode toolbar,
  ~120 LoC)
- `frontend/src/client/projects.ts` (extend orchestrator with view-mode
  segment-control + last-view restore, ~80 LoC)
- `frontend/src/client/i18n.ts` (~50 new keys DE+EN)
- `frontend/src/styles/global.css` (~180 LoC additions for cards grid +
  popovers + edit-mode chrome + responsive layout)

## 14. Q15 decision rationale (delegated to inventor by m)

Decision: **option (a) — bespoke /projects, hardcoded system chips**.

Reasoning:
1. Custom Views is event-shaped (`AllSources = {deadline, appointment,
   project_event, approval_request}`). Projects are NOT events; they're scope.
2. Custom Views' `RenderShape × DataSource` orthogonality (any shape × any
   source) breaks for projects (projects only ever render as tree or flat
   list, never as cards or calendar).
3. Adding `SourceProject + ShapeTree` would be a parallel substrate sharing
   only the `paliad.user_views` table — that's not unification, that's
   coexistence with extra coupling.
4. The "view selector" m wants is satisfied by the chip row + URL state in
   v1; if/when users ask for "save this chip combo as a named view," a small
   `paliad.user_project_views` table is a v2 addition.

Reversibility: high. The `paliad.user_pinned_projects` table is a single small
table with one obvious shape; the chip row state schema is stable and
serialisable. If a unifying abstraction emerges later, the data ports trivially.

## 15. Out of scope (v1)

- Drag-to-reorder projects (data-driven, not user-orderable).
- Bulk operations on multiple projects.
- Project-level analytics dashboards.
- Cross-project rollups (covered by /events / /agenda / Custom Views).
- Sharing pinned-list with others.
- Persistent expand state across sessions (sessionStorage suffices for v1).
- Most-recent-activity tree sort (use /agenda or a Custom View).
- Saved-views table for /projects (chips + URL bookmarks suffice for v1).
- Lazy-loading the tree (full tree fits today).
- Sidebar pin widget (could land alongside or right after).
- Firm-wide card layout templates ("Was unsere Senior-PAs sehen") — v2.
- Per-project-type default layouts (different for Mandant vs Case) — v2.
- Cross-user layout sharing — v2.
- Real-time card refresh (5min TTL on the server cache; reload to refresh).

## 16. References

- `paliad.projects` — schema with `path ltree`, hierarchical
- `frontend/src/projects.tsx` + `client/projects.ts` — current /projects page
- `frontend/src/client/project-tree.ts` — existing tree renderer (reused)
- `paliad.can_see_project()` — visibility predicate
- t-139 hierarchy aggregation — descendant-count primitives the new tree extends
- t-144 Custom Views — the substrate /projects deliberately does NOT
  piggy-back on (Q15 rationale §6.1, §14)
- `frontend/src/components/Sidebar.tsx` — has its own mini-tree for nav;
  CSS hooks reusable
- `frontend/src/client/project-indent.ts` — existing indented-list helper
  (out of scope for v1; flat list reuses the table view)

---

**Next step:** await m's answers on §12. Lock the design after responses.
Coder shift after lock.
