Files
paliad/docs/design-projects-page-2026-05-07.md
m 438e73fd13 docs(t-paliad-149): renumber migrations 058→060 (PR 1) and 059→061 (PR 2)
058 = paliadin_poc (t-146), 059 = profession_vs_responsibility (t-148), both shipped on main 2026-05-07. Next available is 060.

Per maria's coder-shift instruction.
2026-05-07 22:15:22 +02:00

57 KiB
Raw Permalink Blame History

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:245GET /api/projects/tree returns the full nested tree (ProjectService.BuildTree).
  • internal/services/project_service.go:319BuildTree 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:

{
  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."

/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).

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:

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):

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:34AllSources = {deadline, appointment, project_event, approval_request}. Projects are NOT a source.
  • internal/services/render_spec.go:31AllShapes = {list, cards, calendar}. No tree shape.
  • internal/services/view_service.goRunSpec 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):

-- 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:

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:

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

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.