@@ -86,6 +86,8 @@ interface NotificationHistory {
8686
8787interface NotificationConfig {
8888 quietHours : { start : number ; end : number } ;
89+ workHours : { start : number ; end : number } ; // School IT hours — batch non-critical
90+ workdayBatchTime : number ; // Hour to send work-day queued digest
8991 maxSmsPerHour : number ;
9092 maxPerDay : number ;
9193 batchTime : number ; // Hour to send batched notifications
@@ -103,10 +105,12 @@ interface ChannelConfig {
103105// ─── Default Configuration ───────────────────────────────────────────────────
104106
105107const DEFAULT_CONFIG : NotificationConfig = {
106- quietHours : { start : 22 , end : 7 } , // 10pm - 7am Indiana time
108+ quietHours : { start : 22 , end : 7 } , // 10pm - 7am Indiana time (sleep)
109+ workHours : { start : 7 , end : 16 } , // 7am - 4pm (school IT — non-critical batched)
110+ workdayBatchTime : 16 , // 4pm digest of work-day queued items
107111 maxSmsPerHour : 5 ,
108112 maxPerDay : 20 ,
109- batchTime : 7 , // 7am - send queued notifications
113+ batchTime : 7 , // 7am - send overnight queued notifications
110114 escalationThreshold : 3 , // 3 same P1 errors → escalate to P0
111115 timezone : 'America/Indiana/Indianapolis' ,
112116 cooldowns : {
@@ -116,7 +120,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
116120 milestone : 120 , // Nice-to-know, not urgent — was 30
117121 insight : 360 , // Max 2-3/day — was 60
118122 question : 0 , // ARI needs input — keep at 0
119- reminder : 0 ,
123+ reminder : 60 , // Max one reminder per hour — was 0 (too noisy)
120124 finance : 240 , // 4hr — pre/post-market briefings handle the rest
121125 task : 30 , // Batch more — was 15
122126 system : 120 , // Background info — was 60
@@ -125,7 +129,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
125129 billing : 1440 , // Once per day
126130 value : 720 , // Twice per day max
127131 adaptive : 1440 , // Once per day
128- governance : 60 , // Once per hour
132+ governance : 120 , // Once per 2hr — was 60 (council vote noise)
129133 } ,
130134} ;
131135
@@ -275,24 +279,33 @@ export class NotificationManager {
275279 pLevel : TypedPriority
276280 ) : Promise < NotificationResult > {
277281 const isQuiet = this . isQuietHours ( ) ;
282+ const isWork = ! isQuiet && this . isWorkHours ( ) ;
278283 const channels : NotificationResult [ 'channels' ] = { } ;
279284 let sent = false ;
280285 let reason = '' ;
281286
282- // P0 (critical): SMS + Telegram + Notion — bypasses quiet hours
287+ // P0 (critical): SMS + Telegram + Notion — bypasses everything
283288 if ( pLevel === 'P0' ) {
284289 channels . sms = await this . sendSMS ( request , true ) ;
285290 channels . telegram = await this . sendTelegram ( request , true ) ;
286291 channels . notion = await this . sendNotion ( request , pLevel ) ;
287292 sent = ( channels . telegram ?. sent ?? false ) || ( channels . sms ?. sent ?? false ) || ! ! channels . notion ?. pageId ;
288293 reason = 'P0: Immediate delivery (all channels)' ;
289294 }
290- // P1 during work hours: Telegram + Notion
295+ // Work hours (7am-4pm M-F): only interrupt-worthy categories fire immediately
296+ // Everything else queues for 4PM workday digest
297+ else if ( isWork && ! this . isTelegramEligibleDuringWork ( request . category ) ) {
298+ this . queueForWorkday ( request , pLevel ) ;
299+ channels . notion = await this . sendNotion ( request , pLevel ) ;
300+ sent = false ;
301+ reason = `${ pLevel } : Deferred to 4 PM work-day digest` ;
302+ }
303+ // P1 during active hours: Telegram + Notion
291304 else if ( pLevel === 'P1' && ! isQuiet ) {
292305 channels . telegram = await this . sendTelegram ( request , false ) ;
293306 channels . notion = await this . sendNotion ( request , pLevel ) ;
294307 sent = ( channels . telegram ?. sent ?? false ) || ! ! channels . notion ?. pageId ;
295- reason = 'P1: Work hours delivery (Telegram + Notion)' ;
308+ reason = 'P1: Active hours delivery (Telegram + Notion)' ;
296309 }
297310 // P1 during quiet hours: Queue for morning Telegram
298311 else if ( pLevel === 'P1' && isQuiet ) {
@@ -301,7 +314,7 @@ export class NotificationManager {
301314 sent = false ;
302315 reason = 'P1: Queued for 7 AM Telegram (quiet hours)' ;
303316 }
304- // P2 during work hours: Telegram + Notion
317+ // P2 during active hours: Telegram silent + Notion
305318 else if ( pLevel === 'P2' && ! isQuiet ) {
306319 channels . telegram = await this . sendTelegram ( request , false , true ) ; // silent push
307320 channels . notion = await this . sendNotion ( request , pLevel ) ;
@@ -314,7 +327,7 @@ export class NotificationManager {
314327 sent = false ;
315328 reason = `${ pLevel } : Queued for morning` ;
316329 }
317- // P3/P4 during work hours : Notion batched
330+ // P3/P4: Notion batched
318331 else {
319332 this . queueForBatch ( request , pLevel ) ;
320333 sent = false ;
@@ -619,6 +632,76 @@ export class NotificationManager {
619632 return hour >= start && hour < end ;
620633 }
621634
635+ /**
636+ * Work hours: 7am-4pm weekdays (school IT — Pryce is at work, can't be interrupted).
637+ * During work hours only security, error, question categories reach Telegram immediately.
638+ * Everything else queues for the 4 PM work-day digest.
639+ */
640+ private isWorkHours ( ) : boolean {
641+ const now = new Date ( ) ;
642+ const indiana = new Date (
643+ now . toLocaleString ( 'en-US' , { timeZone : this . config . timezone } )
644+ ) ;
645+ const hour = indiana . getHours ( ) ;
646+ const day = indiana . getDay ( ) ; // 0=Sun, 6=Sat
647+
648+ if ( day === 0 || day === 6 ) return false ; // Weekdays only
649+ const { start, end } = this . config . workHours ;
650+ return hour >= start && hour < end ;
651+ }
652+
653+ /**
654+ * Categories that can still interrupt Telegram during work hours.
655+ * Anything not in this list queues for 4 PM.
656+ */
657+ private isTelegramEligibleDuringWork ( category : NotificationCategory ) : boolean {
658+ const eligible : ReadonlySet < NotificationCategory > = new Set ( [
659+ 'security' , // Always interrupts — never silence security
660+ 'error' , // Critical system errors need attention
661+ 'question' , // ARI is asking for input — needs response
662+ ] ) ;
663+ return eligible . has ( category ) ;
664+ }
665+
666+ /**
667+ * Queue notification for 4 PM work-day digest delivery.
668+ */
669+ private queueForWorkday ( request : NotificationRequest , pLevel : TypedPriority ) : void {
670+ const now = new Date ( ) ;
671+ const indiana = new Date (
672+ now . toLocaleString ( 'en-US' , { timeZone : this . config . timezone } )
673+ ) ;
674+
675+ const workdayEnd = new Date ( indiana ) ;
676+ workdayEnd . setHours ( this . config . workdayBatchTime , 0 , 0 , 0 ) ;
677+
678+ // If past 4 PM, queue for tomorrow 4 PM (shouldn't happen — isWorkHours() gates this)
679+ if ( indiana . getHours ( ) >= this . config . workdayBatchTime ) {
680+ workdayEnd . setDate ( workdayEnd . getDate ( ) + 1 ) ;
681+ }
682+
683+ const entry : NotificationEntry = {
684+ id : crypto . randomUUID ( ) ,
685+ priority : pLevel ,
686+ title : request . title ,
687+ body : request . body ,
688+ category : request . category ,
689+ channel : 'both' ,
690+ queuedAt : now . toISOString ( ) ,
691+ queuedFor : workdayEnd . toISOString ( ) ,
692+ smsSent : false ,
693+ notionSent : false ,
694+ dedupKey : request . dedupKey ,
695+ escalationCount : 0 ,
696+ } ;
697+
698+ this . batchQueue . push ( {
699+ entry,
700+ scheduledFor : workdayEnd . toISOString ( ) ,
701+ reason : 'work_hours_batch' ,
702+ } ) ;
703+ }
704+
622705 /**
623706 * Update Focus Mode state (called by scheduler or agent poll)
624707 * When Focus is active, treat as quiet hours for notification routing.
0 commit comments