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.
1251 lines
57 KiB
Markdown
1251 lines
57 KiB
Markdown
# 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.
|