create-astram-app 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * notificationList — React-free helpers for the Notifications page (G-035, notification-center
3
+ * SPEC §5F): URL state ⇄ list query, date presets, status labels, row and bulk actions, empty
4
+ * text. Kept pure so `node --test` covers them (notificationList.test.mjs); NotificationsPage.jsx
5
+ * only renders what these return. (Not named `notificationsPage.js`: on a case-insensitive file
6
+ * system `./NotificationsPage` would resolve to it.)
7
+ *
8
+ * The URL holds the whole view (tab, kind, status, date, from, to, search, page, limit) so a
9
+ * reload or a shared link keeps it. A hand-edited URL never poisons the state: every value is
10
+ * checked and falls back to its default.
11
+ */
12
+
13
+ /** Tabs (spec §10): category → label. */
14
+ export const TABS = Object.freeze([
15
+ Object.freeze({ value: 'personal', label: 'Notifications' }),
16
+ Object.freeze({ value: 'announcement', label: 'Announcements' }),
17
+ ]);
18
+
19
+ /** Page sizes (spec §10) and the default. */
20
+ export const PAGE_SIZES = Object.freeze([10, 20, 50, 100]);
21
+ export const DEFAULT_LIMIT = 20;
22
+
23
+ /** Status filter (spec §10): '' = All (the server leaves archived notices out). */
24
+ export const STATUS_OPTIONS = Object.freeze([
25
+ Object.freeze({ value: '', label: 'All' }),
26
+ Object.freeze({ value: 'unread', label: 'Unread' }),
27
+ Object.freeze({ value: 'read', label: 'Read' }),
28
+ Object.freeze({ value: 'archived', label: 'Archived' }),
29
+ ]);
30
+
31
+ /** Date filter (spec §10): '' = any time, the three presets, or a custom from–to. */
32
+ export const DATE_OPTIONS = Object.freeze([
33
+ Object.freeze({ value: '', label: 'Any time' }),
34
+ Object.freeze({ value: '7', label: 'Last 7 days' }),
35
+ Object.freeze({ value: '30', label: 'Last 30 days' }),
36
+ Object.freeze({ value: '90', label: 'Last 90 days' }),
37
+ Object.freeze({ value: 'custom', label: 'Custom range' }),
38
+ ]);
39
+
40
+ /** The longest search the server takes (characters after trimming, spec §3). */
41
+ export const SEARCH_MAX = 100;
42
+
43
+ const DAY_MS = 24 * 60 * 60 * 1000;
44
+ const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
45
+ const STATUSES = new Set(['unread', 'read', 'archived']);
46
+ const DATES = new Set(['7', '30', '90', 'custom']);
47
+ // The guard's kind shape (`NOTIFICATION_KIND_PATTERN`); the server checks the length. This only
48
+ // keeps junk out of the URL-derived state.
49
+ const KIND_RE = /^(?=.{1,128}$)[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
50
+
51
+ /** `YYYY-MM-DD` naming a real calendar day, else ''. */
52
+ export function validDay(value) {
53
+ if (typeof value !== 'string') return '';
54
+ const m = DAY_RE.exec(value);
55
+ if (!m) return '';
56
+ const y = Number(m[1]);
57
+ const mo = Number(m[2]);
58
+ const d = Number(m[3]);
59
+ const at = new Date(Date.UTC(y, mo - 1, d));
60
+ if (at.getUTCFullYear() !== y || at.getUTCMonth() !== mo - 1 || at.getUTCDate() !== d) return '';
61
+ return value;
62
+ }
63
+
64
+ /** The UTC calendar day of a ms timestamp as `YYYY-MM-DD` (the server reads days as UTC). */
65
+ export function utcDay(ms) {
66
+ return new Date(ms).toISOString().slice(0, 10);
67
+ }
68
+
69
+ /**
70
+ * The page state from the URL (`URLSearchParams` or anything with `get`). Unknown or malformed
71
+ * values fall back to the defaults: tab `personal`, everything else empty, page 1, limit 20.
72
+ */
73
+ export function readPageState(sp) {
74
+ const get = (k) => {
75
+ const v = sp?.get?.(k);
76
+ return typeof v === 'string' ? v : '';
77
+ };
78
+ const tab = get('tab') === 'announcement' ? 'announcement' : 'personal';
79
+ const kind = KIND_RE.test(get('kind')) ? get('kind') : '';
80
+ const status = STATUSES.has(get('status')) ? get('status') : '';
81
+ const date = DATES.has(get('date')) ? get('date') : '';
82
+ const page = Number.parseInt(get('page'), 10);
83
+ const limit = Number.parseInt(get('limit'), 10);
84
+ return {
85
+ tab,
86
+ kind,
87
+ status,
88
+ date,
89
+ from: date === 'custom' ? validDay(get('from')) : '',
90
+ to: date === 'custom' ? validDay(get('to')) : '',
91
+ search: get('search').slice(0, SEARCH_MAX),
92
+ page: Number.isFinite(page) && page >= 1 ? page : 1,
93
+ limit: PAGE_SIZES.includes(limit) ? limit : DEFAULT_LIMIT,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * The URL params for a page state, defaults left out to keep the link short. `tab` is always
99
+ * written so a shared link names its tab. Returns a plain object for `setSearchParams`.
100
+ */
101
+ export function pageStateToParams(state) {
102
+ const p = { tab: state.tab === 'announcement' ? 'announcement' : 'personal' };
103
+ if (state.kind) p.kind = state.kind;
104
+ if (state.status) p.status = state.status;
105
+ if (state.date) p.date = state.date;
106
+ if (state.date === 'custom' && state.from) p.from = state.from;
107
+ if (state.date === 'custom' && state.to) p.to = state.to;
108
+ // Spaces only ⇒ no search: keep it out of the URL (the server treats it the same way).
109
+ const search = typeof state.search === 'string' ? state.search.trim() : '';
110
+ if (search) p.search = search;
111
+ if (state.page > 1) p.page = String(state.page);
112
+ if (state.limit !== DEFAULT_LIMIT) p.limit = String(state.limit);
113
+ return p;
114
+ }
115
+
116
+ /**
117
+ * `{ from, to }` (UTC days, inclusive) for a date filter. A preset of N days covers today and
118
+ * the N − 1 days before it, with no `to` (up to now). Custom passes the chosen days through;
119
+ * an inverted range (from after to) is `{ invalid: true }` and filters nothing until fixed.
120
+ */
121
+ export function dateRange(state, now = Date.now()) {
122
+ if (state.date === '7' || state.date === '30' || state.date === '90') {
123
+ const days = Number(state.date);
124
+ return { from: utcDay(now - (days - 1) * DAY_MS), to: '' };
125
+ }
126
+ if (state.date === 'custom') {
127
+ const from = validDay(state.from);
128
+ const to = validDay(state.to);
129
+ if (from && to && from > to) return { from: '', to: '', invalid: true };
130
+ return { from, to };
131
+ }
132
+ return { from: '', to: '' };
133
+ }
134
+
135
+ /**
136
+ * The `listNotifications` query for a page state: `{ category, page, limit }` plus only the
137
+ * filters that are set. Search is trimmed (blank ⇒ none, spec §3).
138
+ */
139
+ export function buildListQuery(state, now = Date.now()) {
140
+ const q = {
141
+ category: state.tab === 'announcement' ? 'announcement' : 'personal',
142
+ page: state.page >= 1 ? state.page : 1,
143
+ limit: PAGE_SIZES.includes(state.limit) ? state.limit : DEFAULT_LIMIT,
144
+ };
145
+ if (state.kind) q.kind = state.kind;
146
+ if (state.status) q.status = state.status;
147
+ const { from, to } = dateRange(state, now);
148
+ if (from) q.from = from;
149
+ if (to) q.to = to;
150
+ const search = typeof state.search === 'string' ? state.search.trim() : '';
151
+ if (search) q.search = search;
152
+ return q;
153
+ }
154
+
155
+ /** True when a search or any filter narrows the list (the empty text differs). */
156
+ export function hasActiveFilters(state) {
157
+ const search = typeof state.search === 'string' ? state.search.trim() : '';
158
+ return Boolean(search || state.kind || state.status || state.date);
159
+ }
160
+
161
+ /** The no-rows text (spec §10, §12). */
162
+ export function emptyText(state) {
163
+ return hasActiveFilters(state) ? 'No matching notifications' : 'No notifications yet';
164
+ }
165
+
166
+ /** A row's status: archived wins over read/unread. */
167
+ export function rowStatus(row) {
168
+ if (row?.archivedAt) return 'archived';
169
+ return row?.readAt ? 'read' : 'unread';
170
+ }
171
+
172
+ const STATUS_LABEL = { unread: 'Unread', read: 'Read', archived: 'Archived' };
173
+ const STATUS_VARIANT = { unread: 'info', read: 'secondary', archived: 'outline' };
174
+
175
+ /** The Status column text: Unread / Read / Archived. */
176
+ export function statusLabel(row) {
177
+ return STATUS_LABEL[rowStatus(row)];
178
+ }
179
+
180
+ /** The Status column badge variant (`ui/badge` variants). */
181
+ export function statusVariant(row) {
182
+ return STATUS_VARIANT[rowStatus(row)];
183
+ }
184
+
185
+ /**
186
+ * The single-row actions: an unread row ⇒ Mark read + Archive; a read row ⇒ Mark unread +
187
+ * Archive; an archived row ⇒ Unarchive only (it comes back as read, spec §12).
188
+ * Each `{ action, label }`, `action` being the bulk action name.
189
+ */
190
+ export function rowActions(row) {
191
+ const status = rowStatus(row);
192
+ if (status === 'archived') return [{ action: 'unarchive', label: 'Unarchive' }];
193
+ return [
194
+ status === 'unread'
195
+ ? { action: 'read', label: 'Mark read' }
196
+ : { action: 'unread', label: 'Mark unread' },
197
+ { action: 'archive', label: 'Archive' },
198
+ ];
199
+ }
200
+
201
+ /** The bulk bar's actions, in order (spec §10). */
202
+ export const BULK_ACTIONS = Object.freeze([
203
+ Object.freeze({ action: 'read', label: 'Mark read' }),
204
+ Object.freeze({ action: 'unread', label: 'Mark unread' }),
205
+ Object.freeze({ action: 'archive', label: 'Archive' }),
206
+ Object.freeze({ action: 'unarchive', label: 'Unarchive' }),
207
+ ]);
208
+
209
+ /** The most notices one bulk call takes (spec §10; frontend-core `NOTIFICATION_BULK_MAX`). */
210
+ export const BULK_MAX = 100;
211
+
212
+ const BULK_DONE = {
213
+ read: 'marked read',
214
+ unread: 'marked unread',
215
+ archive: 'archived',
216
+ unarchive: 'unarchived',
217
+ };
218
+
219
+ /** The toast after a bulk call: "3 notifications archived" / "1 notification marked read". */
220
+ export function bulkDoneText(action, updated) {
221
+ const n = Number.isFinite(updated) && updated > 0 ? Math.floor(updated) : 0;
222
+ return `${n} ${n === 1 ? 'notification' : 'notifications'} ${BULK_DONE[action] ?? 'updated'}`;
223
+ }
224
+
225
+ /** The Type filter options: the kinds of this tab's category, by label (never the raw kind). */
226
+ export function kindOptionsFor(kinds, category) {
227
+ if (!Array.isArray(kinds)) return [];
228
+ return kinds
229
+ .filter((k) => k && k.category === category && typeof k.kind === 'string')
230
+ .map((k) => ({
231
+ value: k.kind,
232
+ label: typeof k.label === 'string' && k.label ? k.label : 'Other',
233
+ }))
234
+ .sort((a, b) => a.label.localeCompare(b.label));
235
+ }
236
+
237
+ /**
238
+ * When a notice arrived, as a date and time ("27 Oct 2026, 14:05" in en-GB); '' when
239
+ * unparsable. `opts` ({ locale, timeZone }) pins the output in tests.
240
+ */
241
+ export function receivedText(iso, { locale, timeZone } = {}) {
242
+ if (typeof iso !== 'string' || iso === '') return '';
243
+ const at = Date.parse(iso);
244
+ if (Number.isNaN(at)) return '';
245
+ try {
246
+ return new Date(at).toLocaleString(locale, {
247
+ day: 'numeric',
248
+ month: 'short',
249
+ year: 'numeric',
250
+ hour: '2-digit',
251
+ minute: '2-digit',
252
+ ...(timeZone ? { timeZone } : {}),
253
+ });
254
+ } catch {
255
+ return '';
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The selection kept after a reload: only uuids still on the page. `selected` is a Set; the
261
+ * result is a new Set (or the same one when nothing dropped out).
262
+ */
263
+ export function keepSelection(selected, rows) {
264
+ if (!(selected instanceof Set) || selected.size === 0) return selected;
265
+ const onPage = new Set((rows || []).map((r) => r?.uuid));
266
+ const kept = new Set([...selected].filter((u) => onPage.has(u)));
267
+ return kept.size === selected.size ? selected : kept;
268
+ }
@@ -0,0 +1,273 @@
1
+ // notificationList.test.mjs — Notifications page helpers (G-035, notification-center SPEC §5F,
2
+ // §10, §12): URL state ⇄ list query, date presets → from/to, status labels, row and bulk
3
+ // actions, empty text, the Type filter options.
4
+ //
5
+ // node --test src/pages/notifications/notificationList.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ BULK_ACTIONS,
10
+ DEFAULT_LIMIT,
11
+ PAGE_SIZES,
12
+ buildListQuery,
13
+ bulkDoneText,
14
+ dateRange,
15
+ emptyText,
16
+ hasActiveFilters,
17
+ keepSelection,
18
+ kindOptionsFor,
19
+ pageStateToParams,
20
+ readPageState,
21
+ receivedText,
22
+ rowActions,
23
+ rowStatus,
24
+ statusLabel,
25
+ statusVariant,
26
+ validDay,
27
+ } from './notificationList.js';
28
+
29
+ const sp = (s) => new URLSearchParams(s);
30
+ const now = Date.parse('2026-10-27T10:30:00.000Z');
31
+
32
+ describe('readPageState', () => {
33
+ test('defaults: personal tab, no filters, page 1, limit 20', () => {
34
+ assert.deepEqual(readPageState(sp('')), {
35
+ tab: 'personal',
36
+ kind: '',
37
+ status: '',
38
+ date: '',
39
+ from: '',
40
+ to: '',
41
+ search: '',
42
+ page: 1,
43
+ limit: 20,
44
+ });
45
+ assert.equal(DEFAULT_LIMIT, 20);
46
+ assert.deepEqual([...PAGE_SIZES], [10, 20, 50, 100]);
47
+ });
48
+
49
+ test('a full URL round-trips', () => {
50
+ const s = readPageState(
51
+ sp(
52
+ 'tab=announcement&kind=app.task_assigned&status=archived&date=custom&from=2026-10-01&to=2026-10-20&search=batch&page=3&limit=50',
53
+ ),
54
+ );
55
+ assert.deepEqual(s, {
56
+ tab: 'announcement',
57
+ kind: 'app.task_assigned',
58
+ status: 'archived',
59
+ date: 'custom',
60
+ from: '2026-10-01',
61
+ to: '2026-10-20',
62
+ search: 'batch',
63
+ page: 3,
64
+ limit: 50,
65
+ });
66
+ assert.deepEqual(readPageState(sp(new URLSearchParams(pageStateToParams(s)).toString())), s);
67
+ });
68
+
69
+ test('hand-edited junk falls back to the defaults', () => {
70
+ const s = readPageState(sp('tab=other&kind=Bad Kind&status=deleted&date=365&page=-2&limit=33'));
71
+ assert.equal(s.tab, 'personal');
72
+ assert.equal(s.kind, '');
73
+ assert.equal(s.status, '');
74
+ assert.equal(s.date, '');
75
+ assert.equal(s.page, 1);
76
+ assert.equal(s.limit, 20);
77
+ });
78
+
79
+ test('from/to only with date=custom, and only real days', () => {
80
+ assert.equal(readPageState(sp('date=7&from=2026-10-01')).from, '');
81
+ assert.equal(readPageState(sp('date=custom&from=2026-02-30')).from, '');
82
+ assert.equal(readPageState(sp('date=custom&to=2026-10-05')).to, '2026-10-05');
83
+ });
84
+
85
+ test('search is capped at 100 characters', () => {
86
+ assert.equal(readPageState(sp(`search=${'x'.repeat(150)}`)).search.length, 100);
87
+ });
88
+ });
89
+
90
+ describe('pageStateToParams', () => {
91
+ test('defaults leave only the tab', () => {
92
+ assert.deepEqual(pageStateToParams(readPageState(sp(''))), { tab: 'personal' });
93
+ });
94
+ test('from/to are written only for a custom range', () => {
95
+ const p = pageStateToParams({
96
+ ...readPageState(sp('')),
97
+ date: '30',
98
+ from: '2026-10-01',
99
+ to: '2026-10-02',
100
+ });
101
+ assert.deepEqual(p, { tab: 'personal', date: '30' });
102
+ });
103
+ test('a spaces-only search stays out of the URL; a real one is trimmed', () => {
104
+ const base = readPageState(sp(''));
105
+ assert.deepEqual(pageStateToParams({ ...base, search: ' ' }), { tab: 'personal' });
106
+ assert.deepEqual(pageStateToParams({ ...base, search: ' batch ' }), {
107
+ tab: 'personal',
108
+ search: 'batch',
109
+ });
110
+ });
111
+ });
112
+
113
+ describe('date presets → from/to (UTC days)', () => {
114
+ const base = readPageState(sp(''));
115
+ test('Last 7 days = today and the 6 days before, no upper bound', () => {
116
+ assert.deepEqual(dateRange({ ...base, date: '7' }, now), { from: '2026-10-21', to: '' });
117
+ });
118
+ test('Last 30 and 90 days', () => {
119
+ assert.equal(dateRange({ ...base, date: '30' }, now).from, '2026-09-28');
120
+ assert.equal(dateRange({ ...base, date: '90' }, now).from, '2026-07-30');
121
+ });
122
+ test('custom passes the days through; an inverted range is flagged and dropped', () => {
123
+ assert.deepEqual(dateRange({ ...base, date: 'custom', from: '2026-10-01', to: '' }, now), {
124
+ from: '2026-10-01',
125
+ to: '',
126
+ });
127
+ assert.deepEqual(
128
+ dateRange({ ...base, date: 'custom', from: '2026-10-20', to: '2026-10-01' }, now),
129
+ { from: '', to: '', invalid: true },
130
+ );
131
+ });
132
+ test('any time ⇒ no bounds', () => {
133
+ assert.deepEqual(dateRange(base, now), { from: '', to: '' });
134
+ });
135
+ test('validDay rejects non-days', () => {
136
+ assert.equal(validDay('2026-10-27'), '2026-10-27');
137
+ for (const v of ['2026-1-27', '2026-13-01', 'yesterday', '', null])
138
+ assert.equal(validDay(v), '');
139
+ });
140
+ });
141
+
142
+ describe('buildListQuery', () => {
143
+ test('defaults: category, page and limit only', () => {
144
+ assert.deepEqual(buildListQuery(readPageState(sp('')), now), {
145
+ category: 'personal',
146
+ page: 1,
147
+ limit: 20,
148
+ });
149
+ });
150
+ test('every filter set', () => {
151
+ const q = buildListQuery(
152
+ readPageState(
153
+ sp(
154
+ 'tab=announcement&kind=app.news&status=unread&date=7&search=%20batch%20&page=2&limit=10',
155
+ ),
156
+ ),
157
+ now,
158
+ );
159
+ assert.deepEqual(q, {
160
+ category: 'announcement',
161
+ page: 2,
162
+ limit: 10,
163
+ kind: 'app.news',
164
+ status: 'unread',
165
+ from: '2026-10-21',
166
+ search: 'batch',
167
+ });
168
+ });
169
+ test('a blank search is no search (spec §12)', () => {
170
+ const q = buildListQuery({ ...readPageState(sp('')), search: ' ' }, now);
171
+ assert.equal('search' in q, false);
172
+ });
173
+ test('Status All sends no status (server ⇒ not archived)', () => {
174
+ assert.equal('status' in buildListQuery(readPageState(sp('status=')), now), false);
175
+ });
176
+ });
177
+
178
+ describe('empty text', () => {
179
+ const base = readPageState(sp(''));
180
+ test('no filters ⇒ "No notifications yet"', () => {
181
+ assert.equal(hasActiveFilters(base), false);
182
+ assert.equal(emptyText(base), 'No notifications yet');
183
+ assert.equal(emptyText({ ...base, search: ' ' }), 'No notifications yet');
184
+ });
185
+ test('any filter or search ⇒ "No matching notifications"', () => {
186
+ for (const patch of [{ search: 'x' }, { kind: 'app.a' }, { status: 'read' }, { date: '7' }]) {
187
+ assert.equal(emptyText({ ...base, ...patch }), 'No matching notifications');
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('status', () => {
193
+ const unread = { readAt: null, archivedAt: null };
194
+ const read = { readAt: '2026-10-27T09:00:00.000Z', archivedAt: null };
195
+ const archived = { readAt: '2026-10-27T09:00:00.000Z', archivedAt: '2026-10-27T09:10:00.000Z' };
196
+ test('labels and badge variants', () => {
197
+ assert.equal(statusLabel(unread), 'Unread');
198
+ assert.equal(statusLabel(read), 'Read');
199
+ assert.equal(statusLabel(archived), 'Archived');
200
+ assert.equal(rowStatus({ readAt: null, archivedAt: '2026-10-27T09:10:00.000Z' }), 'archived');
201
+ assert.equal(statusVariant(unread), 'info');
202
+ assert.equal(statusVariant(read), 'secondary');
203
+ assert.equal(statusVariant(archived), 'outline');
204
+ });
205
+ test('row actions follow the status', () => {
206
+ assert.deepEqual(
207
+ rowActions(unread).map((a) => a.action),
208
+ ['read', 'archive'],
209
+ );
210
+ assert.deepEqual(
211
+ rowActions(read).map((a) => a.action),
212
+ ['unread', 'archive'],
213
+ );
214
+ assert.deepEqual(
215
+ rowActions(archived).map((a) => a.action),
216
+ ['unarchive'],
217
+ );
218
+ });
219
+ test('bulk offers the four actions', () => {
220
+ assert.deepEqual(
221
+ BULK_ACTIONS.map((a) => a.action),
222
+ ['read', 'unread', 'archive', 'unarchive'],
223
+ );
224
+ });
225
+ test('bulk toast text', () => {
226
+ assert.equal(bulkDoneText('archive', 3), '3 notifications archived');
227
+ assert.equal(bulkDoneText('read', 1), '1 notification marked read');
228
+ assert.equal(bulkDoneText('unread', undefined), '0 notifications marked unread');
229
+ });
230
+ });
231
+
232
+ describe('Type filter options', () => {
233
+ const kinds = [
234
+ { kind: 'app.task_assigned', label: 'Task assigned', category: 'personal' },
235
+ { kind: 'shield.access_changed', label: 'Access change', category: 'personal' },
236
+ { kind: 'app.news', label: 'News', category: 'announcement' },
237
+ ];
238
+ test('only the tab’s category, by label, sorted', () => {
239
+ assert.deepEqual(kindOptionsFor(kinds, 'personal'), [
240
+ { value: 'shield.access_changed', label: 'Access change' },
241
+ { value: 'app.task_assigned', label: 'Task assigned' },
242
+ ]);
243
+ assert.deepEqual(kindOptionsFor(kinds, 'announcement'), [{ value: 'app.news', label: 'News' }]);
244
+ });
245
+ test('never shows the raw kind as a label', () => {
246
+ const [opt] = kindOptionsFor([{ kind: 'app.x', label: '', category: 'personal' }], 'personal');
247
+ assert.notEqual(opt.label, 'app.x');
248
+ });
249
+ test('not a list ⇒ none', () => {
250
+ assert.deepEqual(kindOptionsFor(undefined, 'personal'), []);
251
+ });
252
+ });
253
+
254
+ describe('receivedText', () => {
255
+ test('date and time; unparsable ⇒ empty', () => {
256
+ const text = receivedText('2026-10-27T14:05:00.000Z', { locale: 'en-GB', timeZone: 'UTC' });
257
+ assert.match(text, /27 Oct 2026/);
258
+ assert.match(text, /14:05/);
259
+ assert.equal(receivedText('nope'), '');
260
+ assert.equal(receivedText(undefined), '');
261
+ });
262
+ });
263
+
264
+ describe('keepSelection', () => {
265
+ test('drops uuids no longer on the page', () => {
266
+ const s = new Set(['a', 'b', 'c']);
267
+ assert.deepEqual([...keepSelection(s, [{ uuid: 'a' }, { uuid: 'c' }])], ['a', 'c']);
268
+ });
269
+ test('same set back when nothing dropped', () => {
270
+ const s = new Set(['a']);
271
+ assert.equal(keepSelection(s, [{ uuid: 'a' }, { uuid: 'b' }]), s);
272
+ });
273
+ });
@@ -5,7 +5,8 @@
5
5
  * On open: for holders of `identity:role:update`, the open admin tasks (a new permission no role
6
6
  * in a company holds yet): "Grant…" opens `/access/add?key=` with the key ticked; "Reviewed, not
7
7
  * needed" confirms (naming the permission and company) then dismisses. Below, the announcement
8
- * notices with the same read behaviour as the bell. The `can` gate is cosmetic: the server
8
+ * notices with the same behaviour as the bell (unread only, severity dot, "View all
9
+ * notifications" to `/notifications?tab=announcement`; G-035). The `can` gate is cosmetic: the server
9
10
  * decides, and a 403 on the task list hides the section. Labels and company names only — the
10
11
  * task's company id and key are used in calls and URLs, never shown.
11
12
  */
@@ -164,11 +165,7 @@ export default function AnnouncementsMenu() {
164
165
  <DropdownMenuLabel>Announcements</DropdownMenuLabel>
165
166
  <DropdownMenuSeparator />
166
167
  {canTasks && <TasksSection onDismiss={setTarget} />}
167
- <NoticeSection
168
- category="announcement"
169
- heading={canTasks ? 'News' : undefined}
170
- emptyText="No announcements."
171
- />
168
+ <NoticeSection category="announcement" heading={canTasks ? 'News' : undefined} />
172
169
  </DropdownMenuContent>
173
170
  </DropdownMenu>
174
171