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.
40 KiB
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.RunSpecunions four sources (deadline, appointment, project_event, approval_request) into one ranked[]ViewRow.FilterSpechas predicates for every axis we need (ProjectEventPredicates.EventTypes,ApprovalRequestPredicates).filter-barknows the axes we need:time,project,approval_viewer_role,approval_status,approval_entity_type,project_event_kind, plusshape/sort/density.- Shape renderers exist:
shape-list(table + compact + approval),shape-cards(day-grouped),shape-calendar(thin adapter onmountCalendar).
So the work is mostly re-mix:
- Extend
InboxSystemViewfromSources=[ApprovalRequest]toSources=[ApprovalRequest, ProjectEvent], defaultTime.Horizon=Past30d, and add a curatedproject_event.event_typesdefault that filters out noise (approvals duplicate-suppression, checklist mutations, status churn). - Extend
shape-list.tssorow_action="approve"no longer assumes every row is an approval — rename it"inbox", dispatch perrow.kind(approval → existing approve-card layout; project_event → navigate-style stream row). - Wire the existing view-axis selector (the chip cluster on
/events) onto/inbox's host, persisting selection via the filter-bar URL codec (axisshapealready inAxisKey). - Add a high-watermark read cursor (
paliad.users.inbox_seen_at) +POST /api/inbox/mark-all-seen+ extend/api/inbox/countto count unseen project_events too. Adds one new axisunread_onlyto 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_atonpaliad.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'srenderApprovalListassumes every row is an approval and unconditionally parsesrow.detailas anApprovalDetail. Project_event rows in the same list would crash the parse. We need to branch perrow.kindinside the inbox row stamper. /inboxshape toggle.client/inbox.tshardcodesshape-list; theshapeaxis is wired intofilter-bar/axes.tsbut/inbox'sINBOX_AXESdeliberately omits it (because today the only meaningful shape was list). Adding it onto INBOX_AXES + a small dispatcher inonResultgives 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:
*_approval_*duplicates would render twice per request.status_changedandmember_role_changedare 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 setsinbox_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: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: noneon the table-wrap / cards-wrap / cal-wrap hosts. - For calendar,
mountCalendar()constructs a month/week/day grid into a dedicatedevents-calendar-wraphost; 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:
-
Add
"shape"toINBOX_AXES. -
Dispatch in the
onResultcallback byeffective.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, ...); } } -
The renderers exist already:
renderCardsShapeinviews/shape-cards.ts,renderCalendarShapeinviews/shape-calendar.ts,renderListShapeinviews/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)
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 fromrenderApprovalList). - 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 inclient/projects-detail.ts:651–700(.entity-eventmarkup).
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:
- Migration
NNN_inbox_seen_at.up.sql:ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL; filter_spec.go: extendKnownProjectEventKinds(addnote_created,our_side_changed,deadline_updated,deadline_deleted,deadlines_imported). AddInboxProjectEventKinds(curated subset, Q1=A).system_views.go: rewriteInboxSystemViewper §6.1 with both sources,HorizonPast30d,SortDateDesc,RowAction=RowActionInbox.render_spec.go: addRowActionInbox, register inKnownRowActions.view_service.go: inrunProjectEvents, auto-drop*_approval_*event_types when ApprovalRequest is inspec.Sources(§6.2).approvals.go:- New handler
handleInboxMarkAllSeen→UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1. - Modify
handleInboxCountto returnpending_approvals_count + unread_project_events_count. SQL in approval_service.go: one new methodUnseenInboxCountForUser(userID)returning that union. KeepPendingCountForUser(dashboard still uses it).
- New handler
shape-list.ts: factorrenderApprovalRow(row)out ofrenderApprovalList. AddrenderInboxList(rows)that dispatches perrow.kind. Wirerow_action="inbox"to it.client/inbox.ts:- Add the
unread_onlyaxis toINBOX_AXESand wire to a FilterSpec overlay (sub-specTime.Horizon=Past30dAND 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.
- Add the
- 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. - 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.
- Tests:
system_views_test.gocovers the InboxSystemView spec shape; new test for the de-dup helper in view_service.approval_service_test.goadds tests for the newUnseenInboxCountForUsermethod. Newinbox_seen_at_test.gocovers the cursor migration + the POST handler. - 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:
client/inbox.ts: add"shape"toINBOX_AXES. Add the per-shape host divs toinbox.tsx(one for cards, one for calendar) matching the/eventspattern. ImplementonResultdispatch.shape-cards.ts: whenrow.kind==="approval_request"ANDrow.detail.status==="pending", stamp the approval row template inline. Hoist the template out ofshape-list.tsif reuse pays.shape-calendar.ts: approval_request rows render as date-point chips; click opens a detail modal. The modal reuses the existingapproval-edit-modalfor suggest-changes when the user is the approver; otherwise a read-only summary.- CSS: ensure
.entity-eventand.views-approval-rowmarkup coexist on the cards view without z-index clashes; lightweight targeting via.views-cards-list[data-surface="inbox"]. - 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:
paliad.inbox_dismissalstable —(user_id, source, row_id, dismissed_at)PK(user_id, source, row_id). "source" isapproval_request/project_event; "row_id" is the row's UUID. New endpointPOST /api/inbox/dismissbody{source, row_id}. RunSpec for inbox subtracts dismissed rows./api/inbox/count: subtract dismissed rows from the count.- Dashboard widget:
DashboardData.InboxSummaryswaps to a newUnifiedInboxSummarythat mirrors the page count. Backwards-compat JSON: keep old fields, addtotal_countandtop_unified. - Empty-state: "Alle Einträge gelesen — gut gemacht."
- Optional
member_role_changedetc.: if Slice A surfaces that one of the excluded event_types is actually wanted, this slice opens upInboxProjectEventKindsaccordingly.
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
loadInboxSummaryuses 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.
runProjectEventsreads 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-seenaccepts 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
renderApprovalRowout ofrenderApprovalListrisks 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
InboxProjectEventKindslist literal infilter_spec.go— no schema impact, no migration change. Cheap to flip.
11. Build/test verify list (Slice A done-when)
make buildclean.go test ./...passes; new tests cover:- InboxSystemView spec shape includes both sources + curated kinds.
runProjectEventsdrops*_approval_*when ApprovalRequest is in spec.UnseenInboxCountForUserreturns expected count for cursor and pending-approval combinations.- POST
/api/inbox/mark-all-seenupdates the column. - URL codec round-trip for
unread_onlyaxis.
- Inbox loads at
/inboxwith project-event rows interleaved with approval rows in date-desc order. - "Nur ungelesen" chip toggles between unread (with pending-approval carve-out) and full feed.
- "Alles als gelesen markieren" advances cursor; bar refreshes; badge clears (except for any still-pending approvals).
- Sidebar bell badge count is the unified number (approval + unread events).
- Existing approve/reject/revoke + suggest-changes flows on inbox rows still work unchanged.
?tab=minelegacy redirect still hits the right state.- 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_changedinInboxProjectEventKindswithout 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
runProjectEventsthat, whenevent_type='member_role_changed', inspectsmetadata.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:
- §2 / §6.1: add
member_role_changedto InboxProjectEventKinds. Note Slice B narrowing follow-up. - §4 / §5: front of the bar gets a new
inbox_focusaxis (4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default "Alles".project_event_kindstays available as an advanced chip, visible after the user expands the bar's overflow section. - §7 Slice A task list: add task —
"12a. New
inbox_focusaxis (filter-bar/types.ts,axes.ts). FilterSpec overlay translates the chip value to a(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)triple. URL codec round-trips." - §11 Slice B done-when: add — "
member_role_changednarrowing 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.