@digitalcanopy/notifications 0.0.0-stage → 0.1.2

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,13 @@
1
+ # @digitalcanopy/notifications
2
+
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Manual patch release for @digitalcanopy/notifications
8
+
9
+ ## 0.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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
+ ```
@@ -0,0 +1 @@
1
+ var s=["critical","attention","info"];function a(e){return`${e.source}:${e.id}`}function c(e){return s.indexOf(e)}function f(e,t){return c(e.tier)-c(t.tier)||Number(e.read)-Number(t.read)||Date.parse(t.createdAt)-Date.parse(e.createdAt)}function l(e){return[...e].sort(f)}function b(e,t){let i={critical:0,attention:0,info:0};for(let n of e)n.read||(i[n.tier]+=1);let o=i.critical+i.attention+i.info,r=s.find(n=>i[n]>0)??null;return{total:e.length,unread:o,unreadByTier:i,topTier:r,items:l(e),...t!==void 0?{summary:t}:{}}}function d(e=[]){return{items:p(e),dismissed:[]}}function p(e){let t=new Map;for(let i of e)t.set(a(i),i);return[...t.values()]}function A(e,t){switch(t.type){case"ingest":{let i=new Set(e.dismissed),o=new Map(e.items.map(r=>[a(r),r]));for(let r of t.notifications){let n=a(r);if(i.has(n))continue;let u=o.get(n);o.set(n,u?.read?{...r,read:!0}:r)}return{...e,items:[...o.values()]}}case"markRead":return{...e,items:e.items.map(i=>a(i)===t.key?{...i,read:!0}:i)};case"markAllRead":return{...e,items:e.items.map(i=>i.read?i:{...i,read:!0})};case"dismiss":return{items:e.items.filter(i=>a(i)!==t.key),dismissed:e.dismissed.includes(t.key)?e.dismissed:[...e.dismissed,t.key]};case"reset":return d(t.notifications??[]);default:return e}}var g=new Set(["http:","https:","mailto:"]);function N(e){if(!e)return!1;let t=e.replace(/[\u0000-\u0020\u007f-\u009f]/g,"");if(t.startsWith("//"))return!1;if(/^[/#?.]/.test(t))return!0;try{return g.has(new URL(t).protocol)}catch{return!/^[^/?#]*:/.test(t)}}export{s as a,a as b,c,f as d,l as e,b as f,d as g,A as h,N as i};
@@ -0,0 +1,19 @@
1
+ import { A as ActionableNotification, D as DigestRollup, T as TriageState, U as UrgencyTier, a as TriageAction } from './types-C-Qf-T0b.mjs';
2
+ export { b as DigestSummarizer, c as URGENCY_TIERS } from './types-C-Qf-T0b.mjs';
3
+
4
+ /** Notifications are unique per (source, id), not by id alone. */
5
+ declare function notificationKey(n: Pick<ActionableNotification, 'source' | 'id'>): string;
6
+ declare function tierRank(tier: UrgencyTier): number;
7
+ declare function compareNotifications(a: ActionableNotification, b: ActionableNotification): number;
8
+ declare function sortNotifications(items: readonly ActionableNotification[]): ActionableNotification[];
9
+ declare function buildDigestRollup(items: readonly ActionableNotification[], summary?: string): DigestRollup;
10
+ declare function createTriageState(items?: ActionableNotification[]): TriageState;
11
+ declare function triageReducer(state: TriageState, action: TriageAction): TriageState;
12
+
13
+ /**
14
+ * Returns true only for http(s)/mailto URLs and same-origin relative paths.
15
+ * Rejects `javascript:`, `data:` and other script-capable schemes.
16
+ */
17
+ declare function isSafeActionUrl(url: string | undefined): url is string;
18
+
19
+ export { ActionableNotification, DigestRollup, TriageAction, TriageState, UrgencyTier, buildDigestRollup, compareNotifications, createTriageState, isSafeActionUrl, notificationKey, sortNotifications, tierRank, triageReducer };
@@ -0,0 +1,19 @@
1
+ import { A as ActionableNotification, D as DigestRollup, T as TriageState, U as UrgencyTier, a as TriageAction } from './types-C-Qf-T0b.js';
2
+ export { b as DigestSummarizer, c as URGENCY_TIERS } from './types-C-Qf-T0b.js';
3
+
4
+ /** Notifications are unique per (source, id), not by id alone. */
5
+ declare function notificationKey(n: Pick<ActionableNotification, 'source' | 'id'>): string;
6
+ declare function tierRank(tier: UrgencyTier): number;
7
+ declare function compareNotifications(a: ActionableNotification, b: ActionableNotification): number;
8
+ declare function sortNotifications(items: readonly ActionableNotification[]): ActionableNotification[];
9
+ declare function buildDigestRollup(items: readonly ActionableNotification[], summary?: string): DigestRollup;
10
+ declare function createTriageState(items?: ActionableNotification[]): TriageState;
11
+ declare function triageReducer(state: TriageState, action: TriageAction): TriageState;
12
+
13
+ /**
14
+ * Returns true only for http(s)/mailto URLs and same-origin relative paths.
15
+ * Rejects `javascript:`, `data:` and other script-capable schemes.
16
+ */
17
+ declare function isSafeActionUrl(url: string | undefined): url is string;
18
+
19
+ export { ActionableNotification, DigestRollup, TriageAction, TriageState, UrgencyTier, buildDigestRollup, compareNotifications, createTriageState, isSafeActionUrl, notificationKey, sortNotifications, tierRank, triageReducer };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var N=Object.prototype.hasOwnProperty;var T=(t,e)=>{for(var i in e)u(t,i,{get:e[i],enumerable:!0})},R=(t,e,i,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of A(e))!N.call(t,r)&&r!==i&&u(t,r,{get:()=>e[r],enumerable:!(n=b(e,r))||n.enumerable});return t};var x=t=>R(u({},"__esModule",{value:!0}),t);var U={};T(U,{URGENCY_TIERS:()=>c,buildDigestRollup:()=>p,compareNotifications:()=>f,createTriageState:()=>d,isSafeActionUrl:()=>m,notificationKey:()=>a,sortNotifications:()=>l,tierRank:()=>s,triageReducer:()=>g});module.exports=x(U);var c=["critical","attention","info"];function a(t){return`${t.source}:${t.id}`}function s(t){return c.indexOf(t)}function f(t,e){return s(t.tier)-s(e.tier)||Number(t.read)-Number(e.read)||Date.parse(e.createdAt)-Date.parse(t.createdAt)}function l(t){return[...t].sort(f)}function p(t,e){let i={critical:0,attention:0,info:0};for(let o of t)o.read||(i[o.tier]+=1);let n=i.critical+i.attention+i.info,r=c.find(o=>i[o]>0)??null;return{total:t.length,unread:n,unreadByTier:i,topTier:r,items:l(t),...e!==void 0?{summary:e}:{}}}function d(t=[]){return{items:S(t),dismissed:[]}}function S(t){let e=new Map;for(let i of t)e.set(a(i),i);return[...e.values()]}function g(t,e){switch(e.type){case"ingest":{let i=new Set(t.dismissed),n=new Map(t.items.map(r=>[a(r),r]));for(let r of e.notifications){let o=a(r);if(i.has(o))continue;let y=n.get(o);n.set(o,y?.read?{...r,read:!0}:r)}return{...t,items:[...n.values()]}}case"markRead":return{...t,items:t.items.map(i=>a(i)===e.key?{...i,read:!0}:i)};case"markAllRead":return{...t,items:t.items.map(i=>i.read?i:{...i,read:!0})};case"dismiss":return{items:t.items.filter(i=>a(i)!==e.key),dismissed:t.dismissed.includes(e.key)?t.dismissed:[...t.dismissed,e.key]};case"reset":return d(e.notifications??[]);default:return t}}var k=new Set(["http:","https:","mailto:"]);function m(t){if(!t)return!1;let e=t.replace(/[\u0000-\u0020\u007f-\u009f]/g,"");if(e.startsWith("//"))return!1;if(/^[/#?.]/.test(e))return!0;try{return k.has(new URL(e).protocol)}catch{return!/^[^/?#]*:/.test(e)}}0&&(module.exports={URGENCY_TIERS,buildDigestRollup,compareNotifications,createTriageState,isSafeActionUrl,notificationKey,sortNotifications,tierRank,triageReducer});
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ import{a as n,b as t,c as i,d as o,e,f as r,g as a,h as c,i as f}from"./chunk-RCMJRFBC.mjs";export{n as URGENCY_TIERS,r as buildDigestRollup,o as compareNotifications,a as createTriageState,f as isSafeActionUrl,t as notificationKey,e as sortNotifications,i as tierRank,c as triageReducer};
@@ -0,0 +1,45 @@
1
+ import { A as ActionableNotification, b as DigestSummarizer, D as DigestRollup, U as UrgencyTier } from '../types-C-Qf-T0b.mjs';
2
+ import * as react from 'react';
3
+ import { ReactNode } from 'react';
4
+
5
+ interface UseNotificationDigestOptions {
6
+ initial?: ActionableNotification[];
7
+ /** Optional AI summarizer; only invoked when there are unread items. */
8
+ summarizer?: DigestSummarizer;
9
+ }
10
+ interface UseNotificationDigestResult {
11
+ digest: DigestRollup;
12
+ ingest: (notifications: ActionableNotification[]) => void;
13
+ /** `key` is the composite `source:id` from `notificationKey()`. */
14
+ markRead: (key: string) => void;
15
+ markAllRead: () => void;
16
+ dismiss: (key: string) => void;
17
+ reset: (notifications?: ActionableNotification[]) => void;
18
+ isSummarizing: boolean;
19
+ summaryError: Error | null;
20
+ }
21
+ declare function useNotificationDigest(options?: UseNotificationDigestOptions): UseNotificationDigestResult;
22
+
23
+ declare const TIER_STYLES: Record<UrgencyTier, {
24
+ rail: string;
25
+ glyph: string;
26
+ label: string;
27
+ }>;
28
+ interface NotificationDigestCardProps {
29
+ digest: DigestRollup;
30
+ title?: string;
31
+ defaultOpen?: boolean;
32
+ /** localStorage key for persisted open state. Pass null to disable persistence. */
33
+ storageKey?: string | null;
34
+ className?: string;
35
+ /** Receives the composite source:id key. */
36
+ onMarkRead?: (key: string) => void;
37
+ onMarkAllRead?: () => void;
38
+ onDismiss?: (key: string) => void;
39
+ /** Override rendering of a single row. */
40
+ renderItem?: (item: ActionableNotification) => ReactNode;
41
+ emptyState?: ReactNode;
42
+ }
43
+ declare function NotificationDigestCard({ digest, title, defaultOpen, storageKey, className, onMarkRead, onMarkAllRead, onDismiss, renderItem, emptyState, }: NotificationDigestCardProps): react.JSX.Element;
44
+
45
+ export { NotificationDigestCard, type NotificationDigestCardProps, TIER_STYLES, type UseNotificationDigestOptions, type UseNotificationDigestResult, useNotificationDigest };
@@ -0,0 +1,45 @@
1
+ import { A as ActionableNotification, b as DigestSummarizer, D as DigestRollup, U as UrgencyTier } from '../types-C-Qf-T0b.js';
2
+ import * as react from 'react';
3
+ import { ReactNode } from 'react';
4
+
5
+ interface UseNotificationDigestOptions {
6
+ initial?: ActionableNotification[];
7
+ /** Optional AI summarizer; only invoked when there are unread items. */
8
+ summarizer?: DigestSummarizer;
9
+ }
10
+ interface UseNotificationDigestResult {
11
+ digest: DigestRollup;
12
+ ingest: (notifications: ActionableNotification[]) => void;
13
+ /** `key` is the composite `source:id` from `notificationKey()`. */
14
+ markRead: (key: string) => void;
15
+ markAllRead: () => void;
16
+ dismiss: (key: string) => void;
17
+ reset: (notifications?: ActionableNotification[]) => void;
18
+ isSummarizing: boolean;
19
+ summaryError: Error | null;
20
+ }
21
+ declare function useNotificationDigest(options?: UseNotificationDigestOptions): UseNotificationDigestResult;
22
+
23
+ declare const TIER_STYLES: Record<UrgencyTier, {
24
+ rail: string;
25
+ glyph: string;
26
+ label: string;
27
+ }>;
28
+ interface NotificationDigestCardProps {
29
+ digest: DigestRollup;
30
+ title?: string;
31
+ defaultOpen?: boolean;
32
+ /** localStorage key for persisted open state. Pass null to disable persistence. */
33
+ storageKey?: string | null;
34
+ className?: string;
35
+ /** Receives the composite source:id key. */
36
+ onMarkRead?: (key: string) => void;
37
+ onMarkAllRead?: () => void;
38
+ onDismiss?: (key: string) => void;
39
+ /** Override rendering of a single row. */
40
+ renderItem?: (item: ActionableNotification) => ReactNode;
41
+ emptyState?: ReactNode;
42
+ }
43
+ declare function NotificationDigestCard({ digest, title, defaultOpen, storageKey, className, onMarkRead, onMarkAllRead, onDismiss, renderItem, emptyState, }: NotificationDigestCardProps): react.JSX.Element;
44
+
45
+ export { NotificationDigestCard, type NotificationDigestCardProps, TIER_STYLES, type UseNotificationDigestOptions, type UseNotificationDigestResult, useNotificationDigest };
@@ -0,0 +1 @@
1
+ "use strict";var I=Object.create;var A=Object.defineProperty;var $=Object.getOwnPropertyDescriptor;var L=Object.getOwnPropertyNames;var _=Object.getPrototypeOf,P=Object.prototype.hasOwnProperty;var Y=(t,e)=>{for(var r in e)A(t,r,{get:e[r],enumerable:!0})},C=(t,e,r,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of L(e))!P.call(t,o)&&o!==r&&A(t,o,{get:()=>e[o],enumerable:!(s=$(e,o))||s.enumerable});return t};var M=(t,e,r)=>(r=t!=null?I(_(t)):{},C(e||!t||!t.__esModule?A(r,"default",{value:t,enumerable:!0}):r,t)),K=t=>C(A({},"__esModule",{value:!0}),t);var H={};Y(H,{NotificationDigestCard:()=>z,TIER_STYLES:()=>b,useNotificationDigest:()=>v});module.exports=K(H);var a=require("react");var k=["critical","attention","info"];function c(t){return`${t.source}:${t.id}`}function U(t){return k.indexOf(t)}function G(t,e){return U(t.tier)-U(e.tier)||Number(t.read)-Number(e.read)||Date.parse(e.createdAt)-Date.parse(t.createdAt)}function j(t){return[...t].sort(G)}function w(t,e){let r={critical:0,attention:0,info:0};for(let l of t)l.read||(r[l.tier]+=1);let s=r.critical+r.attention+r.info,o=k.find(l=>r[l]>0)??null;return{total:t.length,unread:s,unreadByTier:r,topTier:o,items:j(t),...e!==void 0?{summary:e}:{}}}function D(t=[]){return{items:B(t),dismissed:[]}}function B(t){let e=new Map;for(let r of t)e.set(c(r),r);return[...e.values()]}function E(t,e){switch(e.type){case"ingest":{let r=new Set(t.dismissed),s=new Map(t.items.map(o=>[c(o),o]));for(let o of e.notifications){let l=c(o);if(r.has(l))continue;let u=s.get(l);s.set(l,u?.read?{...o,read:!0}:o)}return{...t,items:[...s.values()]}}case"markRead":return{...t,items:t.items.map(r=>c(r)===e.key?{...r,read:!0}:r)};case"markAllRead":return{...t,items:t.items.map(r=>r.read?r:{...r,read:!0})};case"dismiss":return{items:t.items.filter(r=>c(r)!==e.key),dismissed:t.dismissed.includes(e.key)?t.dismissed:[...t.dismissed,e.key]};case"reset":return D(e.notifications??[]);default:return t}}function v(t={}){let{initial:e,summarizer:r}=t,[s,o]=(0,a.useReducer)(E,e,i=>D(i)),[l,u]=(0,a.useState)(),[N,p]=(0,a.useState)(!1),[T,m]=(0,a.useState)(null),x=(0,a.useRef)(r);x.current=r;let g=(0,a.useMemo)(()=>s.items.filter(i=>!i.read),[s.items]),R=JSON.stringify(g.map(i=>[i.source,i.id,i.tier,i.title,i.body??""]).sort((i,d)=>i.join("\0")<d.join("\0")?-1:1));return(0,a.useEffect)(()=>{u(void 0);let i=x.current;if(!i||g.length===0){p(!1),m(null);return}let d=new AbortController;return p(!0),m(null),i(g,d.signal).then(y=>{d.signal.aborted||u(y)}).catch(y=>{d.signal.aborted||m(y instanceof Error?y:new Error(String(y)))}).finally(()=>{d.signal.aborted||p(!1)}),()=>d.abort()},[R]),{digest:(0,a.useMemo)(()=>w(s.items,l),[s.items,l]),ingest:(0,a.useCallback)(i=>o({type:"ingest",notifications:i}),[]),markRead:(0,a.useCallback)(i=>o({type:"markRead",key:i}),[]),markAllRead:(0,a.useCallback)(()=>o({type:"markAllRead"}),[]),dismiss:(0,a.useCallback)(i=>o({type:"dismiss",key:i}),[]),reset:(0,a.useCallback)(i=>o({type:"reset",notifications:i}),[]),isSummarizing:N,summaryError:T}}var f=M(require("@radix-ui/react-collapsible")),S=require("react");var F=new Set(["http:","https:","mailto:"]);function O(t){if(!t)return!1;let e=t.replace(/[\u0000-\u0020\u007f-\u009f]/g,"");if(e.startsWith("//"))return!1;if(/^[/#?.]/.test(e))return!0;try{return F.has(new URL(e).protocol)}catch{return!/^[^/?#]*:/.test(e)}}var n=require("react/jsx-runtime"),b={critical:{rail:"border-l-4 border-l-rose-500",glyph:"!",label:"Critical"},attention:{rail:"border-l-4 border-l-amber-400",glyph:"\u2022",label:"Needs attention"},info:{rail:"border-l-4 border-l-sky-500",glyph:"i",label:"Info"}},J="sunshade:notification-digest:open";function W(t,e){if(!t||typeof window>"u")return e;try{let r=window.localStorage.getItem(t);return r===null?e:r==="1"}catch{return e}}function q(t,e){if(!(!t||typeof window>"u"))try{window.localStorage.setItem(t,e?"1":"0")}catch{}}function z({digest:t,title:e="Notifications",defaultOpen:r=!1,storageKey:s=J,className:o="",onMarkRead:l,onMarkAllRead:u,onDismiss:N,renderItem:p,emptyState:T=(0,n.jsx)("p",{className:"px-4 py-3 text-sm text-slate-500",children:"You're all caught up."})}){let[m,x]=(0,S.useState)(()=>W(s,r)),g=(0,S.useCallback)(i=>{x(i),q(s,i)},[s]),R=t.topTier??"info",h=b[R];return(0,n.jsxs)(f.Root,{open:m,onOpenChange:g,"data-testid":"notification-digest-card","data-tier":R,className:`rounded-md border border-slate-200 bg-white ${h.rail} ${o}`.trim(),children:[(0,n.jsxs)("div",{className:"flex items-center justify-between gap-2 px-4 py-3",children:[(0,n.jsx)(f.Trigger,{asChild:!0,children:(0,n.jsxs)("button",{type:"button",className:"flex flex-1 items-center gap-2 text-left font-medium",children:[(0,n.jsx)("span",{"aria-hidden":"true",children:h.glyph}),(0,n.jsx)("span",{children:e}),t.unread>0&&(0,n.jsx)("span",{"data-testid":"unread-badge","aria-label":`${t.unread} unread`,className:"animate-pulse rounded-full bg-rose-500 px-2 py-0.5 text-xs text-white",children:t.unread})]})}),t.unread>0&&u&&(0,n.jsx)("button",{type:"button",className:"text-xs text-slate-500 underline",onClick:u,children:"Mark all read"})]}),t.summary&&(0,n.jsx)("p",{className:"px-4 pb-2 text-sm text-slate-600",children:t.summary}),(0,n.jsx)(f.Content,{children:t.items.length===0?T:(0,n.jsx)("ul",{className:"divide-y divide-slate-100",children:t.items.map(i=>p?(0,n.jsx)("li",{children:p(i)},c(i)):(0,n.jsxs)("li",{"data-tier":i.tier,className:`${b[i.tier].rail} flex items-start gap-2 px-4 py-2 ${i.read?"opacity-60":""}`,children:[(0,n.jsx)("span",{"aria-hidden":"true",children:b[i.tier].glyph}),(0,n.jsxs)("div",{className:"flex-1",children:[(0,n.jsx)("p",{className:"text-sm font-medium",children:i.title}),i.body&&(0,n.jsx)("p",{className:"text-xs text-slate-600",children:i.body}),O(i.actionUrl)&&(0,n.jsx)("a",{href:i.actionUrl,className:"text-xs text-sky-600 underline",children:i.actionLabel??"Open"})]}),!i.read&&l&&(0,n.jsx)("button",{type:"button",className:"text-xs",onClick:()=>l(c(i)),children:"Mark read"}),N&&(0,n.jsx)("button",{type:"button","aria-label":`Dismiss ${i.title}`,className:"text-xs",onClick:()=>N(c(i)),children:"\u2715"})]},c(i)))})})]})}0&&(module.exports={NotificationDigestCard,TIER_STYLES,useNotificationDigest});
@@ -0,0 +1 @@
1
+ import{b as g,f as k,g as v,h as w,i as E}from"../chunk-RCMJRFBC.mjs";import{useCallback as b,useEffect as O,useMemo as A,useReducer as T,useRef as U,useState as S}from"react";function z(e={}){let{initial:o,summarizer:r}=e,[a,n]=T(w,o,t=>v(t)),[d,p]=S(),[N,l]=S(!1),[h,u]=S(null),x=U(r);x.current=r;let m=A(()=>a.items.filter(t=>!t.read),[a.items]),R=JSON.stringify(m.map(t=>[t.source,t.id,t.tier,t.title,t.body??""]).sort((t,s)=>t.join("\0")<s.join("\0")?-1:1));return O(()=>{p(void 0);let t=x.current;if(!t||m.length===0){l(!1),u(null);return}let s=new AbortController;return l(!0),u(null),t(m,s.signal).then(f=>{s.signal.aborted||p(f)}).catch(f=>{s.signal.aborted||u(f instanceof Error?f:new Error(String(f)))}).finally(()=>{s.signal.aborted||l(!1)}),()=>s.abort()},[R]),{digest:A(()=>k(a.items,d),[a.items,d]),ingest:b(t=>n({type:"ingest",notifications:t}),[]),markRead:b(t=>n({type:"markRead",key:t}),[]),markAllRead:b(()=>n({type:"markAllRead"}),[]),dismiss:b(t=>n({type:"dismiss",key:t}),[]),reset:b(t=>n({type:"reset",notifications:t}),[]),isSummarizing:N,summaryError:h}}import*as c from"@radix-ui/react-collapsible";import{useCallback as I,useState as $}from"react";import{jsx as i,jsxs as y}from"react/jsx-runtime";var C={critical:{rail:"border-l-4 border-l-rose-500",glyph:"!",label:"Critical"},attention:{rail:"border-l-4 border-l-amber-400",glyph:"\u2022",label:"Needs attention"},info:{rail:"border-l-4 border-l-sky-500",glyph:"i",label:"Info"}},L="sunshade:notification-digest:open";function Y(e,o){if(!e||typeof window>"u")return o;try{let r=window.localStorage.getItem(e);return r===null?o:r==="1"}catch{return o}}function _(e,o){if(!(!e||typeof window>"u"))try{window.localStorage.setItem(e,o?"1":"0")}catch{}}function M({digest:e,title:o="Notifications",defaultOpen:r=!1,storageKey:a=L,className:n="",onMarkRead:d,onMarkAllRead:p,onDismiss:N,renderItem:l,emptyState:h=i("p",{className:"px-4 py-3 text-sm text-slate-500",children:"You're all caught up."})}){let[u,x]=$(()=>Y(a,r)),m=I(t=>{x(t),_(a,t)},[a]),R=e.topTier??"info",D=C[R];return y(c.Root,{open:u,onOpenChange:m,"data-testid":"notification-digest-card","data-tier":R,className:`rounded-md border border-slate-200 bg-white ${D.rail} ${n}`.trim(),children:[y("div",{className:"flex items-center justify-between gap-2 px-4 py-3",children:[i(c.Trigger,{asChild:!0,children:y("button",{type:"button",className:"flex flex-1 items-center gap-2 text-left font-medium",children:[i("span",{"aria-hidden":"true",children:D.glyph}),i("span",{children:o}),e.unread>0&&i("span",{"data-testid":"unread-badge","aria-label":`${e.unread} unread`,className:"animate-pulse rounded-full bg-rose-500 px-2 py-0.5 text-xs text-white",children:e.unread})]})}),e.unread>0&&p&&i("button",{type:"button",className:"text-xs text-slate-500 underline",onClick:p,children:"Mark all read"})]}),e.summary&&i("p",{className:"px-4 pb-2 text-sm text-slate-600",children:e.summary}),i(c.Content,{children:e.items.length===0?h:i("ul",{className:"divide-y divide-slate-100",children:e.items.map(t=>l?i("li",{children:l(t)},g(t)):y("li",{"data-tier":t.tier,className:`${C[t.tier].rail} flex items-start gap-2 px-4 py-2 ${t.read?"opacity-60":""}`,children:[i("span",{"aria-hidden":"true",children:C[t.tier].glyph}),y("div",{className:"flex-1",children:[i("p",{className:"text-sm font-medium",children:t.title}),t.body&&i("p",{className:"text-xs text-slate-600",children:t.body}),E(t.actionUrl)&&i("a",{href:t.actionUrl,className:"text-xs text-sky-600 underline",children:t.actionLabel??"Open"})]}),!t.read&&d&&i("button",{type:"button",className:"text-xs",onClick:()=>d(g(t)),children:"Mark read"}),N&&i("button",{type:"button","aria-label":`Dismiss ${t.title}`,className:"text-xs",onClick:()=>N(g(t)),children:"\u2715"})]},g(t)))})})]})}export{M as NotificationDigestCard,C as TIER_STYLES,z as useNotificationDigest};
@@ -0,0 +1,57 @@
1
+ /** Urgency tiers, ordered from most to least urgent. */
2
+ type UrgencyTier = 'critical' | 'attention' | 'info';
3
+ declare const URGENCY_TIERS: readonly UrgencyTier[];
4
+ interface ActionableNotification {
5
+ id: string;
6
+ tier: UrgencyTier;
7
+ title: string;
8
+ body?: string;
9
+ /** Originating app/feature, e.g. "tracker", "cozy", "hub". */
10
+ source: string;
11
+ /** ISO-8601 timestamp. */
12
+ createdAt: string;
13
+ read: boolean;
14
+ actionUrl?: string;
15
+ actionLabel?: string;
16
+ /** Optional grouping key (e.g. peer care drops from the same sender). */
17
+ groupKey?: string;
18
+ }
19
+ interface DigestRollup {
20
+ total: number;
21
+ unread: number;
22
+ unreadByTier: Record<UrgencyTier, number>;
23
+ /** Highest tier among unread items, or null when everything is read. */
24
+ topTier: UrgencyTier | null;
25
+ /** Items sorted by tier, then unread first, then newest first. */
26
+ items: ActionableNotification[];
27
+ /** Optional AI-generated summary of the digest. */
28
+ summary?: string;
29
+ }
30
+ /** AI summarization contract: implemented by consuming apps. */
31
+ type DigestSummarizer = (items: readonly ActionableNotification[], signal?: AbortSignal) => Promise<string>;
32
+ interface TriageState {
33
+ items: ActionableNotification[];
34
+ /** Keys (see `notificationKey`) of dismissed items; re-ingesting them is a no-op. */
35
+ dismissed: string[];
36
+ }
37
+ /**
38
+ * Actions address notifications by composite key (`source:id`), since IDs are
39
+ * only unique within a source. Use `notificationKey()` to build one.
40
+ */
41
+ type TriageAction = {
42
+ type: 'ingest';
43
+ notifications: ActionableNotification[];
44
+ } | {
45
+ type: 'markRead';
46
+ key: string;
47
+ } | {
48
+ type: 'markAllRead';
49
+ } | {
50
+ type: 'dismiss';
51
+ key: string;
52
+ } | {
53
+ type: 'reset';
54
+ notifications?: ActionableNotification[];
55
+ };
56
+
57
+ export { type ActionableNotification as A, type DigestRollup as D, type TriageState as T, type UrgencyTier as U, type TriageAction as a, type DigestSummarizer as b, URGENCY_TIERS as c };
@@ -0,0 +1,57 @@
1
+ /** Urgency tiers, ordered from most to least urgent. */
2
+ type UrgencyTier = 'critical' | 'attention' | 'info';
3
+ declare const URGENCY_TIERS: readonly UrgencyTier[];
4
+ interface ActionableNotification {
5
+ id: string;
6
+ tier: UrgencyTier;
7
+ title: string;
8
+ body?: string;
9
+ /** Originating app/feature, e.g. "tracker", "cozy", "hub". */
10
+ source: string;
11
+ /** ISO-8601 timestamp. */
12
+ createdAt: string;
13
+ read: boolean;
14
+ actionUrl?: string;
15
+ actionLabel?: string;
16
+ /** Optional grouping key (e.g. peer care drops from the same sender). */
17
+ groupKey?: string;
18
+ }
19
+ interface DigestRollup {
20
+ total: number;
21
+ unread: number;
22
+ unreadByTier: Record<UrgencyTier, number>;
23
+ /** Highest tier among unread items, or null when everything is read. */
24
+ topTier: UrgencyTier | null;
25
+ /** Items sorted by tier, then unread first, then newest first. */
26
+ items: ActionableNotification[];
27
+ /** Optional AI-generated summary of the digest. */
28
+ summary?: string;
29
+ }
30
+ /** AI summarization contract: implemented by consuming apps. */
31
+ type DigestSummarizer = (items: readonly ActionableNotification[], signal?: AbortSignal) => Promise<string>;
32
+ interface TriageState {
33
+ items: ActionableNotification[];
34
+ /** Keys (see `notificationKey`) of dismissed items; re-ingesting them is a no-op. */
35
+ dismissed: string[];
36
+ }
37
+ /**
38
+ * Actions address notifications by composite key (`source:id`), since IDs are
39
+ * only unique within a source. Use `notificationKey()` to build one.
40
+ */
41
+ type TriageAction = {
42
+ type: 'ingest';
43
+ notifications: ActionableNotification[];
44
+ } | {
45
+ type: 'markRead';
46
+ key: string;
47
+ } | {
48
+ type: 'markAllRead';
49
+ } | {
50
+ type: 'dismiss';
51
+ key: string;
52
+ } | {
53
+ type: 'reset';
54
+ notifications?: ActionableNotification[];
55
+ };
56
+
57
+ export { type ActionableNotification as A, type DigestRollup as D, type TriageState as T, type UrgencyTier as U, type TriageAction as a, type DigestSummarizer as b, URGENCY_TIERS as c };
package/package.json CHANGED
@@ -1,6 +1,61 @@
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.2",
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
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.mjs",
20
+ "require": "./dist/index.js"
21
+ },
22
+ "./react": {
23
+ "types": "./dist/react/index.d.ts",
24
+ "import": "./dist/react/index.mjs",
25
+ "require": "./dist/react/index.js"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "tsup src/index.ts src/react/index.ts --dts --format cjs,esm --minify",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:boundaries": "vitest run"
33
+ },
34
+ "dependencies": {
35
+ "@radix-ui/react-collapsible": "^1.1.3"
36
+ },
37
+ "peerDependencies": {
38
+ "react": ">=18.0.0 || >=19.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "^19.1.0",
47
+ "react": "^19.1.0",
48
+ "tsup": "^8.5.1",
49
+ "typescript": "^5.7.3",
50
+ "vitest": "^3.0.5"
51
+ },
52
+ "keywords": [
53
+ "sunshade",
54
+ "notifications",
55
+ "triage",
56
+ "digest",
57
+ "react"
58
+ ],
59
+ "author": "SunShade Systems",
60
+ "license": "MIT"
61
+ }