From 51ac79412a696cb0599faee8b78aeca2980a8d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:31:18 +0000 Subject: [PATCH] fix(app-shell): surface the bell badge's notifications + approvals breakdown in the inbox popover (#7233) The badge is `unread topics + pendingApprovalsCount` clamped at "9+", and the per-tab count pills clamp too, so a loaded console showed three "9+"s that reconcile to nothing. Add a breakdown line under the popover header stating the exact unclamped addends beside the exact total; the formula and the counting APIs are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L9U1G2piXmYrhYQX96XUyv --- .../inbox-bell-badge-breakdown-os7233.md | 30 +++ .../app-shell/src/layout/InboxPopover.tsx | 36 ++++ .../InboxPopover.badgeBreakdown.test.tsx | 171 ++++++++++++++++++ .../inboxBadgeBreakdown-i18n-7233.test.ts | 44 +++++ packages/i18n/src/locales/ar.ts | 3 + packages/i18n/src/locales/de.ts | 3 + packages/i18n/src/locales/en.ts | 6 + packages/i18n/src/locales/es.ts | 3 + packages/i18n/src/locales/fr.ts | 3 + packages/i18n/src/locales/ja.ts | 3 + packages/i18n/src/locales/ko.ts | 3 + packages/i18n/src/locales/pt.ts | 3 + packages/i18n/src/locales/ru.ts | 3 + packages/i18n/src/locales/zh.ts | 3 + 14 files changed, 314 insertions(+) create mode 100644 .changeset/inbox-bell-badge-breakdown-os7233.md create mode 100644 packages/app-shell/src/layout/__tests__/InboxPopover.badgeBreakdown.test.tsx create mode 100644 packages/i18n/src/__tests__/inboxBadgeBreakdown-i18n-7233.test.ts diff --git a/.changeset/inbox-bell-badge-breakdown-os7233.md b/.changeset/inbox-bell-badge-breakdown-os7233.md new file mode 100644 index 0000000000..4037f6dea5 --- /dev/null +++ b/.changeset/inbox-bell-badge-breakdown-os7233.md @@ -0,0 +1,30 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +The inbox popover now spells out what the bell badge is made of + +The bell badge is `unread notification topics + pending approvals`, clamped to +"9+" above nine. As one number it is unexplainable: objectstack#7213 measured +Home's "pending approvals" card saying 8 while the bell said "9+", and read that +as the two counts disagreeing — they never did, the bell was simply carrying a +second addend the user could not see. + +The popover already tabs the two streams and puts a count pill on each tab, so +the split was partly visible — but those pills clamp at "9+" too. A loaded +console therefore showed three "9+"s that reconcile to nothing, which is why +sectioning alone did not close this. + +A breakdown line under the popover header now states the exact, unclamped +addends beside the exact total — `15 total · 12 notifications + 3 pending +approvals`. The approvals half is the same `pendingApprovalsCount` the Home card +and the Approvals Inbox tab read, so the number a user reconciles against is +literally the one they see elsewhere. + +The badge formula, the counting APIs and the "9+" clamp on the badge itself are +unchanged — this is a display fix. Three new keys +(`notifications.badgeTotal` / `badgeNotifications` / `badgeApprovals`) land in +all ten locale packs. They interpolate named placeholders (`{{total}}`, +`{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, which would +additionally drive plural-key resolution these packs carry no forms for. diff --git a/packages/app-shell/src/layout/InboxPopover.tsx b/packages/app-shell/src/layout/InboxPopover.tsx index f4b4590ac5..0ef8d0f9a1 100644 --- a/packages/app-shell/src/layout/InboxPopover.tsx +++ b/packages/app-shell/src/layout/InboxPopover.tsx @@ -226,6 +226,7 @@ export function InboxPopover({ {totalBadge > 0 && ( {totalBadge > 9 ? '9+' : totalBadge} @@ -248,6 +249,41 @@ export function InboxPopover({ )} + {/* Badge breakdown (#7233). The bell badge is `unreadTopics + + pendingApprovalsCount` and clamps at "9+", so the number on its own + is unexplainable — and the two tab pills clamp at "9+" too, which + means a loaded inbox can show three "9+"s that reconcile to nothing. + Spell the addends out here, unclamped, so a user seeing "9+" can + read exactly which N notifications and which M pending approvals it + is made of (the M is the same count Home's approvals card and the + Approvals Inbox tab show — one source, `pendingApprovalsCount`). */} + {totalBadge > 0 && ( +
+ + {t('notifications.badgeTotal', { + defaultValue: '{{total}} total', + total: totalBadge, + })} + + · + + {t('notifications.badgeNotifications', { + defaultValue: '{{unread}} notifications', + unread: unreadTopics, + })} + + + + + {t('notifications.badgeApprovals', { + defaultValue: '{{approvals}} pending approvals', + approvals: pendingApprovalsCount, + })} + +
+ )} setTab(v as typeof tab)} className="w-full"> diff --git a/packages/app-shell/src/layout/__tests__/InboxPopover.badgeBreakdown.test.tsx b/packages/app-shell/src/layout/__tests__/InboxPopover.badgeBreakdown.test.tsx new file mode 100644 index 0000000000..5803af88f7 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/InboxPopover.badgeBreakdown.test.tsx @@ -0,0 +1,171 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * #7233 — the bell badge is `unread notification topics + pending approvals` + * and clamps at "9+", so on a loaded console it is one opaque number that a + * user cannot reconcile against Home's "pending approvals" card (which reads + * the same `pendingApprovalsCount`). The popover already *tabs* the two + * streams and puts a count pill on each tab, but those pills clamp at "9+" + * too — three "9+"s that add up to nothing. + * + * These pin the breakdown line: the exact, unclamped addends, next to the + * exact total, so "9+" is explainable as N notifications + M pending + * approvals. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Interpolating stub: the real packs carry `{{unread}}` / `{{approvals}}` / +// `{{total}}`, so a stub that returned `defaultValue` verbatim would make every +// numeric assertion below vacuous (it would assert on the literal "{{total}}"). +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + // `formatRelativeTime` is reached through `utils/relativeTime` for every + // rendered row — keep the real module and override only the hook. + ...(await importOriginal>()), + useObjectTranslation: () => ({ + language: 'en', + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => + String(options?.[name] ?? ''), + ), + }), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ appName: 'setup' }), +})); + +vi.mock('../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: 'setup' }), +})); + +// Passthrough primitives so the popover body renders without driving Radix +// open/close in jsdom (the pattern WorkspaceSwitcher.test.tsx uses). +vi.mock('@object-ui/components', () => ({ + Button: ({ children, ...p }: any) => , + Popover: ({ children }: any) =>
{children}
, + PopoverTrigger: ({ children }: any) =>
{children}
, + PopoverContent: ({ children }: any) =>
{children}
, + Tabs: ({ children }: any) =>
{children}
, + TabsList: ({ children }: any) =>
{children}
, + TabsTrigger: ({ children }: any) => , + TabsContent: ({ children }: any) =>
{children}
, +})); + +vi.mock('lucide-react', () => ({ + Bell: () => , + CheckSquare: () => , + Activity: () => , + ChevronRight: () => , +})); + +import { InboxPopover, type InboxNotification } from '../InboxPopover'; + +const notif = (over: Partial & { id: string }): InboxNotification => ({ + type: 'project.digest', + title: 'Scheduled project digest', + is_read: false, + created_at: '2026-08-10T10:00:00Z', + ...over, +}); + +/** N distinct unread topics (each its own `(topic, title)` pair). */ +const distinctUnread = (n: number): InboxNotification[] => + Array.from({ length: n }, (_, i) => + notif({ id: `t${i}`, type: `topic.${i}`, title: `Topic ${i}` }), + ); + +function renderPopover(props: { + notifications: InboxNotification[]; + pendingApprovalsCount: number; + unreadCount?: number; +}) { + return render( + !n.is_read).length} + pendingApprovalsCount={props.pendingApprovalsCount} + activities={[]} + onMarkAllRead={vi.fn()} + onMarkRead={vi.fn()} + />, + ); +} + +describe('InboxPopover — bell badge breakdown (#7233)', () => { + it('explains a clamped "9+" badge as exact notifications + pending approvals', () => { + renderPopover({ notifications: distinctUnread(12), pendingApprovalsCount: 3 }); + + // The badge itself still clamps — the formula is unchanged by this fix. + expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('9+'); + + // …and the popover spells out what that "9+" is made of, unclamped. + expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('15 total'); + expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent( + '12 notifications', + ); + expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent( + '3 pending approvals', + ); + }); + + it('states the two addends and the total consistently (total === N + M)', () => { + renderPopover({ notifications: distinctUnread(4), pendingApprovalsCount: 2 }); + + const read = (id: string) => { + const text = screen.getByTestId(id).textContent ?? ''; + const n = Number(text.match(/\d+/)?.[0]); + expect(Number.isNaN(n)).toBe(false); + return n; + }; + const total = read('inbox-badge-breakdown-total'); + const unread = read('inbox-badge-breakdown-notifications'); + const approvals = read('inbox-badge-breakdown-approvals'); + + expect(unread + approvals).toBe(total); + expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent(String(total)); + }); + + it('reports the approvals half straight from pendingApprovalsCount (the count Home shows)', () => { + // Home's approvals card and the Approvals Inbox tab read the same number; + // #7213 measured home saying 8 while the bell said "9+". The breakdown has + // to show that 8 verbatim, never the notification-inflated total. + renderPopover({ notifications: distinctUnread(2), pendingApprovalsCount: 8 }); + + expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent( + '8 pending approvals', + ); + expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('10 total'); + }); + + it('counts coalesced repeats as one topic, matching the badge formula (#2765)', () => { + // 10 identical digests + 2 distinct topics = 3 unread topics, not 12. + const repeats = Array.from({ length: 10 }, (_, i) => notif({ id: `d${i}` })); + renderPopover({ + notifications: [ + ...repeats, + notif({ id: 'a1', type: 'task.assigned', title: 'New task assigned' }), + notif({ id: 'm1', type: 'comment.mention', title: 'You were mentioned' }), + ], + pendingApprovalsCount: 1, + }); + + expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent( + '3 notifications', + ); + expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('4 total'); + }); + + it('renders no breakdown when there is no badge to explain', () => { + renderPopover({ + notifications: [notif({ id: 'r1', is_read: true })], + pendingApprovalsCount: 0, + }); + + expect(screen.queryByTestId('inbox-bell-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inbox-badge-breakdown')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/__tests__/inboxBadgeBreakdown-i18n-7233.test.ts b/packages/i18n/src/__tests__/inboxBadgeBreakdown-i18n-7233.test.ts new file mode 100644 index 0000000000..b8a732c2b5 --- /dev/null +++ b/packages/i18n/src/__tests__/inboxBadgeBreakdown-i18n-7233.test.ts @@ -0,0 +1,44 @@ +/** + * `notifications.badge*` — the bell-badge breakdown strings (#7233). + * + * These three are interpolated with **named** placeholders (`{{total}}`, + * `{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, because + * `count` additionally drives plural-key resolution and these packs carry no + * plural forms. A pack that spells the placeholder differently does not fail + * loudly — i18next renders the literal `{{unread}}`, or an empty string — so + * the placeholder name is pinned here alongside the key's existence. + * + * All ten packs are asserted: `all-locales-key-parity` already owns the key + * SET, but not which placeholder a value spells, and a translated value is + * exactly where `{{unread}}` quietly becomes `{{count}}`. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +const readPath = (node: unknown, path: string): unknown => + path + .split('.') + .reduce( + (acc, seg) => + acc && typeof acc === 'object' ? (acc as Record)[seg] : undefined, + node, + ); + +const CASES = [ + { key: 'notifications.badgeTotal', placeholder: 'total' }, + { key: 'notifications.badgeNotifications', placeholder: 'unread' }, + { key: 'notifications.badgeApprovals', placeholder: 'approvals' }, +] as const; + +const LOCALES = Object.keys(builtInLocales) as (keyof typeof builtInLocales)[]; + +describe.each(LOCALES)('%s notifications.badge* (#7233)', (code) => { + it.each(CASES)('$key is a non-empty string carrying {{$placeholder}}', ({ key, placeholder }) => { + const value = readPath(builtInLocales[code], key); + expect(typeof value).toBe('string'); + expect((value as string).trim().length).toBeGreaterThan(0); + expect(value as string).toContain(`{{${placeholder}}}`); + // `count` would send i18next looking for `_one` / `_other`. + expect(value as string).not.toContain('{{count}}'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8a8d985894..0d815c6388 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2758,6 +2758,9 @@ const ar = { viewApprovals: "عرض الموافقات", noPendingApprovals: "لا توجد موافقات معلقة", openApprovalsInbox: "فتح صندوق الموافقات", + badgeTotal: "{{total}} إجمالاً", + badgeNotifications: "{{unread}} إشعارات", + badgeApprovals: "{{approvals}} موافقات معلقة", emptyUnread: "كل شيء مقروء", filterUnread: "غير مقروء", filterAll: "الكل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 8ac7e54115..4b069c4a3a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2754,6 +2754,9 @@ const de = { viewApprovals: "Genehmigungen anzeigen", noPendingApprovals: "Keine ausstehenden Genehmigungen", openApprovalsInbox: "Genehmigungs-Posteingang öffnen", + badgeTotal: "{{total}} insgesamt", + badgeNotifications: "{{unread}} Benachrichtigungen", + badgeApprovals: "{{approvals}} ausstehende Genehmigungen", emptyUnread: "Alles gelesen", filterUnread: "Ungelesen", filterAll: "Alle", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 2fe731195e..c0626d844c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2966,6 +2966,12 @@ const en = { viewApprovals: 'View approvals', noPendingApprovals: 'No pending approvals', openApprovalsInbox: 'Open Approvals Inbox', + // Bell-badge breakdown (#7233): the badge sums unread notification topics + // and pending approvals, then clamps at "9+". These three spell the sum + // out inside the popover so the number is explainable. + badgeTotal: '{{total}} total', + badgeNotifications: '{{unread}} notifications', + badgeApprovals: '{{approvals}} pending approvals', }, publicForm: { submit: 'Submit', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index fd4416c295..9c183644d5 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2758,6 +2758,9 @@ const es = { viewApprovals: "Ver aprobaciones", noPendingApprovals: "Sin aprobaciones pendientes", openApprovalsInbox: "Abrir bandeja de aprobaciones", + badgeTotal: "{{total}} en total", + badgeNotifications: "{{unread}} notificaciones", + badgeApprovals: "{{approvals}} aprobaciones pendientes", emptyUnread: "Todo al día", filterUnread: "No leídos", filterAll: "Todos", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index b856fd0297..7bf21d8dbf 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2754,6 +2754,9 @@ const fr = { viewApprovals: "Voir les approbations", noPendingApprovals: "Aucune approbation en attente", openApprovalsInbox: "Ouvrir la boîte d'approbations", + badgeTotal: "{{total}} au total", + badgeNotifications: "{{unread}} notifications", + badgeApprovals: "{{approvals}} approbations en attente", emptyUnread: "Tout est lu", filterUnread: "Non lus", filterAll: "Tous", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ae1db4b8f9..b4af5f21d4 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2754,6 +2754,9 @@ const ja = { viewApprovals: "承認を表示", noPendingApprovals: "承認待ちはありません", openApprovalsInbox: "承認ボックスを開く", + badgeTotal: "合計 {{total}} 件", + badgeNotifications: "通知 {{unread}} 件", + badgeApprovals: "承認待ち {{approvals}} 件", emptyUnread: "既読にしました", filterUnread: "未読", filterAll: "すべて", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ee668d9308..6e64271650 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2753,6 +2753,9 @@ const ko = { viewApprovals: "승인 보기", noPendingApprovals: "대기 중인 승인 없음", openApprovalsInbox: "승인 보관함 열기", + badgeTotal: "총 {{total}}건", + badgeNotifications: "알림 {{unread}}건", + badgeApprovals: "승인 대기 {{approvals}}건", emptyUnread: "모두 읽음", filterUnread: "읽지 않음", filterAll: "전체", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 202315eaf2..f4ecc5c373 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2753,6 +2753,9 @@ const pt = { viewApprovals: "Ver aprovações", noPendingApprovals: "Sem aprovações pendentes", openApprovalsInbox: "Abrir caixa de aprovações", + badgeTotal: "{{total}} no total", + badgeNotifications: "{{unread}} notificações", + badgeApprovals: "{{approvals}} aprovações pendentes", emptyUnread: "Tudo lido", filterUnread: "Não lidos", filterAll: "Todos", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 1985db68de..0d29a3a524 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2761,6 +2761,9 @@ const ru = { viewApprovals: "Показать утверждения", noPendingApprovals: "Нет ожидающих утверждений", openApprovalsInbox: "Открыть входящие утверждений", + badgeTotal: "Всего: {{total}}", + badgeNotifications: "{{unread}} уведомлений", + badgeApprovals: "{{approvals}} ожидающих утверждений", emptyUnread: "Всё прочитано", filterUnread: "Непрочитанные", filterAll: "Все", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index dd15947e1f..6741b032fe 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2870,6 +2870,9 @@ const zh = { viewApprovals: '查看审批', noPendingApprovals: '暂无待审批事项', openApprovalsInbox: '打开审批中心', + badgeTotal: '共 {{total}} 项', + badgeNotifications: '{{unread}} 条通知', + badgeApprovals: '{{approvals}} 条待审批', }, publicForm: { submit: '提交',