@digitalcanopy/notifications 0.0.0-stage → 0.1.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @digitalcanopy/notifications
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Manual patch release for @digitalcanopy/notifications
package/README.md CHANGED
@@ -1,3 +1,16 @@
1
- # Temporary Holding Version
1
+ # @digitalcanopy/notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Universal notification triage state machine, AI digest contracts, and a collapsible `NotificationDigestCard`.
4
+
5
+ - `@digitalcanopy/notifications` – headless types, `triageReducer`, `buildDigestRollup`, `notificationKey`, `isSafeActionUrl` (no React required).
6
+ - `@digitalcanopy/notifications/react` – `useNotificationDigest` and `NotificationDigestCard` (requires `react`).
7
+
8
+ Notifications are identified by the composite key `source:id` (`notificationKey()`); `markRead`/`dismiss` take that key. Dismissed keys stay dismissed when re-ingested. Action URLs are rendered only if `isSafeActionUrl` allows them (http, https, mailto, relative).
9
+
10
+ ## Tailwind
11
+
12
+ `NotificationDigestCard` uses Tailwind utility classes and ships no CSS. Add the package to your Tailwind `content` globs, e.g.:
13
+
14
+ ```js
15
+ content: ['./src/**/*.{ts,tsx}', './node_modules/@digitalcanopy/notifications/dist/**/*.{js,mjs}']
16
+ ```
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@digitalcanopy/notifications",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Universal notification triage state machine, AI digest contracts, and collapsible digest card shared across SunShade apps",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.mjs",
15
+ "require": "./dist/index.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react/index.d.ts",
19
+ "import": "./dist/react/index.mjs",
20
+ "require": "./dist/react/index.js"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "build": "tsup src/index.ts src/react/index.ts --dts --format cjs,esm --minify",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:boundaries": "vitest run"
28
+ },
29
+ "dependencies": {
30
+ "@radix-ui/react-collapsible": "^1.1.3"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18.0.0 || >=19.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@types/react": "^19.1.0",
42
+ "react": "^19.1.0",
43
+ "tsup": "^8.5.1",
44
+ "typescript": "^5.7.3",
45
+ "vitest": "^3.0.5"
46
+ },
47
+ "keywords": [
48
+ "sunshade",
49
+ "notifications",
50
+ "triage",
51
+ "digest",
52
+ "react"
53
+ ],
54
+ "author": "SunShade Systems",
55
+ "license": "MIT"
56
+ }
@@ -0,0 +1,79 @@
1
+ import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { NotificationDigestCard, useNotificationDigest } from '../react';
4
+ import { buildDigestRollup } from '../triage';
5
+ import type { ActionableNotification } from '../types';
6
+
7
+ const n = (over: Partial<ActionableNotification>): ActionableNotification => ({
8
+ id: 'a',
9
+ tier: 'info',
10
+ title: 'Hello',
11
+ source: 'tracker',
12
+ createdAt: '2026-11-01T00:00:00Z',
13
+ read: false,
14
+ ...over,
15
+ });
16
+
17
+ beforeEach(() => window.localStorage.clear());
18
+
19
+ describe('useNotificationDigest', () => {
20
+ it('clears isSummarizing when unread items are cleared mid-flight', async () => {
21
+ const summarizer = () => new Promise<string>(() => {});
22
+ const { result } = renderHook(() =>
23
+ useNotificationDigest({ initial: [n({ id: '1' })], summarizer })
24
+ );
25
+ await waitFor(() => expect(result.current.isSummarizing).toBe(true));
26
+ act(() => result.current.markAllRead());
27
+ await waitFor(() => expect(result.current.isSummarizing).toBe(false));
28
+ });
29
+
30
+ it('drops a stale summary when notification content changes', async () => {
31
+ const summarizer = async (items: readonly ActionableNotification[]) => items[0].title;
32
+ const { result } = renderHook(() =>
33
+ useNotificationDigest({ initial: [n({ id: '1', title: 'arriving' })], summarizer })
34
+ );
35
+ await waitFor(() => expect(result.current.digest.summary).toBe('arriving'));
36
+ act(() => result.current.ingest([n({ id: '1', title: 'canceled' })]));
37
+ expect(result.current.digest.summary).not.toBe('arriving');
38
+ await waitFor(() => expect(result.current.digest.summary).toBe('canceled'));
39
+ });
40
+
41
+ it('manages state through actions', () => {
42
+ const { result } = renderHook(() => useNotificationDigest({ initial: [n({ id: '1' })] }));
43
+ expect(result.current.digest.unread).toBe(1);
44
+ act(() => result.current.ingest([n({ id: '2', tier: 'critical' })]));
45
+ expect(result.current.digest.topTier).toBe('critical');
46
+ act(() => result.current.markAllRead());
47
+ expect(result.current.digest.unread).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('NotificationDigestCard', () => {
52
+ it('does not render unsafe action links', () => {
53
+ const digest = buildDigestRollup([
54
+ n({ id: '1', title: 'Bad', actionUrl: 'javascript:alert(1)', actionLabel: 'BadLink' }),
55
+ n({ id: '2', title: 'Good', actionUrl: 'https://example.com', actionLabel: 'GoodLink' }),
56
+ ]);
57
+ render(<NotificationDigestCard digest={digest} defaultOpen storageKey={null} />);
58
+ expect(screen.queryByText('BadLink')).toBeNull();
59
+ expect(screen.getByText('GoodLink')).toBeTruthy();
60
+ });
61
+
62
+ it('shows badge, tier accent, and persists open state', () => {
63
+ const digest = buildDigestRollup([n({ id: '1', tier: 'critical', title: 'Blocked' })]);
64
+ const { unmount } = render(<NotificationDigestCard digest={digest} />);
65
+ expect(screen.getByTestId('unread-badge').textContent).toBe('1');
66
+ expect(screen.getByTestId('notification-digest-card').className).toContain('border-l-rose-500');
67
+ expect(screen.queryByText('Blocked')).toBeNull();
68
+ fireEvent.click(screen.getByRole('button', { name: /Notifications/ }));
69
+ expect(screen.getByText('Blocked')).toBeTruthy();
70
+ unmount();
71
+ render(<NotificationDigestCard digest={digest} />);
72
+ expect(screen.getByText('Blocked')).toBeTruthy();
73
+ });
74
+
75
+ it('renders empty state when open with no items', () => {
76
+ render(<NotificationDigestCard digest={buildDigestRollup([])} defaultOpen storageKey={null} />);
77
+ expect(screen.getByText(/all caught up/)).toBeTruthy();
78
+ });
79
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildDigestRollup, createTriageState, triageReducer } from '../triage';
3
+ import { isSafeActionUrl } from '../url';
4
+ import type { ActionableNotification } from '../types';
5
+
6
+ const n = (over: Partial<ActionableNotification>): ActionableNotification => ({
7
+ id: 'a',
8
+ tier: 'info',
9
+ title: 't',
10
+ source: 'tracker',
11
+ createdAt: '2026-11-01T00:00:00Z',
12
+ read: false,
13
+ ...over,
14
+ });
15
+
16
+ describe('triage', () => {
17
+ it('rolls up unread counts and top tier', () => {
18
+ const r = buildDigestRollup([
19
+ n({ id: '1', tier: 'info' }),
20
+ n({ id: '2', tier: 'critical', read: true }),
21
+ n({ id: '3', tier: 'attention' }),
22
+ ]);
23
+ expect(r.total).toBe(3);
24
+ expect(r.unread).toBe(2);
25
+ expect(r.topTier).toBe('attention');
26
+ expect(r.items[0].id).toBe('2');
27
+ });
28
+
29
+ it('returns null topTier when empty', () => {
30
+ expect(buildDigestRollup([]).topTier).toBeNull();
31
+ });
32
+
33
+ it('ingests without resurrecting read items, and dedupes', () => {
34
+ let s = createTriageState([n({ id: '1', read: true })]);
35
+ s = triageReducer(s, { type: 'ingest', notifications: [n({ id: '1' }), n({ id: '2' })] });
36
+ expect(s.items).toHaveLength(2);
37
+ expect(s.items.find((i) => i.id === '1')?.read).toBe(true);
38
+ });
39
+
40
+ it('marks read, marks all read, dismisses', () => {
41
+ let s = createTriageState([n({ id: '1' }), n({ id: '2' })]);
42
+ s = triageReducer(s, { type: 'markRead', key: 'tracker:1' });
43
+ expect(buildDigestRollup(s.items).unread).toBe(1);
44
+ s = triageReducer(s, { type: 'markAllRead' });
45
+ expect(buildDigestRollup(s.items).unread).toBe(0);
46
+ s = triageReducer(s, { type: 'dismiss', key: 'tracker:2' });
47
+ expect(s.items).toHaveLength(1);
48
+ });
49
+
50
+ it('keeps same-id notifications from different sources distinct', () => {
51
+ let s = createTriageState([n({ id: '1', source: 'tracker' }), n({ id: '1', source: 'cozy' })]);
52
+ expect(s.items).toHaveLength(2);
53
+ s = triageReducer(s, { type: 'markRead', key: 'cozy:1' });
54
+ expect(s.items.find((i) => i.source === 'tracker')?.read).toBe(false);
55
+ expect(s.items.find((i) => i.source === 'cozy')?.read).toBe(true);
56
+ });
57
+
58
+ it('does not resurrect dismissed notifications on re-ingest', () => {
59
+ let s = createTriageState([n({ id: '1' })]);
60
+ s = triageReducer(s, { type: 'dismiss', key: 'tracker:1' });
61
+ s = triageReducer(s, { type: 'ingest', notifications: [n({ id: '1' })] });
62
+ expect(s.items).toHaveLength(0);
63
+ });
64
+ });
65
+
66
+ describe('isSafeActionUrl', () => {
67
+ it('allows safe URLs and rejects script schemes', () => {
68
+ for (const u of ['https://a.com', 'http://a.com', 'mailto:a@b.c', '/path', '#x', './x', 'foo/bar']) {
69
+ expect(isSafeActionUrl(u)).toBe(true);
70
+ }
71
+ for (const u of ['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'java\nscript:alert(1)', 'data:text/html,x', '//evil.com', '', undefined]) {
72
+ expect(isSafeActionUrl(u as string | undefined)).toBe(false);
73
+ }
74
+ });
75
+ });
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from './types';
2
+ export {
3
+ buildDigestRollup,
4
+ compareNotifications,
5
+ createTriageState,
6
+ notificationKey,
7
+ sortNotifications,
8
+ tierRank,
9
+ triageReducer,
10
+ } from './triage';
11
+ export { isSafeActionUrl } from './url';
@@ -0,0 +1,154 @@
1
+ import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import { useCallback, useState, type ReactNode } from 'react';
3
+ import { notificationKey } from '../triage';
4
+ import type { ActionableNotification, DigestRollup, UrgencyTier } from '../types';
5
+ import { isSafeActionUrl } from '../url';
6
+
7
+ export const TIER_STYLES: Record<UrgencyTier, { rail: string; glyph: string; label: string }> = {
8
+ critical: { rail: 'border-l-4 border-l-rose-500', glyph: '!', label: 'Critical' },
9
+ attention: { rail: 'border-l-4 border-l-amber-400', glyph: '•', label: 'Needs attention' },
10
+ info: { rail: 'border-l-4 border-l-sky-500', glyph: 'i', label: 'Info' },
11
+ };
12
+
13
+ const DEFAULT_STORAGE_KEY = 'sunshade:notification-digest:open';
14
+
15
+ function readStoredOpen(key: string | null, fallback: boolean): boolean {
16
+ if (!key || typeof window === 'undefined') return fallback;
17
+ try {
18
+ const v = window.localStorage.getItem(key);
19
+ return v === null ? fallback : v === '1';
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function writeStoredOpen(key: string | null, open: boolean): void {
26
+ if (!key || typeof window === 'undefined') return;
27
+ try {
28
+ window.localStorage.setItem(key, open ? '1' : '0');
29
+ } catch {
30
+ /* storage unavailable; state stays in-memory */
31
+ }
32
+ }
33
+
34
+ export interface NotificationDigestCardProps {
35
+ digest: DigestRollup;
36
+ title?: string;
37
+ defaultOpen?: boolean;
38
+ /** localStorage key for persisted open state. Pass null to disable persistence. */
39
+ storageKey?: string | null;
40
+ className?: string;
41
+ /** Receives the composite source:id key. */
42
+ onMarkRead?: (key: string) => void;
43
+ onMarkAllRead?: () => void;
44
+ onDismiss?: (key: string) => void;
45
+ /** Override rendering of a single row. */
46
+ renderItem?: (item: ActionableNotification) => ReactNode;
47
+ emptyState?: ReactNode;
48
+ }
49
+
50
+ export function NotificationDigestCard({
51
+ digest,
52
+ title = 'Notifications',
53
+ defaultOpen = false,
54
+ storageKey = DEFAULT_STORAGE_KEY,
55
+ className = '',
56
+ onMarkRead,
57
+ onMarkAllRead,
58
+ onDismiss,
59
+ renderItem,
60
+ emptyState = <p className="px-4 py-3 text-sm text-slate-500">You're all caught up.</p>,
61
+ }: NotificationDigestCardProps) {
62
+ const [open, setOpen] = useState(() => readStoredOpen(storageKey, defaultOpen));
63
+ const handleOpenChange = useCallback(
64
+ (next: boolean) => {
65
+ setOpen(next);
66
+ writeStoredOpen(storageKey, next);
67
+ },
68
+ [storageKey]
69
+ );
70
+
71
+ const tier = digest.topTier ?? 'info';
72
+ const style = TIER_STYLES[tier];
73
+
74
+ return (
75
+ <Collapsible.Root
76
+ open={open}
77
+ onOpenChange={handleOpenChange}
78
+ data-testid="notification-digest-card"
79
+ data-tier={tier}
80
+ className={`rounded-md border border-slate-200 bg-white ${style.rail} ${className}`.trim()}
81
+ >
82
+ <div className="flex items-center justify-between gap-2 px-4 py-3">
83
+ <Collapsible.Trigger asChild>
84
+ <button type="button" className="flex flex-1 items-center gap-2 text-left font-medium">
85
+ <span aria-hidden="true">{style.glyph}</span>
86
+ <span>{title}</span>
87
+ {digest.unread > 0 && (
88
+ <span
89
+ data-testid="unread-badge"
90
+ aria-label={`${digest.unread} unread`}
91
+ className="animate-pulse rounded-full bg-rose-500 px-2 py-0.5 text-xs text-white"
92
+ >
93
+ {digest.unread}
94
+ </span>
95
+ )}
96
+ </button>
97
+ </Collapsible.Trigger>
98
+ {digest.unread > 0 && onMarkAllRead && (
99
+ <button type="button" className="text-xs text-slate-500 underline" onClick={onMarkAllRead}>
100
+ Mark all read
101
+ </button>
102
+ )}
103
+ </div>
104
+ {digest.summary && <p className="px-4 pb-2 text-sm text-slate-600">{digest.summary}</p>}
105
+ <Collapsible.Content>
106
+ {digest.items.length === 0 ? (
107
+ emptyState
108
+ ) : (
109
+ <ul className="divide-y divide-slate-100">
110
+ {digest.items.map((item) =>
111
+ renderItem ? (
112
+ <li key={notificationKey(item)}>{renderItem(item)}</li>
113
+ ) : (
114
+ <li
115
+ key={notificationKey(item)}
116
+ data-tier={item.tier}
117
+ className={`${TIER_STYLES[item.tier].rail} flex items-start gap-2 px-4 py-2 ${
118
+ item.read ? 'opacity-60' : ''
119
+ }`}
120
+ >
121
+ <span aria-hidden="true">{TIER_STYLES[item.tier].glyph}</span>
122
+ <div className="flex-1">
123
+ <p className="text-sm font-medium">{item.title}</p>
124
+ {item.body && <p className="text-xs text-slate-600">{item.body}</p>}
125
+ {isSafeActionUrl(item.actionUrl) && (
126
+ <a href={item.actionUrl} className="text-xs text-sky-600 underline">
127
+ {item.actionLabel ?? 'Open'}
128
+ </a>
129
+ )}
130
+ </div>
131
+ {!item.read && onMarkRead && (
132
+ <button type="button" className="text-xs" onClick={() => onMarkRead(notificationKey(item))}>
133
+ Mark read
134
+ </button>
135
+ )}
136
+ {onDismiss && (
137
+ <button
138
+ type="button"
139
+ aria-label={`Dismiss ${item.title}`}
140
+ className="text-xs"
141
+ onClick={() => onDismiss(notificationKey(item))}
142
+ >
143
+ ✕
144
+ </button>
145
+ )}
146
+ </li>
147
+ )
148
+ )}
149
+ </ul>
150
+ )}
151
+ </Collapsible.Content>
152
+ </Collapsible.Root>
153
+ );
154
+ }
@@ -0,0 +1,7 @@
1
+ export { useNotificationDigest } from './useNotificationDigest';
2
+ export type {
3
+ UseNotificationDigestOptions,
4
+ UseNotificationDigestResult,
5
+ } from './useNotificationDigest';
6
+ export { NotificationDigestCard, TIER_STYLES } from './NotificationDigestCard';
7
+ export type { NotificationDigestCardProps } from './NotificationDigestCard';
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
2
+ import { buildDigestRollup, createTriageState, triageReducer } from '../triage';
3
+ import type { ActionableNotification, DigestRollup, DigestSummarizer } from '../types';
4
+
5
+ export interface UseNotificationDigestOptions {
6
+ initial?: ActionableNotification[];
7
+ /** Optional AI summarizer; only invoked when there are unread items. */
8
+ summarizer?: DigestSummarizer;
9
+ }
10
+
11
+ export interface UseNotificationDigestResult {
12
+ digest: DigestRollup;
13
+ ingest: (notifications: ActionableNotification[]) => void;
14
+ /** `key` is the composite `source:id` from `notificationKey()`. */
15
+ markRead: (key: string) => void;
16
+ markAllRead: () => void;
17
+ dismiss: (key: string) => void;
18
+ reset: (notifications?: ActionableNotification[]) => void;
19
+ isSummarizing: boolean;
20
+ summaryError: Error | null;
21
+ }
22
+
23
+ export function useNotificationDigest(
24
+ options: UseNotificationDigestOptions = {}
25
+ ): UseNotificationDigestResult {
26
+ const { initial, summarizer } = options;
27
+ const [state, dispatch] = useReducer(triageReducer, initial, (i) => createTriageState(i));
28
+ const [summary, setSummary] = useState<string | undefined>();
29
+ const [isSummarizing, setIsSummarizing] = useState(false);
30
+ const [summaryError, setSummaryError] = useState<Error | null>(null);
31
+ const summarizerRef = useRef(summarizer);
32
+ summarizerRef.current = summarizer;
33
+
34
+ const unreadItems = useMemo(() => state.items.filter((i) => !i.read), [state.items]);
35
+ // Content-sensitive key: re-summarize when an unread item's content changes, not just its id.
36
+ const unreadKey = JSON.stringify(
37
+ unreadItems
38
+ .map((i) => [i.source, i.id, i.tier, i.title, i.body ?? ''])
39
+ .sort((a, b) => (a.join('\u0000') < b.join('\u0000') ? -1 : 1))
40
+ );
41
+
42
+ useEffect(() => {
43
+ // Drop any previous summary immediately so it never describes stale content.
44
+ setSummary(undefined);
45
+ const run = summarizerRef.current;
46
+ if (!run || unreadItems.length === 0) {
47
+ setIsSummarizing(false);
48
+ setSummaryError(null);
49
+ return;
50
+ }
51
+ const controller = new AbortController();
52
+ setIsSummarizing(true);
53
+ setSummaryError(null);
54
+ run(unreadItems, controller.signal)
55
+ .then((text) => {
56
+ if (!controller.signal.aborted) setSummary(text);
57
+ })
58
+ .catch((err: unknown) => {
59
+ if (!controller.signal.aborted) {
60
+ setSummaryError(err instanceof Error ? err : new Error(String(err)));
61
+ }
62
+ })
63
+ .finally(() => {
64
+ if (!controller.signal.aborted) setIsSummarizing(false);
65
+ });
66
+ return () => controller.abort();
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [unreadKey]);
69
+
70
+ const digest = useMemo(() => buildDigestRollup(state.items, summary), [state.items, summary]);
71
+
72
+ return {
73
+ digest,
74
+ ingest: useCallback((notifications) => dispatch({ type: 'ingest', notifications }), []),
75
+ markRead: useCallback((key) => dispatch({ type: 'markRead', key }), []),
76
+ markAllRead: useCallback(() => dispatch({ type: 'markAllRead' }), []),
77
+ dismiss: useCallback((key) => dispatch({ type: 'dismiss', key }), []),
78
+ reset: useCallback((notifications) => dispatch({ type: 'reset', notifications }), []),
79
+ isSummarizing,
80
+ summaryError,
81
+ };
82
+ }
package/src/triage.ts ADDED
@@ -0,0 +1,94 @@
1
+ import {
2
+ URGENCY_TIERS,
3
+ type ActionableNotification,
4
+ type DigestRollup,
5
+ type TriageAction,
6
+ type TriageState,
7
+ type UrgencyTier,
8
+ } from './types';
9
+
10
+ /** Notifications are unique per (source, id), not by id alone. */
11
+ export function notificationKey(n: Pick<ActionableNotification, 'source' | 'id'>): string {
12
+ return `${n.source}:${n.id}`;
13
+ }
14
+
15
+ export function tierRank(tier: UrgencyTier): number {
16
+ return URGENCY_TIERS.indexOf(tier);
17
+ }
18
+
19
+ export function compareNotifications(a: ActionableNotification, b: ActionableNotification): number {
20
+ return (
21
+ tierRank(a.tier) - tierRank(b.tier) ||
22
+ Number(a.read) - Number(b.read) ||
23
+ Date.parse(b.createdAt) - Date.parse(a.createdAt)
24
+ );
25
+ }
26
+
27
+ export function sortNotifications(items: readonly ActionableNotification[]): ActionableNotification[] {
28
+ return [...items].sort(compareNotifications);
29
+ }
30
+
31
+ export function buildDigestRollup(
32
+ items: readonly ActionableNotification[],
33
+ summary?: string
34
+ ): DigestRollup {
35
+ const unreadByTier: Record<UrgencyTier, number> = { critical: 0, attention: 0, info: 0 };
36
+ for (const item of items) {
37
+ if (!item.read) unreadByTier[item.tier] += 1;
38
+ }
39
+ const unread = unreadByTier.critical + unreadByTier.attention + unreadByTier.info;
40
+ const topTier = URGENCY_TIERS.find((t) => unreadByTier[t] > 0) ?? null;
41
+ return {
42
+ total: items.length,
43
+ unread,
44
+ unreadByTier,
45
+ topTier,
46
+ items: sortNotifications(items),
47
+ ...(summary !== undefined ? { summary } : {}),
48
+ };
49
+ }
50
+
51
+ export function createTriageState(items: ActionableNotification[] = []): TriageState {
52
+ return { items: dedupe(items), dismissed: [] };
53
+ }
54
+
55
+ function dedupe(items: readonly ActionableNotification[]): ActionableNotification[] {
56
+ const map = new Map<string, ActionableNotification>();
57
+ for (const item of items) map.set(notificationKey(item), item);
58
+ return [...map.values()];
59
+ }
60
+
61
+ export function triageReducer(state: TriageState, action: TriageAction): TriageState {
62
+ switch (action.type) {
63
+ case 'ingest': {
64
+ const dismissed = new Set(state.dismissed);
65
+ const existing = new Map(state.items.map((i) => [notificationKey(i), i]));
66
+ for (const incoming of action.notifications) {
67
+ const key = notificationKey(incoming);
68
+ if (dismissed.has(key)) continue;
69
+ const prev = existing.get(key);
70
+ // Never resurrect an already-read item as unread on re-ingest.
71
+ existing.set(key, prev?.read ? { ...incoming, read: true } : incoming);
72
+ }
73
+ return { ...state, items: [...existing.values()] };
74
+ }
75
+ case 'markRead':
76
+ return {
77
+ ...state,
78
+ items: state.items.map((i) => (notificationKey(i) === action.key ? { ...i, read: true } : i)),
79
+ };
80
+ case 'markAllRead':
81
+ return { ...state, items: state.items.map((i) => (i.read ? i : { ...i, read: true })) };
82
+ case 'dismiss':
83
+ return {
84
+ items: state.items.filter((i) => notificationKey(i) !== action.key),
85
+ dismissed: state.dismissed.includes(action.key)
86
+ ? state.dismissed
87
+ : [...state.dismissed, action.key],
88
+ };
89
+ case 'reset':
90
+ return createTriageState(action.notifications ?? []);
91
+ default:
92
+ return state;
93
+ }
94
+ }
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Urgency tiers, ordered from most to least urgent. */
2
+ export type UrgencyTier = 'critical' | 'attention' | 'info';
3
+
4
+ export const URGENCY_TIERS: readonly UrgencyTier[] = ['critical', 'attention', 'info'];
5
+
6
+ export interface ActionableNotification {
7
+ id: string;
8
+ tier: UrgencyTier;
9
+ title: string;
10
+ body?: string;
11
+ /** Originating app/feature, e.g. "tracker", "cozy", "hub". */
12
+ source: string;
13
+ /** ISO-8601 timestamp. */
14
+ createdAt: string;
15
+ read: boolean;
16
+ actionUrl?: string;
17
+ actionLabel?: string;
18
+ /** Optional grouping key (e.g. peer care drops from the same sender). */
19
+ groupKey?: string;
20
+ }
21
+
22
+ export interface DigestRollup {
23
+ total: number;
24
+ unread: number;
25
+ unreadByTier: Record<UrgencyTier, number>;
26
+ /** Highest tier among unread items, or null when everything is read. */
27
+ topTier: UrgencyTier | null;
28
+ /** Items sorted by tier, then unread first, then newest first. */
29
+ items: ActionableNotification[];
30
+ /** Optional AI-generated summary of the digest. */
31
+ summary?: string;
32
+ }
33
+
34
+ /** AI summarization contract: implemented by consuming apps. */
35
+ export type DigestSummarizer = (
36
+ items: readonly ActionableNotification[],
37
+ signal?: AbortSignal
38
+ ) => Promise<string>;
39
+
40
+ export interface TriageState {
41
+ items: ActionableNotification[];
42
+ /** Keys (see `notificationKey`) of dismissed items; re-ingesting them is a no-op. */
43
+ dismissed: string[];
44
+ }
45
+
46
+ /**
47
+ * Actions address notifications by composite key (`source:id`), since IDs are
48
+ * only unique within a source. Use `notificationKey()` to build one.
49
+ */
50
+ export type TriageAction =
51
+ | { type: 'ingest'; notifications: ActionableNotification[] }
52
+ | { type: 'markRead'; key: string }
53
+ | { type: 'markAllRead' }
54
+ | { type: 'dismiss'; key: string }
55
+ | { type: 'reset'; notifications?: ActionableNotification[] };
package/src/url.ts ADDED
@@ -0,0 +1,19 @@
1
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
2
+
3
+ /**
4
+ * Returns true only for http(s)/mailto URLs and same-origin relative paths.
5
+ * Rejects `javascript:`, `data:` and other script-capable schemes.
6
+ */
7
+ export function isSafeActionUrl(url: string | undefined): url is string {
8
+ if (!url) return false;
9
+ // Strip control chars/whitespace browsers ignore inside schemes (e.g. "java\nscript:").
10
+ const trimmed = url.replace(/[\u0000-\u0020\u007f-\u009f]/g, '');
11
+ if (trimmed.startsWith('//')) return false;
12
+ if (/^[/#?.]/.test(trimmed)) return true;
13
+ try {
14
+ return SAFE_PROTOCOLS.has(new URL(trimmed).protocol);
15
+ } catch {
16
+ // No scheme: treat as a relative path only if it has no colon before a slash.
17
+ return !/^[^/?#]*:/.test(trimmed);
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "emitDeclarationOnly": true,
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "outDir": "./dist"
9
+ },
10
+ "include": ["src/**/*"],
11
+ "exclude": ["node_modules", "dist", "src/__tests__"]
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "lib": ["esnext", "dom"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "module": "esnext",
12
+ "moduleResolution": "bundler",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "jsx": "react-jsx",
16
+ "declaration": true,
17
+ "declarationMap": true
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ globals: true,
8
+ environment: 'jsdom',
9
+ css: false,
10
+ },
11
+ });