learning_ai_common_plat/packages/celebrations/src/client.ts
saravanakumardb1 be03efa111 feat(shared-packages): add 9 @bytelyst/* client packages with 100% API coverage
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)
2026-03-19 13:10:09 -07:00

226 lines
6.2 KiB
TypeScript

/**
* Product-agnostic celebration engine.
*
* Provides milestone triggers, haptic configs, confetti, sounds,
* and positive reinforcement messages.
* Pure client-side TS — no backend dependency.
*/
import type {
Celebration,
CelebrationConfig,
CelebrationEngine,
CelebrationTrigger,
} from './types.js';
const DEFAULT_CELEBRATIONS: Record<CelebrationTrigger, Celebration> = {
task_completed: {
id: 'task_completed',
title: 'Task Done!',
body: 'Great work completing that task!',
emoji: '✅',
hapticType: 'light',
confetti: false,
sound: 'chime',
},
streak_continued: {
id: 'streak_continued',
title: 'Streak Alive!',
body: 'You kept your streak going — amazing consistency!',
emoji: '🔥',
hapticType: 'medium',
confetti: false,
sound: 'chime',
},
streak_milestone: {
id: 'streak_milestone',
title: 'Streak Milestone!',
body: 'What an incredible streak — you are unstoppable!',
emoji: '⭐',
hapticType: 'heavy',
confetti: true,
sound: 'success',
},
achievement_unlocked: {
id: 'achievement_unlocked',
title: 'Achievement Unlocked!',
body: 'You just unlocked a new achievement!',
emoji: '🏆',
hapticType: 'heavy',
confetti: true,
sound: 'success',
},
level_up: {
id: 'level_up',
title: 'Level Up!',
body: 'You reached a new level — keep going!',
emoji: '🚀',
hapticType: 'heavy',
confetti: true,
sound: 'level_up',
},
personal_best: {
id: 'personal_best',
title: 'Personal Best!',
body: 'You just set a new personal record!',
emoji: '🥇',
hapticType: 'heavy',
confetti: true,
sound: 'success',
},
milestone_reached: {
id: 'milestone_reached',
title: 'Milestone Reached!',
body: 'Another milestone in the books — well done!',
emoji: '🎯',
hapticType: 'medium',
confetti: true,
sound: 'success',
},
goal_completed: {
id: 'goal_completed',
title: 'Goal Complete!',
body: 'You achieved your goal — celebrate this win!',
emoji: '🎉',
hapticType: 'heavy',
confetti: true,
sound: 'success',
},
first_action: {
id: 'first_action',
title: 'First Step!',
body: 'Every journey begins with a single step — great start!',
emoji: '👣',
hapticType: 'medium',
confetti: true,
sound: 'chime',
},
halfway: {
id: 'halfway',
title: 'Halfway There!',
body: "You're halfway through — the finish line is in sight!",
emoji: '⏳',
hapticType: 'medium',
confetti: false,
sound: 'chime',
},
session_completed: {
id: 'session_completed',
title: 'Session Complete!',
body: 'Another session done — you showed up and that matters!',
emoji: '💪',
hapticType: 'medium',
confetti: false,
sound: 'success',
},
session_started: {
id: 'session_started',
title: 'Session Started!',
body: 'You just started — showing up is the hardest part!',
emoji: '🌟',
hapticType: 'light',
confetti: false,
sound: 'none',
},
};
const POSITIVE_MESSAGES: string[] = [
"You're doing amazing!",
'Keep it up — every step counts!',
"Look at you go — you're incredible!",
'Your dedication is inspiring!',
'Consistency is your superpower!',
"You're making real progress!",
'One step at a time — and you nailed it!',
'Be proud of how far you have come!',
];
const POSITIVE_INCOMPLETE_MESSAGES: string[] = [
'Every bit of progress counts!',
'You showed up — that takes courage!',
"It's okay to take a break — you'll come back stronger!",
'Progress, not perfection!',
'You started, and that matters most!',
'Rest is part of the journey!',
"You've done more than you think!",
'Tomorrow is another opportunity!',
];
const TIMED_MILESTONES = [
{ fraction: 0.25, id: 'timed_25' },
{ fraction: 0.5, id: 'timed_50' },
{ fraction: 0.75, id: 'timed_75' },
{ fraction: 1.0, id: 'timed_100' },
];
const FALLBACK_CELEBRATION: Celebration = {
id: 'generic',
title: 'Nice!',
body: 'Keep up the great work!',
emoji: '🎉',
hapticType: 'light',
confetti: false,
sound: 'chime',
};
export function createCelebrationEngine(config?: CelebrationConfig): CelebrationEngine {
const customTriggers = config?.customTriggers ?? {};
function getCelebration(trigger: CelebrationTrigger | string): Celebration {
if (trigger in customTriggers) return customTriggers[trigger];
if (trigger in DEFAULT_CELEBRATIONS) return DEFAULT_CELEBRATIONS[trigger as CelebrationTrigger];
return { ...FALLBACK_CELEBRATION, id: trigger };
}
function getTimedCelebrations(
elapsedMs: number,
targetMs: number,
shownIds: Set<string>
): Celebration[] {
if (targetMs <= 0) return [];
const progress = elapsedMs / targetMs;
const results: Celebration[] = [];
for (const milestone of TIMED_MILESTONES) {
if (progress >= milestone.fraction && !shownIds.has(milestone.id)) {
const percent = Math.round(milestone.fraction * 100);
results.push({
id: milestone.id,
title: `${percent}% Complete!`,
body: getPositiveMessage(percent),
emoji: milestone.fraction === 1.0 ? '🎉' : '⏳',
hapticType: milestone.fraction === 1.0 ? 'heavy' : 'medium',
confetti: milestone.fraction >= 0.75,
sound: milestone.fraction === 1.0 ? 'success' : 'chime',
});
}
}
return results;
}
function isPersonalBest(current: number, previous: number): boolean {
return current > previous && previous >= 0;
}
function getPositiveMessage(progressPercent: number): string {
const clamped = Math.max(0, Math.min(100, progressPercent));
const index = Math.floor((clamped / 100) * (POSITIVE_MESSAGES.length - 1));
return POSITIVE_MESSAGES[index];
}
function getPositiveIncompleteMessage(progressPercent: number): string {
const clamped = Math.max(0, Math.min(100, progressPercent));
const index = Math.floor((clamped / 100) * (POSITIVE_INCOMPLETE_MESSAGES.length - 1));
return POSITIVE_INCOMPLETE_MESSAGES[index];
}
return {
getCelebration,
getTimedCelebrations,
isPersonalBest,
getPositiveMessage,
getPositiveIncompleteMessage,
};
}