Packages added: - @bytelyst/referral-client — referral API client + share helpers - @bytelyst/subscription-client — subscription/plan API client + cache - @bytelyst/celebrations — milestone triggers, confetti, positive messages - @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases - @bytelyst/accessibility — VoiceOver/TalkBack label generators - @bytelyst/quick-actions — progressive disclosure, smart defaults - @bytelyst/time-references — familiar duration references - @bytelyst/org-client — org/workspace/membership/license API client - @bytelyst/marketplace-client — listing/review/install API client All packages: pure TS, ESM, globalThis.fetch, no Node.js deps. 99 Vitest tests across 9 packages, 79/79 public methods covered. Review fixes applied: - time-references: fix module-level mutable state leak + add clearCustomReferences() - accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords - subscription-client: fix flaky daysRemaining test (ms boundary race)
158 lines
4.3 KiB
TypeScript
158 lines
4.3 KiB
TypeScript
/**
|
|
* Neurodivergent-friendly notification messaging system.
|
|
*
|
|
* Encouraging tone, adaptive frequency, forbidden phrases.
|
|
* Pure client-side TS — no backend dependency.
|
|
*/
|
|
|
|
import type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js';
|
|
|
|
export const FORBIDDEN_PHRASES: readonly string[] = [
|
|
"You haven't",
|
|
'You forgot',
|
|
"Don't forget",
|
|
'You should have',
|
|
"Why didn't you",
|
|
'You missed',
|
|
'You failed',
|
|
'You need to',
|
|
] as const;
|
|
|
|
const DEFAULT_MESSAGES: Record<string, GentleMessage[]> = {
|
|
reminder: [
|
|
{
|
|
title: 'Gentle Reminder',
|
|
body: 'Whenever you are ready, there is something waiting for you.',
|
|
tone: 'encouraging',
|
|
},
|
|
{
|
|
title: 'Quick Note',
|
|
body: 'No rush — just a friendly nudge when the time feels right.',
|
|
tone: 'encouraging',
|
|
},
|
|
{
|
|
title: 'Hey there',
|
|
body: 'Take your time. We will be here when you are ready.',
|
|
tone: 'neutral',
|
|
},
|
|
],
|
|
progress: [
|
|
{
|
|
title: 'Nice Progress',
|
|
body: 'Look at what you have accomplished — every step matters!',
|
|
tone: 'encouraging',
|
|
},
|
|
{
|
|
title: 'Moving Forward',
|
|
body: 'You are making progress at your own pace. That is perfect.',
|
|
tone: 'encouraging',
|
|
},
|
|
],
|
|
check_in: [
|
|
{
|
|
title: 'Check In',
|
|
body: 'How are you feeling? Remember, there is no wrong answer.',
|
|
tone: 'encouraging',
|
|
},
|
|
{ title: 'Quick Check', body: 'Just checking in — hope you are doing well!', tone: 'neutral' },
|
|
],
|
|
streak: [
|
|
{
|
|
title: 'Streak Update',
|
|
body: 'Your consistency is impressive — keep it going if it feels right!',
|
|
tone: 'encouraging',
|
|
},
|
|
],
|
|
idle: [
|
|
{
|
|
title: 'Welcome Back',
|
|
body: 'Great to see you again — no judgment, just glad you are here!',
|
|
tone: 'encouraging',
|
|
},
|
|
{
|
|
title: 'Hi Again',
|
|
body: 'Whenever you are ready to jump back in, we are here.',
|
|
tone: 'neutral',
|
|
},
|
|
],
|
|
};
|
|
|
|
export function createGentleNotificationEngine(
|
|
initialConfig?: Partial<GentleNotificationConfig>
|
|
): GentleNotificationEngine {
|
|
const messagePools: Record<string, GentleMessage[]> = { ...DEFAULT_MESSAGES };
|
|
|
|
function getDefaultConfig(): GentleNotificationConfig {
|
|
return {
|
|
maxPerHour: 3,
|
|
tone: 'encouraging',
|
|
adaptiveFrequency: true,
|
|
dismissCount: 0,
|
|
suppressThreshold: 5,
|
|
...initialConfig,
|
|
};
|
|
}
|
|
|
|
function getMessage(type: string, config?: GentleNotificationConfig): GentleMessage {
|
|
const tone = config?.tone ?? 'encouraging';
|
|
const pool = messagePools[type];
|
|
|
|
if (!pool || pool.length === 0) {
|
|
return {
|
|
title: 'Hey',
|
|
body: 'Hope you are having a good day!',
|
|
tone,
|
|
};
|
|
}
|
|
|
|
// Filter by tone if possible, fallback to any
|
|
const toneFiltered = pool.filter(m => m.tone === tone);
|
|
const candidates = toneFiltered.length > 0 ? toneFiltered : pool;
|
|
const index = Math.floor(Math.random() * candidates.length);
|
|
return candidates[index];
|
|
}
|
|
|
|
function shouldSuppress(config: GentleNotificationConfig): boolean {
|
|
if (config.adaptiveFrequency && config.dismissCount >= config.suppressThreshold) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig {
|
|
const newConfig = { ...config, dismissCount: config.dismissCount + 1 };
|
|
if (newConfig.adaptiveFrequency && newConfig.dismissCount >= newConfig.suppressThreshold) {
|
|
newConfig.maxPerHour = Math.max(1, Math.floor(newConfig.maxPerHour / 2));
|
|
}
|
|
return newConfig;
|
|
}
|
|
|
|
function resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig {
|
|
return { ...config, dismissCount: 0 };
|
|
}
|
|
|
|
function registerMessages(type: string, messages: GentleMessage[]): void {
|
|
messagePools[type] = [...(messagePools[type] ?? []), ...messages];
|
|
}
|
|
|
|
function getForbiddenPhrases(): readonly string[] {
|
|
return FORBIDDEN_PHRASES;
|
|
}
|
|
|
|
function containsForbiddenPhrase(text: string): boolean {
|
|
const lower = text.toLowerCase();
|
|
return FORBIDDEN_PHRASES.some(phrase => lower.includes(phrase.toLowerCase()));
|
|
}
|
|
|
|
return {
|
|
getDefaultConfig,
|
|
getMessage,
|
|
shouldSuppress,
|
|
recordDismissal,
|
|
resetDismissals,
|
|
registerMessages,
|
|
getForbiddenPhrases,
|
|
containsForbiddenPhrase,
|
|
};
|
|
}
|