fix(notifications): humanize JSON bodies, collapse stale bursts, Natural Light restyle #5462
3 changed files+541−553
Addedsrc/__tests__/notification-render.test.ts+160−0View fileUnifiedSplit
@@ -0,0 +1,160 @@
1/**
2 * Render-time helpers for /notifications (issue #211 visual-audit sweep).
3 *
4 * Defect 1: a notification whose body was stored as a JSON payload rendered
5 * the raw blob verbatim at the top of the inbox. humanizeNotificationBody
6 * must never surface raw braces — it extracts message/title fields or
7 * builds a "key: value" digest, all at render time (stored data untouched).
8 *
9 * Defect 2: a stale sweep writes one "PR #x has gone quiet" row per stale
10 * PR (~200 identical rows). collapseNotificationRows groups adjacent runs
11 * of the same kind + repo into one row, mirroring the burst-collapse
12 * precedent in src/lib/recent-activity.ts.
13 *
14 * Pure module — no db import, no mocks needed.
15 */
16
17import { describe, it, expect } from "bun:test";
18import {
19 humanizeNotificationBody,
20 collapseNotificationRows,
21 collapsedSummary,
22} from "../lib/notification-render";
23
24describe("notifications: humanizeNotificationBody", () => {
25 it("passes plain prose through untouched", () => {
26 expect(humanizeNotificationBody("No activity for 7+ days.")).toBe(
27 "No activity for 7+ days."
28 );
29 });
30
31 it("returns null for null/empty bodies", () => {
32 expect(humanizeNotificationBody(null)).toBeNull();
33 expect(humanizeNotificationBody(undefined)).toBeNull();
34 expect(humanizeNotificationBody(" ")).toBeNull();
35 });
36
37 it("extracts the message field from a JSON payload instead of showing braces", () => {
38 const body = JSON.stringify({
39 event: "gate_failed",
40 message: "Gate 'tests' failed on push to main",
41 sha: "abc123",
42 });
43 expect(humanizeNotificationBody(body)).toBe(
44 "Gate 'tests' failed on push to main"
45 );
46 });
47
48 it("prefers title when message is absent", () => {
49 const body = JSON.stringify({ title: "Deploy finished", durationMs: 4200 });
50 expect(humanizeNotificationBody(body)).toBe("Deploy finished");
51 });
52
53 it("falls back to a key: value digest when no message-ish field exists", () => {
54 const body = JSON.stringify({ repo_name: "gluecron", prNumber: 42 });
55 const out = humanizeNotificationBody(body);
56 expect(out).toBe("repo name: gluecron · pr number: 42");
57 });
58
59 it("never returns raw braces for any parseable JSON", () => {
60 for (const body of [
61 "{}",
62 "[]",
63 '{"nested":{"deep":{}}}',
64 '["a","b","c","d","e"]',
65 '{"message":"hi"}',
66 ]) {
67 const out = humanizeNotificationBody(body);
68 if (out !== null) {
69 expect(out).not.toContain("{");
70 expect(out).not.toContain("}");
71 expect(out).not.toContain("[");
72 }
73 }
74 });
75
76 it("returns null (hide body) for empty JSON objects/arrays", () => {
77 expect(humanizeNotificationBody("{}")).toBeNull();
78 expect(humanizeNotificationBody("[]")).toBeNull();
79 });
80
81 it("summarizes arrays with an overflow count", () => {
82 expect(humanizeNotificationBody('["a","b","c","d","e"]')).toBe(
83 "a, b, c and 2 more"
84 );
85 });
86
87 it("keeps brace-leading prose that is not valid JSON", () => {
88 expect(humanizeNotificationBody("{owner} pushed to main")).toBe(
89 "{owner} pushed to main"
90 );
91 });
92});
93
94describe("notifications: collapseNotificationRows", () => {
95 const stale = (id: number, repositoryId: string) => ({
96 id: String(id),
97 kind: "pr_stale",
98 type: null,
99 repositoryId,
100 });
101
102 it("collapses a run of same-kind same-repo rows into one group", () => {
103 const rows = Array.from({ length: 200 }, (_, i) => stale(i, "repo-1"));
104 const groups = collapseNotificationRows(rows);
105 expect(groups.length).toBe(1);
106 expect(groups[0]!.kind).toBe("pr_stale");
107 expect(groups[0]!.items.length).toBe(200);
108 });
109
110 it("splits groups at repo boundaries", () => {
111 const rows = [stale(1, "repo-a"), stale(2, "repo-a"), stale(3, "repo-b")];
112 const groups = collapseNotificationRows(rows);
113 expect(groups.length).toBe(2);
114 expect(groups[0]!.items.length).toBe(2);
115 expect(groups[1]!.items.length).toBe(1);
116 });
117
118 it("never collapses non-collapsible kinds", () => {
119 const rows = [
120 { id: "1", kind: "mention", type: null, repositoryId: "r" },
121 { id: "2", kind: "mention", type: null, repositoryId: "r" },
122 ];
123 const groups = collapseNotificationRows(rows);
124 expect(groups.length).toBe(2);
125 expect(groups.every((g) => g.items.length === 1)).toBe(true);
126 });
127
128 it("preserves order and leaves interleaved kinds as singles", () => {
129 const rows = [
130 stale(1, "r"),
131 stale(2, "r"),
132 { id: "3", kind: "issue_comment", type: null, repositoryId: "r" },
133 stale(4, "r"),
134 ];
135 const groups = collapseNotificationRows(rows);
136 expect(groups.map((g) => g.items.length)).toEqual([2, 1, 1]);
137 });
138
139 it("falls back to the 0089 `type` column when kind is missing", () => {
140 const rows = [
141 { id: "1", kind: null, type: "issue_stale", repositoryId: "r" },
142 { id: "2", kind: null, type: "issue_stale", repositoryId: "r" },
143 ];
144 const groups = collapseNotificationRows(rows);
145 expect(groups.length).toBe(1);
146 expect(groups[0]!.kind).toBe("issue_stale");
147 });
148});
149
150describe("notifications: collapsedSummary", () => {
151 it("phrases PR and issue groups naturally", () => {
152 expect(collapsedSummary("pr_stale", 234)).toBe("234 PRs have gone quiet");
153 expect(collapsedSummary("pr_stale", 1)).toBe("1 PR has gone quiet");
154 expect(collapsedSummary("issue_stale", 3)).toBe("3 issues have gone quiet");
155 });
156
157 it("has a generic fallback", () => {
158 expect(collapsedSummary("something_else", 2)).toBe("2 notifications");
159 });
160});
Modifiedsrc/routes/notifications.tsx+381−553View fileUnifiedSplit
Large file (1,071 lines). Load full file
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts