Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
144
planned
1,362
shipped

Motir · Work items

MOTIR-312Done

5.7 In-app notifications (bell + unread feed)

The in-app half of the notification surface — a bell in the shell header with an unread count, a notification feed/drawer (mentions first), mark-read / mark-all-read, deep links into issues, and per-user notification preferences (email vs in-app per event type). Story 5.1.6 (DONE) already ships the email half plus the channel-agnostic job events; 5.7 is the in-app channel that consumes those SAME events — a notification persistence model fed by a job, never a second emit path.

One emit path, many channels (the locked invariant). Comment/mention writes emit ONE channel-agnostic event after commit (work-item/comment.created, work-item/mentioned — 5.1.6's events); the email job is one consumer. 5.7 adds a SECOND consumer (the 5.7.3 in-app job) of the same events that writes Notification rows. 5.7 touches no emit site, adds no "also notify in-app" call beside any email send, and is built so the watcher event (work-item/transitioned, Story 5.4) and the automation events (work-item/created, work-item/field.changed, Story 6.6) fan into the SAME handler when they land — no 5.7 change, no forward dep (5.4 + 6.6 already document 5.7 as their seam).

The Jira/Linear-verified shape (decision-ladder rung 1, checked at plan time — not memory). The bell badge counts NEW notifications since the drawer was last opened (opening clears it) — a seen count distinct from per-row read state, so Motir models BOTH: a cheap unread-count aggregate for the badge and a per-row readAt for the blue-dot + mark-read. The drawer splits Direct (mentions / assignment / reporter — "mentions first") from Watching (the 5.4 fan-in slot). Each row deep-links to its issue AND marks itself read on open; "Mark all as read" sits in the drawer overflow and is a single server operation over the unread set (NOT a client loop over rendered rows — Jira's own mark-all ships bugs from doing exactly that, JRACLOUD-85017). Read rows persist in the feed (greyed, no dot). Linear Inbox is the cross-check for the feed model (per-row read state, mark-all, keyboard-navigable, deep-link on open).

Preferences (the Jira personal-notification-settings shape). Each user controls, per event type, whether they are notified by email and/or in-app — generalizing Jira's "notify me when watching / mentioned / assigned / I made the change" into a per-user × event-type × channel matrix on the existing /settings/account personal-settings area. The preference is the channel gate honoured by BOTH the 5.7.3 in-app consumer AND the DONE 5.1.6 email job (5.7.6 wires the gate into 5.1.6 without touching its emit site).

Scale (finding #57 — never load-all). A years-active user accrues thousands of notifications; the feed read is cursor-paginated from the most recent (page size 20) and the unread count is a cheap indexed aggregate (a partial index on the unread predicate), never a COUNT(*) over the whole table or a load-everything read. The drawer shows the newest page + "Show more"; mark-all is a single bulk UPDATE.

Completeness — the real-product states. Empty (no notifications yet — an inviting, non-blank state), loading (skeleton rows in the drawer), error (ErrorState + retry), the unread vs read row treatment (blue-dot vs greyed), the Direct/Watching split with Watching inert until 5.4 lands (a documented seam, drawn the way 2.5.3 drew the disabled view-switcher), the zero-badge state, the preferences page with its per-event-type × channel toggles + saving/saved/error states. All drawn by 5.7.1, asserted in 5.7.7/5.7.8.

Out of scope (documented, each justified): realtime PUSH of the badge (no realtime substrate — the 5.1 live-comments decision; the count refreshes on navigation + a bounded poll while the drawer is open); notification GROUPING / digest batching (an email-delivery policy owned by 5.1.6's job, not the in-app feed); mobile/native push (not a Motir surface); the Watching tab's population + the work-item/transitioned fan-in (Story 5.4 owns the event; 5.7.3 is built to consume it with no change); automation-event notifications (Story 6.6 owns work-item/created + work-item/field.changed; same seam).

Verification

  • Pull the Story branch, pnpm install, pnpm prisma migrate dev (applies the 5.7.2 notification + 5.7.6 notification_preference migration cleanly; a re-run reports "No difference detected" — every FK modelled as a Prisma @relation, no drift), pnpm db:seed, pnpm dev.
  • pnpm test:coverage — Vitest (real Postgres, getSession mock only) over notificationsService (feed paging, unread count, mark-read, mark-all-read, the per-user scoping), the 5.7.3 fan-in job (recipients, actor-excluded, mention-deduped), and the 5.7.6 preference gate stays ≥90% per-file branch/fn/line on the new service/repo/job files (the CI coverage gate); every new repo method has a direct empty-input-guard test.
  • Bell + feed flow: sign in as bophilips@motir.co. From another session, zhuyue@motir.co @-mentions Bo in a comment → Bo's bell badge increments (on Bo's next navigation/poll). Open the drawer (matching design/notifications/drawer.mock.html): the mention sits at the top of Direct with an unread blue-dot; the badge count clears (seen). Click the row → it deep-links to the issue AND the row marks read (dot gone, greyed); the unread count decrements by one. Receive several more → "Mark all as read" in the drawer overflow clears every dot in ONE request (reload: they stay read — the JRACLOUD-85017 regression is absent).
  • Preferences flow: open /settings/account → Notifications: the per-event-type × channel (email / in-app) matrix renders. Toggle in-app OFF for "Mentioned" → a new mention no longer creates a Notification row (no bell increment) but the email still arrives. Toggle email OFF instead → the [EMAIL] dev-console line stops for mentions while the bell still increments — proving BOTH the in-app consumer (5.7.3) and the DONE email job (5.1.6) read the same gate.
  • Self-exclusion + dedupe: mention yourself → no bell increment, no email (actor excluded). A user who is both mentioned and (later) a watcher gets ONE row per event, not two (mention-deduped, the 5.4 dedupe rule the consumer inherits).
  • Scale check (finding #57): seed a user with 2,000+ notifications (pnpm db:seed:large or the 5.7.7 fixture) → the drawer loads only the newest 20 with "Show more"; the badge count comes from the indexed unread aggregate (a single fast query, not a table scan); no unbounded read fires on any path.
  • pnpm test:e2e --grep notifications — Playwright over the real stack: mention → bell increment → drawer open → click → issue → marked read → badge decrement, plus the preference-off path stopping the right channel.
  • a11y check: the bell + drawer pass the strict axe sweep (the bell is a labelled button announcing the unread count, the drawer is a keyboard-navigable feed/list, read state conveyed as text not colour alone, focus returns to the bell on close); the preferences matrix is a labelled control grid; colour via --el-*, shape via element shape tokens.