Files
paliad/docs/design-inbox-overhaul-2026-05-25.md
mAi 2683c5f9cf docs(inbox): t-paliad-249 — inbox overhaul inventor design (m/paliad#80)
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
2026-05-25 15:33:36 +02:00

40 KiB
Raw Blame History

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:320345'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:344430.

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:

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

// 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:4658 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:107141 for the <div className="events-view-selector"> block and client/events.ts:617650 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:

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

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:

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:

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

And register it in KnownRowActions.

Frontend (shape-list.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:651700 (.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 handleInboxMarkAllSeenUPDATE 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.