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)
226 lines
6.2 KiB
TypeScript
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,
|
|
};
|
|
}
|