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)
160 lines
4.7 KiB
TypeScript
160 lines
4.7 KiB
TypeScript
/**
|
|
* Familiar duration references for time-blindness aids.
|
|
*
|
|
* "About as long as a movie", "3 episodes of The Office".
|
|
* Pure client-side TS — no backend dependency.
|
|
*/
|
|
|
|
import type { TimeRangeEntry, TimeReference } from './types.js';
|
|
|
|
const DEFAULT_DATABASE: TimeRangeEntry[] = [
|
|
{
|
|
minHours: 0,
|
|
maxHours: 0.25,
|
|
references: [
|
|
{ text: 'About as long as a coffee break', emoji: '☕', category: 'activity' },
|
|
{ text: 'Like listening to a few songs', emoji: '🎵', category: 'media' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 0.25,
|
|
maxHours: 0.5,
|
|
references: [
|
|
{ text: 'About as long as a TV episode', emoji: '📺', category: 'media' },
|
|
{ text: 'Like a short walk around the block', emoji: '🚶', category: 'activity' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 0.5,
|
|
maxHours: 1,
|
|
references: [
|
|
{ text: 'About as long as a yoga class', emoji: '🧘', category: 'activity' },
|
|
{ text: 'Like watching 2 episodes of The Office', emoji: '📺', category: 'media' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 1,
|
|
maxHours: 2,
|
|
references: [
|
|
{ text: 'About as long as a movie', emoji: '🎬', category: 'media' },
|
|
{ text: 'Like a nice bike ride', emoji: '🚴', category: 'activity' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 2,
|
|
maxHours: 4,
|
|
references: [
|
|
{ text: 'About as long as a road trip to the next city', emoji: '🚗', category: 'travel' },
|
|
{ text: 'Like binge-watching a short series', emoji: '📺', category: 'media' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 4,
|
|
maxHours: 8,
|
|
references: [
|
|
{ text: 'About as long as a work day', emoji: '💼', category: 'activity' },
|
|
{ text: 'Like a full night of sleep', emoji: '😴', category: 'nature' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 8,
|
|
maxHours: 12,
|
|
references: [
|
|
{ text: 'About as long as a cross-country flight', emoji: '✈️', category: 'travel' },
|
|
{ text: 'Like sunrise to sunset in winter', emoji: '🌅', category: 'nature' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 12,
|
|
maxHours: 16,
|
|
references: [
|
|
{ text: 'About as long as daylight hours in summer', emoji: '☀️', category: 'nature' },
|
|
{
|
|
text: 'Like watching the entire Lord of the Rings trilogy (extended)',
|
|
emoji: '🧙',
|
|
category: 'media',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
minHours: 16,
|
|
maxHours: 24,
|
|
references: [
|
|
{ text: 'Almost a full day — impressive!', emoji: '🌍', category: 'nature' },
|
|
{ text: 'Like a full day of hiking', emoji: '🥾', category: 'activity' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 24,
|
|
maxHours: 48,
|
|
references: [
|
|
{ text: 'More than a full day!', emoji: '🏆', category: 'nature' },
|
|
{ text: 'Like a weekend camping trip', emoji: '⛺', category: 'activity' },
|
|
],
|
|
},
|
|
{
|
|
minHours: 48,
|
|
maxHours: Infinity,
|
|
references: [
|
|
{ text: 'An extraordinary duration — you are incredible!', emoji: '🌟', category: 'nature' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const FALLBACK: TimeReference = {
|
|
text: 'A meaningful amount of time',
|
|
emoji: '⏳',
|
|
category: 'nature',
|
|
};
|
|
|
|
const customRegistry: TimeRangeEntry[] = [];
|
|
|
|
function findReferences(hours: number, custom: TimeRangeEntry[]): TimeReference[] {
|
|
for (const entry of custom) {
|
|
if (hours >= entry.minHours && hours < entry.maxHours) {
|
|
return entry.references;
|
|
}
|
|
}
|
|
for (const entry of DEFAULT_DATABASE) {
|
|
if (hours >= entry.minHours && hours < entry.maxHours) {
|
|
return entry.references;
|
|
}
|
|
}
|
|
return [FALLBACK];
|
|
}
|
|
|
|
export function getTimeReference(elapsedHours: number): TimeReference {
|
|
const refs = findReferences(Math.max(0, elapsedHours), customRegistry);
|
|
const index = Math.floor(Math.random() * refs.length);
|
|
return refs[index];
|
|
}
|
|
|
|
export function getEpisodeComparison(
|
|
elapsedHours: number,
|
|
showName = 'The Office',
|
|
episodeMins = 22
|
|
): string {
|
|
const totalMins = elapsedHours * 60;
|
|
const episodes = Math.round(totalMins / episodeMins);
|
|
if (episodes <= 0) return `Less than one episode of ${showName}`;
|
|
if (episodes === 1) return `About 1 episode of ${showName}`;
|
|
return `About ${episodes} episodes of ${showName}`;
|
|
}
|
|
|
|
export function getEncouragingMessage(elapsedHours: number): string {
|
|
if (elapsedHours < 1) return 'Every minute counts — great start!';
|
|
if (elapsedHours < 4) return 'You are building momentum — keep going!';
|
|
if (elapsedHours < 8) return 'Impressive dedication — halfway through the day!';
|
|
if (elapsedHours < 16) return 'Amazing endurance — you are a champion!';
|
|
if (elapsedHours < 24) return 'Nearly a full day — extraordinary commitment!';
|
|
return 'Beyond a full day — you are truly remarkable!';
|
|
}
|
|
|
|
export function registerReferences(entries: TimeRangeEntry[]): void {
|
|
customRegistry.push(...entries);
|
|
}
|
|
|
|
export function clearCustomReferences(): void {
|
|
customRegistry.length = 0;
|
|
}
|