# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles

**Task:** t-paliad-249
**Gitea:** m/paliad#80
**Author:** icarus (inventor)
**Date:** 2026-05-25
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
**Branch:** `mai/icarus/inventor-inbox-overhaul`

---

## 0. TL;DR

`/inbox` today is approval-requests only. m wants it to become the actual
"what's new on my projects" surface — approval requests **plus** recent
project_events on visible projects — with the same view-toggle paradigm
as `/events` (list / cards / calendar) and a meaningful filter row.

The good news: the substrate already exists.

- `view_service.RunSpec` unions four sources (deadline, appointment,
  **project_event**, **approval_request**) into one ranked `[]ViewRow`.
- `FilterSpec` has predicates for every axis we need
  (`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
- `filter-bar` knows the axes we need: `time`, `project`,
  `approval_viewer_role`, `approval_status`, `approval_entity_type`,
  `project_event_kind`, plus `shape` / `sort` / `density`.
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
  (day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).

So the work is **mostly re-mix**:

1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
   `Sources=[ApprovalRequest, ProjectEvent]`, default
   `Time.Horizon=Past30d`, and add a curated `project_event.event_types`
   default that filters out noise (approvals duplicate-suppression,
   checklist mutations, status churn).
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
   every row is an approval — rename it `"inbox"`, dispatch per
   `row.kind` (approval → existing approve-card layout; project_event →
   navigate-style stream row).
3. Wire the existing view-axis selector (the chip cluster on `/events`)
   onto `/inbox`'s host, persisting selection via the filter-bar URL
   codec (axis `shape` already in `AxisKey`).
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
   `POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
   unseen project_events too. Adds one new axis `unread_only` to the bar.

That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
is per-item dismissal — keep out of v1 unless the cursor proves not
enough (m's pick Q3 is the cursor).

No new aggregation service, no new endpoint family — the inbox runs on
`/api/views/inbox/run` like every other system view does today.

---

## 1. Current `/inbox` state

**Routes (`internal/handlers/approvals.go`):**

| Path                                  | Behaviour                                                    |
|---------------------------------------|--------------------------------------------------------------|
| `GET /inbox`                          | Serves `dist/inbox.html`, a thin shell. No SSR data.         |
| `GET /api/inbox/pending-mine`         | Approval requests I can approve.                              |
| `GET /api/inbox/mine`                 | Approval requests I submitted (all statuses by default).      |
| `GET /api/inbox/count`                | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
| `GET /api/approval-requests/{id}`     | Hydrate one request (used by suggest-changes modal).          |
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`.    |

**Data path:** `frontend/src/client/inbox.ts` mounts the universal
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
The bar fetches `/api/views/system`, hands the spec to itself, calls
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
`renderApprovalList(rows)` path (gated by `row_action="approve"`).

**Action wiring:** `wireApprovalActions(host)` listens on
`.views-approval-action` clicks; on success it triggers
`bar.refresh()` and `refreshInboxBadge()` (which pokes
`/api/inbox/count`).

**Empty state + admin nudge:** when the result list is empty AND the
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
the page shows a "configure policies" CTA. Otherwise the localized
"no items" empty-state text.

**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
plus `client/sidebar.ts:320–345`'s `initInboxBadge` which polls
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.

### What aggregates cleanly

The whole approval flow already plugs into `RunSpec`'s union pipeline.
That's the win — extending sources from `[ApprovalRequest]` to
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
`InboxSystemView()` and the engine fans out per source, sorts, returns
one `[]ViewRow`. The hard work (`runProjectEvents` + the
visibility predicate + project metadata join) is already in
`view_service.go:344–430`.

### What doesn't aggregate (yet)

- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
  via information_schema). The bell badge counts pending **approval
  requests for the caller** only — it has no notion of "new project
  events since last visit". We have to add it.
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
  every row is an approval and unconditionally parses
  `row.detail` as an `ApprovalDetail`. Project_event rows in the same
  list would crash the parse. We need to branch per `row.kind` inside
  the inbox row stamper.
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
  the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
  `INBOX_AXES` deliberately omits it (because today the only meaningful
  shape was list). Adding it onto INBOX_AXES + a small dispatcher in
  `onResult` gives us cards + calendar for free.

Everything else (sidebar entry, /api/views machinery, FilterBar URL
codec, RowAction validation) carries through unchanged.

---

## 2. Event-type catalogue for inbox v1 (Q1)

This is the only design pick that requires a head/m signal. **Open
question Q1 in §9 — defaulting to (A) until head answers.**

### (R) Recommendation (A): curated subset

Sources: `[approval_request, project_event]`.

**Approval requests:** all rows whose `viewer_role=any_visible` AND
status ∈ {pending} by default; the existing chip cluster
(approver_eligible / self_requested / any_visible) stays. Decided
requests are filtered by the chip, not hidden by source-removal — so a
user who wants to see "what got approved this week" toggles the status
chip rather than the source.

**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:

| event_type              | In inbox v1? | Reason                                                              |
|-------------------------|--------------|---------------------------------------------------------------------|
| `project_created`       | no           | The author already saw the page; not news to the team yet (the team grows post-creation). |
| `project_archived`      | **yes**      | High-signal lifecycle event ("Akte XY wurde archiviert").           |
| `project_reparented`    | **yes**      | Hierarchy moves matter to everyone with access.                     |
| `project_type_changed`  | **yes**      | Same reason.                                                        |
| `status_changed`        | no           | Currently too granular; surface in Verlauf, revisit if m disagrees. |
| `deadline_created`      | **yes**      | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
| `deadline_completed`    | **yes**      | Likewise.                                                           |
| `deadline_reopened`     | **yes**      | Likewise.                                                           |
| `deadline_updated`      | **yes**      | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
| `deadline_deleted`      | **yes**      | Likewise — add to KnownProjectEventKinds.                           |
| `deadlines_imported`    | **yes**      | Bulk-import event surfaces what got added.                          |
| `appointment_created`   | **yes**      |                                                                     |
| `appointment_updated`   | **yes**      |                                                                     |
| `appointment_deleted`   | **yes**      |                                                                     |
| `note_created`          | **yes**      | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
| `our_side_changed`      | **yes**      | Party-side flip; high-signal, add to KnownProjectEventKinds.        |
| `member_role_changed`   | no           | Admin churn; would dominate active users' inbox. Revisit slice B. |
| `*_approval_requested`  | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
| `approval_decided`      | no           | This is the umbrella audit-only kind; superseded by the approval_request row. |
| `checklist_*`           | no           | Low signal; checklists are surfaced on the project's checklist page. |

The de-dup pattern means: if a row exists in `approval_requests` for an
entity, the corresponding `*_approval_*` project_event is **not** shown
in the inbox — we trust the approval_request row.

### Alternative (B): everything in KnownProjectEventKinds + approvals

Simpler — no curated sub-list, no de-dup. Two drawbacks:

1. `*_approval_*` duplicates would render twice per request.
2. `status_changed` and `member_role_changed` are admin churn; in firm
   tests both would dominate.

If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
the inbox renders the same fact twice.

### Alternative (C): minimal — approvals + appointment_* + deadline_*

Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
brief literally says "new events that relate to one's projects" — notes
and side changes ARE such events. C feels too narrow.

---

## 3. Read/unread model (Q3 → R: high-watermark cursor)

### (R) Decision: per-user high-watermark `inbox_seen_at`

**Schema:**

```sql
ALTER TABLE paliad.users
    ADD COLUMN inbox_seen_at timestamptz NULL;
```

NULL means "never visited" → everything counts as unread. The high-water
cursor advances exactly when the user POSTs to
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
+ implicit advance on page-mount, see Slice A wiring below).

### Why cursor, not per-item

m's recommendation: cursor. Mine matches: single column, no fan-out
table, covers the common case ("I checked my inbox, mark everything
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
inadequate. The risk we're guarding against: a single high-value pending
approval that's a week old gets buried by 80 fresh deadline_updated
events; the user clears the badge and may now never look at the
approval. Mitigation: **approval_requests with status=pending never
fall behind the cursor** — they count toward the badge regardless of
seen_at. This is a tiny conditional in the count query (Slice A).

### Cursor advance behaviour

- **Explicit:** "Alles als gelesen markieren" button in the inbox
  header. POSTs `/api/inbox/mark-all-seen`; server sets
  `inbox_seen_at = now()`.
- **Implicit:** when the page mounts AND the bar surfaces at least one
  row that's newer than the current cursor, the *new* cursor is
  remembered locally as the timestamp of the **newest visible row**.
  We do **not** auto-advance the server cursor on mount — too easy to
  lose items behind a stray pageview. The "neu" highlight on rows
  newer than the saved cursor is the silent UX. Explicit click is the
  one and only path to clearing the badge.

### `unread_only` axis

New filter-bar axis (Slice A):

```ts
// types.ts
unread_only?: boolean;
```

When `true`, the bar overlays a FilterSpec predicate:
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
that's `pe.created_at > $cursor`, for approval_requests that's
`requested_at > $cursor` OR `status='pending'` per the carve-out above).

Default: **unread_only=true** for first paint (per Slice A — landing on
the inbox shows you what's new). The "Alle" chip flips it off so the
user can see history.

---

## 4. Filter contract

The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
`client/inbox.ts`):

| Axis                     | Why on /inbox                                                        | New? |
|--------------------------|----------------------------------------------------------------------|------|
| `time`                   | "Last 30 days" (default) with chip cluster + "Älter anzeigen" .       | already |
| `project`                | Single-select autocomplete from visible projects.                    | already |
| `approval_viewer_role`   | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren".            | already |
| `approval_status`        | pending / approved / rejected / revoked / changes_requested.         | already |
| `approval_entity_type`   | Frist / Termin (chip pair).                                          | already |
| `project_event_kind`     | Chip cluster over InboxProjectEventKinds.                            | already |
| **`unread_only`**        | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen.    | **Slice A new axis** |
| `shape`                  | list / cards / calendar.                                             | already in `AxisKey`, not yet on `/inbox` |
| `sort`                   | Newest first (default) / oldest first.                               | already |
| `density`                | comfortable / compact.                                               | already |

**Default landing state** for a brand-new pageview:
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.

Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
still work because `client/inbox.ts:46–58` already applies the legacy
tab → `a_role` redirect at hydration.

### Source-removal not exposed as an axis

Users do **not** see a "show approvals only / show events only" chip.
The signal we want is "what's new across my projects"; splitting the
two via the filter row is busywork. If they want approvals-only they
chip-pick `project_event_kind` empty + status=any (or future axis pick
`source=approval_request`). If feedback shows otherwise after Slice A
ships, we add the axis in Slice B trivially (`Sources` is a
spec.Sources literal flip).

---

## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)

The pattern `/events` uses today (see `frontend/src/events.tsx:107–141`
for the `<div className="events-view-selector">` block and
`client/events.ts:617–650` for the `applyView` function):

- One chip cluster `data-event-view="cards|list|calendar"`.
- Active class toggle.
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
  hosts.
- For calendar, `mountCalendar()` constructs a month/week/day grid
  into a dedicated `events-calendar-wrap` host; the handle is destroyed
  on shape-leave so its URL state doesn't leak into the other shapes.

### Mapping onto /inbox

The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
a per-page selector.** The axis already round-trips into the URL via
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
just needs:

1. Add `"shape"` to `INBOX_AXES`.
2. Dispatch in the `onResult` callback by `effective.render.shape`:

   ```ts
   onResult: (result, effective) => {
     switch (effective.render.shape) {
       case "cards":    return paintCards(result.rows, effective.render, ...);
       case "calendar": return paintCalendar(result.rows, ...);
       case "list":
       default:         return paintList(result.rows, effective.render, ...);
     }
   }
   ```

3. The renderers exist already: `renderCardsShape` in
   `views/shape-cards.ts`, `renderCalendarShape` in
   `views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
   The only piece of new code is the per-shape host-clearing on switch
   (so we don't leak a stale shape's DOM into the new host).

### Calendar shape — items without dates

Calendar can only render rows with a calendar-mappable date. Today:

- **approval_request:** `requested_at` (timestamp). Maps fine, but
  shows up as a single point — rendering an approval-request on a month
  grid is semantically "you got asked on this day". OK for v1.
- **project_event:** `created_at`. Same shape.
- **deadline:** `due_date`. Already supported.
- **appointment:** `start_at`. Already supported.

So every row in the inbox v1 has a calendar position. No
need to filter rows on calendar-mount. **One caveat:** the calendar
shape currently doesn't render action affordances (approve/reject) — it
opens a detail dialog on click. Slice B accepts that: clicking an
approval row on the calendar opens the inbox-list-style detail in a
modal (re-using the existing per-row /api/approval-requests/{id}
fetch). Out of scope for Slice A.

### Cards shape — day-grouped chronological cards

`shape-cards.ts` groups by day and renders one card per row, with
title + meta + actor. The approval-card layout there is the standard
card (no approve buttons — same caveat as calendar). For Slice B, we
extend `shape-cards.ts` to detect `row.kind === "approval_request"
&& row.detail.status === "pending"` and stamp the approve/reject button
strip inline. The DOM template is the same as
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
template into a shared util.

---

## 6. Backend aggregation service (Q6 → R: reuse RunSpec)

**Decision: do not build a new aggregation service.** The
substrate-level work is exactly two edits:

### 6.1 InboxSystemView (system_views.go:103–144)

```go
func InboxSystemView() SystemView {
  return SystemView{
    Slug: "inbox",
    Name: "Inbox",
    Filter: FilterSpec{
      Version: SpecVersion,
      Sources: []DataSource{
        SourceApprovalRequest,
        SourceProjectEvent,
      },
      Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
      Time:  TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
      Predicates: map[DataSource]Predicates{
        SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
          ViewerRole: "any_visible",
          Status:     []string{"pending"}, // default; bar can override
        }},
        SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
          EventTypes: InboxProjectEventKinds, // curated subset
        }},
      },
    },
    Render: RenderSpec{
      Shape: ShapeList,
      List: &ListConfig{
        Density:   DensityComfortable,
        Sort:      SortDateDesc, // newest first — different from today's date_asc
        RowAction: RowActionInbox, // new — see §6.3
      },
    },
  }
}
```

Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:

```go
var InboxProjectEventKinds = []string{
  "project_archived", "project_reparented", "project_type_changed",
  "deadline_created", "deadline_completed", "deadline_reopened",
  "deadline_updated", "deadline_deleted", "deadlines_imported",
  "appointment_created", "appointment_updated", "appointment_deleted",
  "note_created", "our_side_changed",
}
```

(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
list and remove the `EventTypes` predicate. If head picks C, narrow the
list to deadline_* + appointment_* only.)

KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
`deadlines_imported` are valid filter values — without this the
validator rejects the InboxSystemView spec. Migrate this list at the
same time. (`event_categories` and similar grouping infra are already
covered by `event_category_service.go` and won't move.)

### 6.2 Approval-duplicate suppression

In `view_service.runProjectEvents` (or in a tiny new predicate helper),
skip `event_type LIKE '%_approval_%'` when source-set includes
ApprovalRequest. This avoids the double-count described in Q1 §2.

Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
auto-drop the `*_approval_*` strings when the same RunSpec already
fans out the approval_request source. One conditional, six lines.

### 6.3 Mixed-row row_action

`shape-list.ts` today: `row_action="approve"` → calls
`renderApprovalList(rows)` which assumes every row is an approval.
Need a new value:

```go
// render_spec.go
const RowActionInbox ListRowAction = "inbox"
```

And register it in `KnownRowActions`.

Frontend (`shape-list.ts`):

```ts
if (rowAction === "inbox") {
  host.appendChild(renderInboxList(sorted));
  return;
}
```

Where `renderInboxList(rows)`:

- approval_request rows → existing `renderApprovalRow(row)` template (the
  per-row factor-out from `renderApprovalList`).
- project_event rows → a new `renderProjectEventRow(row)` template:
  timestamp + actor + title + project chip + optional "Öffnen" link
  to the underlying entity (deadline / appointment / note / project
  detail). Modelled on the Verlauf row in
  `client/projects-detail.ts:651–700` (`.entity-event` markup).

This makes the inbox stamping kind-aware. The
existing `wireApprovalActions` continues to find buttons via class
`.views-approval-action` and works unchanged.

### 6.4 Endpoints — what's new vs reused

| Path                                | Behaviour                                                | Slice |
|-------------------------------------|----------------------------------------------------------|-------|
| `GET /api/views/inbox/run`          | **Already exists** — fans the InboxSystemView spec.     | A reuse |
| `GET /api/inbox/count`              | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
| `POST /api/inbox/mark-all-seen`     | New. Sets `users.inbox_seen_at = now()` for the caller.  | A |
| `GET /api/inbox/pending-mine`       | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
| `GET /api/inbox/mine`               | **Keep** — used by the saved view `inbox-mine`.         | unchanged |

The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
narrower-than-RunSpec optimisations and used by the dashboard's
`loadInboxSummary`. No reason to remove them.

### 6.5 InboxSummary on the dashboard (out of scope, but flag)

`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
only pending approvals. If Slice C extends the badge count to include
unread project_events, the dashboard widget also needs to swap
`PendingCountForUser` for the new unified count — keep this as a small
follow-up after Slice A ships and the cursor semantics are proven.

---

## 7. Slice plan

### Slice A — Project-event aggregation + read cursor + list view

**Goal:** /inbox shows pending approvals + curated project_events for
visible projects in the last 30 days, with the new "Nur ungelesen"
toggle. List view only.

Tasks:

1. **Migration `NNN_inbox_seen_at.up.sql`:**
   `ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
   `note_created`, `our_side_changed`, `deadline_updated`,
   `deadline_deleted`, `deadlines_imported`). Add
   `InboxProjectEventKinds` (curated subset, Q1=A).
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
   both sources, `HorizonPast30d`, `SortDateDesc`,
   `RowAction=RowActionInbox`.
4. **`render_spec.go`:** add `RowActionInbox`, register in
   `KnownRowActions`.
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
   `*_approval_*` event_types when ApprovalRequest is in
   `spec.Sources` (§6.2).
6. **`approvals.go`:**
   - New handler `handleInboxMarkAllSeen` →
     `UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
   - Modify `handleInboxCount` to return
     `pending_approvals_count + unread_project_events_count`. SQL
     in approval_service.go: one new method
     `UnseenInboxCountForUser(userID)` returning that union. Keep
     `PendingCountForUser` (dashboard still uses it).
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
   `renderApprovalList`. Add `renderInboxList(rows)` that dispatches
   per `row.kind`. Wire `row_action="inbox"` to it.
8. **`client/inbox.ts`:**
   - Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
     overlay (sub-spec `Time.Horizon=Past30d` AND
     filter predicate "newer than cursor OR pending-approval").
   - Render "Alles als gelesen markieren" button in the page header
     (in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
     refresh bar + badge.
   - Listen for cursor update (server response) and refresh.
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
   path, but the new server count includes project_events. Add no client
   changes for v1 — server returns the wider count.
10. **i18n:** new keys —
    - `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
      header (since the page is now more than approvals).
    - `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
      Genehmigungen.").
    - `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
    - `inbox.axis.unread_only.on/off`.
    - `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
    - `views.col.event_kind` (for the kind column in
      table-density list).
    - DE primary, EN secondary, both in `i18n.ts`.
11. **Tests:** `system_views_test.go` covers the
    InboxSystemView spec shape; new test for the de-dup helper in
    view_service. `approval_service_test.go` adds tests for the new
    `UnseenInboxCountForUser` method. New
    `inbox_seen_at_test.go` covers the cursor migration + the POST
    handler.
12. **Verify** the page renders for a sample user with both event types
    visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
    badge, the project-events deduplicate against approval requests.

### Slice B — Cards + calendar shape toggles

**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
switch via the bar's shape chip. Approval rows on cards/calendar are
*read-only* (open detail modal on click; no inline approve/reject).

Tasks:

1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
   per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
   matching the `/events` pattern. Implement `onResult` dispatch.
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
   `row.detail.status==="pending"`, stamp the approval row template
   inline. Hoist the template out of `shape-list.ts` if reuse pays.
3. **`shape-calendar.ts`:** approval_request rows render as date-point
   chips; click opens a detail modal. The modal reuses the existing
   `approval-edit-modal` for suggest-changes when the user is the
   approver; otherwise a read-only summary.
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
   coexist on the cards view without z-index clashes; lightweight
   targeting via `.views-cards-list[data-surface="inbox"]`.
5. **Tests:** shape toggle persistence via URL codec (already covered
   in `url-codec.test.ts`; add one inbox-surface case).

### Slice C — Badge upgrade + per-item dismiss (deferred)

**Goal:** sidebar badge reflects unified count; per-item dismiss for
power-users.

Tasks:

1. **`paliad.inbox_dismissals` table** —
   `(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
   "source" is `approval_request` / `project_event`; "row_id" is the
   row's UUID. New endpoint `POST /api/inbox/dismiss` body
   `{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
   `UnifiedInboxSummary` that mirrors the page count. Backwards-compat
   JSON: keep old fields, add `total_count` and `top_unified`.
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
   one of the excluded event_types is actually wanted, this slice opens
   up `InboxProjectEventKinds` accordingly.

### Why Slice A alone is shippable

Slice A delivers m's full ask except the cards/calendar views — which
are aesthetic shape toggles, not data changes. Slice A gives:

- Inbox feed across approvals + project_events for visible projects
- Project / type / time / read-state filters
- Newest-first list with mark-all-seen
- Sidebar badge reflects unified unread count (server-side)

Slice B + C are layer cake on top with no schema or substrate changes.

---

## 8. Out of scope

- **Push notifications.** Telegram / WhatsApp / email — different
  channel concerns, separate design.
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
  A wants it, opens its own design.
- **Paliadin chat unread.** Not part of project_events; paliadin lives
  in its own pane. Slice C could surface a banner if asked.
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
  They stay because the dashboard's `loadInboxSummary` uses them and
  no benefit to consolidating.
- **Detail-page changes.** Clicking a project_event row in the inbox
  navigates to the existing entity detail page (deadline, appointment,
  note); we don't build a new "event detail" view.
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
  it; for now the widget keeps showing approval-only.

---

## 9. Open questions for m

Defaulted to (R) per the inventor protocol — only **Q1** is escalated
to head for explicit confirmation because it changes the
inbox's surface area. Everything else falls to the recommended pick
unless head/m flag otherwise.

**Q1 — Event-type catalogue (material pick, head answered):**
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
`member_role_changed` to the curated list with a Slice B narrowing
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
decision recorded in §12.

**Q2 — Time window:** (R) Past30d default + chip cluster
(today / past_7d / past_30d / past_90d / any) + custom range via the
existing time picker. Locked unless head overrides.

**Q3 — Read/unread model:** (R) High-watermark cursor
(`users.inbox_seen_at`). Pending approval_requests carry forward even
when older than the cursor — guards against burying a high-value
approval. Per-item dismiss is Slice C, opt-in. Locked.

**Q4 — Filters surfaced on the bar:** (R) time / project /
approval_viewer_role / approval_status / approval_entity_type /
project_event_kind / unread_only / shape / sort / density. Locked
unless head wants `source` (approvals-only vs events-only chip)
added — defaulting to "not in v1".

**Q5 — View toggle parity with /events:** (R) list (default — newest
first) / cards (day-grouped) / calendar (date-point). Wired via the
filter-bar's existing `shape` axis, not a per-page selector. Locked.

**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
sources in the InboxSystemView spec; no new aggregation service.
Approval-event de-dup applied in `runProjectEvents`. Locked.

**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
`/api/inbox/count` return the unified unread count; sidebar badge
client unchanged. Locked.

**Q8 — Acknowledgement flow:** (R) Approval rows keep
approve/reject/revoke buttons inline (list shape only). project_event
rows have no inline action — click row → navigate to the underlying
entity. Cursor advance is via "Alles als gelesen markieren" only —
no per-row mark-read in v1. Locked.

**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
existing admin nudge for unseeded approval_policies stays untouched.
Locked.

---

## 10. Risks + mitigations

- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
  user-call; with two sources unioned + 30-day window + visibility
  predicate this should stay under 50ms on the live shape (project
  count ~100, events/day low double digits). If
  it doesn't, partial index hint: `paliad.project_events (created_at DESC)
  WHERE event_type IN (curated list)` — Slice A optional, add if
  EXPLAIN shows a seq scan in dev.
- **De-dup correctness.** Suppressing `*_approval_*` events in the
  project_event source relies on the approval_request row being the
  authoritative signal. **Edge case:** a request gets revoked, then
  re-requested — both audit events exist. Both correspond to a single
  approval_request row at any moment (the latter via the partial-index
  upsert). De-dup stays valid.
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
  the second wins (now() wins). Acceptable. If a user reads in tab A
  then clicks an item in tab B that was created between the two reads,
  tab A's "Alles als gelesen" advances past that newer item without
  the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
  an optional `?up_to=<iso>` so the client can pin to the timestamp of
  the newest visible row. Slice A wires this.
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
  `renderApprovalList` risks regressions on the *current* /inbox. Cover
  with a snapshot/golden test on the approval row markup in Slice A
  before the dispatch change.
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
  project_events, the count can easily exceed 100. Keep the "9+" clamp
  for visual reasons — but make the page header show the *exact* count
  ("123 neu") so the user knows what's behind it.
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
  starts, the (R) pick A locks. If head later picks B or C, the only
  change is the `InboxProjectEventKinds` list literal in
  `filter_spec.go` — no schema impact, no migration change. Cheap to
  flip.

---

## 11. Build/test verify list (Slice A done-when)

1. `make build` clean.
2. `go test ./...` passes; new tests cover:
   - InboxSystemView spec shape includes both sources + curated kinds.
   - `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
   - `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
   - POST `/api/inbox/mark-all-seen` updates the column.
   - URL codec round-trip for `unread_only` axis.
3. Inbox loads at `/inbox` with project-event rows interleaved with
   approval rows in date-desc order.
4. "Nur ungelesen" chip toggles between unread (with pending-approval
   carve-out) and full feed.
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
   badge clears (except for any still-pending approvals).
6. Sidebar bell badge count is the unified number (approval + unread events).
7. Existing approve/reject/revoke + suggest-changes flows on inbox
   rows still work unchanged.
8. `?tab=mine` legacy redirect still hits the right state.
9. Bilingual labels render (DE/EN toggle).

That's the doneness bar for Slice A.

---

## §12 — m's decisions (head 2026-05-25 11:30)

Head replied to the `mai instruct head` escalation; folded in below.

**Q1 (Event-type catalogue): A — locked.** Curated subset with
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
that relate to one's projects"), avoids double-counting approval audit
events against the approval_request row.

Locked InboxProjectEventKinds:

- IN: `project_archived`, `project_reparented`, `project_type_changed`,
  `deadline_created`, `deadline_completed`, `deadline_reopened`,
  `deadline_updated`, `deadline_deleted`, `deadlines_imported`,
  `appointment_created`, `appointment_updated`, `appointment_deleted`,
  `note_created`, `our_side_changed`, **`member_role_changed`**
  (added by head — see refinement #1).
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
- OUT (too granular / authoring noise): `status_changed`,
  `project_created`, `checklist_*`.

**Refinement 1 — `member_role_changed` visibility predicate.**
Head wants this kind included but narrowed: surface the row only when
the role change applies to the **viewer themselves** or someone above
them in the project tree (i.e. impacts the viewer's permissions / chain
of command), not when it's a peer's role changing on a project the
viewer happens to see.

- Slice A: include `member_role_changed` in
  `InboxProjectEventKinds` without the narrowing predicate. The row
  will appear for everyone who can see the project — over-surfacing but
  not wrong. This keeps Slice A's MVP scope tight.
- Slice B: add a per-row narrowing filter on top of the inbox source
  (likely a small extension to `runProjectEvents` that, when
  `event_type='member_role_changed'`, inspects `metadata.affects_user_id`
  + walks the project-membership predicate before emitting). The
  metadata shape is already written by the responsible handler; verify
  + lock the filter in B.

Q2-Q9 all default to (R) per the inventor protocol.

**Refinement 2 — Filter chip copy.**
For the visible chip cluster in the bar, head wants user-readable groupings,
not raw event-kind names. The bar today exposes `project_event_kind`
as one chip per kind (rendered via the
`event.title.<kind>` i18n key). For the inbox surface, surface a
**coarser grouping chip cluster** ahead of that:

- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
  approval_entity_type=appointment slice of approvals.
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
  approval_entity_type=deadline slice of approvals.
- "Alles" — default; both sources, full curated kinds list.

Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
the lower-level `project_event_kind` chip's *default visibility* in the
inbox UI; advanced users still see `project_event_kind` if they expand
the bar). The four values map to FilterSpec overlays that tweak
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
final copy and the placement (probably first axis in `INBOX_AXES`).

The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
advanced override for power users — when active, it overrides the
`inbox_focus` chip's per-kind defaults.

---

### What changes for Slice A as a result

Doc deltas vs the draft text above:

1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
   Note Slice B narrowing follow-up.
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
   (4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
   "Alles". `project_event_kind` stays available as an advanced chip,
   visible after the user expands the bar's overflow section.
3. **§7 Slice A task list:** add task —
   "**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
   `axes.ts`). FilterSpec overlay translates the chip value to a
   `(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
   triple. URL codec round-trips."
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
   predicate is in place; rows surface only when the change affects
   the viewer's permissions chain."

No schema changes from the head's adjustments. The `inbox_focus` axis
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
schema moves.
