feat(packages): create 9 NomGap-required platform packages
Create source implementations for packages imported by NomGap: - @bytelyst/accessibility — ARIA helper functions (alertLabel, progressLabel, etc.) - @bytelyst/celebrations — celebration engine for milestones - @bytelyst/gentle-notifications — guilt-free notification filtering - @bytelyst/time-references — human-friendly fasting time references - @bytelyst/subscription-client — billing/subscription HTTP client - @bytelyst/quick-actions — progressive disclosure UI helpers - @bytelyst/referral-client — referral program client - @bytelyst/marketplace-client — influencer marketplace client - @bytelyst/org-client — B2B org management client Made-with: Cursor
This commit is contained in:
parent
58c47a751a
commit
1ee97327ee
@ -2,23 +2,19 @@
|
||||
"name": "@bytelyst/accessibility",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "VoiceOver/TalkBack accessibility label generators for common UI patterns",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
"build": "tsc"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,154 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buttonLabel,
|
||||
timerLabel,
|
||||
progressLabel,
|
||||
sliderLabel,
|
||||
alertLabel,
|
||||
achievementLabel,
|
||||
streakLabel,
|
||||
listItemLabel,
|
||||
formatDurationForA11y,
|
||||
formatNumberForA11y,
|
||||
buildAnnouncement,
|
||||
getPositiveBreakMessage,
|
||||
} from './client.js';
|
||||
|
||||
describe('accessibility label generators', () => {
|
||||
it('buttonLabel returns correct props', () => {
|
||||
const props = buttonLabel('Start Timer', 'Begins a new timer');
|
||||
expect(props.accessible).toBe(true);
|
||||
expect(props.accessibilityLabel).toBe('Start Timer');
|
||||
expect(props.accessibilityHint).toBe('Begins a new timer');
|
||||
expect(props.accessibilityRole).toBe('button');
|
||||
});
|
||||
|
||||
it('timerLabel with context', () => {
|
||||
const props = timerLabel('running', '2 hours 30 minutes', 'Intermittent fasting');
|
||||
expect(props.accessibilityLabel).toContain('running');
|
||||
expect(props.accessibilityLabel).toContain('2 hours 30 minutes');
|
||||
expect(props.accessibilityLabel).toContain('Intermittent fasting');
|
||||
expect(props.accessibilityRole).toBe('timer');
|
||||
});
|
||||
|
||||
it('timerLabel without context', () => {
|
||||
const props = timerLabel('paused', '10 minutes');
|
||||
expect(props.accessibilityLabel).toBe('Timer paused, 10 minutes');
|
||||
});
|
||||
|
||||
it('progressLabel with description', () => {
|
||||
const props = progressLabel('Fasting', 75, 'On track');
|
||||
expect(props.accessibilityLabel).toContain('75 percent');
|
||||
expect(props.accessibilityLabel).toContain('On track');
|
||||
expect(props.accessibilityRole).toBe('progressbar');
|
||||
expect(props.accessibilityValue?.now).toBe(75);
|
||||
});
|
||||
|
||||
it('progressLabel clamps to 0-100', () => {
|
||||
const neg = progressLabel('Test', -10);
|
||||
expect(neg.accessibilityValue?.now).toBe(0);
|
||||
|
||||
const over = progressLabel('Test', 150);
|
||||
expect(over.accessibilityValue?.now).toBe(100);
|
||||
});
|
||||
|
||||
it('sliderLabel with max', () => {
|
||||
const props = sliderLabel('Volume', 7, 10);
|
||||
expect(props.accessibilityLabel).toBe('Volume: 7 of 10');
|
||||
expect(props.accessibilityRole).toBe('adjustable');
|
||||
});
|
||||
|
||||
it('sliderLabel without max', () => {
|
||||
const props = sliderLabel('Score', 42);
|
||||
expect(props.accessibilityLabel).toBe('Score: 42');
|
||||
});
|
||||
|
||||
it('alertLabel', () => {
|
||||
const props = alertLabel('Warning', 'High heart rate detected');
|
||||
expect(props.accessibilityLabel).toBe('Warning alert: High heart rate detected');
|
||||
expect(props.accessibilityRole).toBe('alert');
|
||||
});
|
||||
|
||||
it('achievementLabel earned', () => {
|
||||
const props = achievementLabel('Early Bird', 'Complete 5 morning sessions', true);
|
||||
expect(props.accessibilityLabel).toContain('Earned');
|
||||
expect(props.accessibilityLabel).toContain('Early Bird');
|
||||
expect(props.accessibilityState?.selected).toBe(true);
|
||||
});
|
||||
|
||||
it('achievementLabel locked', () => {
|
||||
const props = achievementLabel('Night Owl', 'Complete 5 late sessions', false);
|
||||
expect(props.accessibilityLabel).toContain('Locked');
|
||||
expect(props.accessibilityState?.selected).toBe(false);
|
||||
});
|
||||
|
||||
it('streakLabel', () => {
|
||||
const props = streakLabel(7, 14);
|
||||
expect(props.accessibilityLabel).toContain('7 days');
|
||||
expect(props.accessibilityLabel).toContain('14 days');
|
||||
});
|
||||
|
||||
it('listItemLabel with badge', () => {
|
||||
const props = listItemLabel('16:8 Protocol', '16 hours fasting', 'Popular');
|
||||
expect(props.accessibilityLabel).toBe('16:8 Protocol, 16 hours fasting, Popular');
|
||||
});
|
||||
|
||||
it('listItemLabel minimal', () => {
|
||||
const props = listItemLabel('Simple Item');
|
||||
expect(props.accessibilityLabel).toBe('Simple Item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationForA11y', () => {
|
||||
it('formats hours and minutes', () => {
|
||||
const result = formatDurationForA11y(16.5 * 60 * 60 * 1000);
|
||||
expect(result).toBe('16 hours 30 minutes');
|
||||
});
|
||||
|
||||
it('formats zero', () => {
|
||||
expect(formatDurationForA11y(0)).toBe('0 seconds');
|
||||
});
|
||||
|
||||
it('formats singular units', () => {
|
||||
const result = formatDurationForA11y(3661000); // 1h 1m 1s
|
||||
expect(result).toBe('1 hour 1 minute 1 second');
|
||||
});
|
||||
|
||||
it('handles negative as zero', () => {
|
||||
expect(formatDurationForA11y(-1000)).toBe('0 seconds');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNumberForA11y', () => {
|
||||
it('formats small numbers', () => {
|
||||
expect(formatNumberForA11y(5)).toBe('five');
|
||||
expect(formatNumberForA11y(0)).toBe('zero');
|
||||
expect(formatNumberForA11y(13)).toBe('thirteen');
|
||||
});
|
||||
|
||||
it('formats larger numbers', () => {
|
||||
expect(formatNumberForA11y(42)).toBe('forty two');
|
||||
expect(formatNumberForA11y(100)).toBe('one hundred');
|
||||
expect(formatNumberForA11y(1234)).toBe('one thousand two hundred thirty four');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAnnouncement', () => {
|
||||
it('combines headline and detail', () => {
|
||||
const result = buildAnnouncement('Fast Complete', 'You fasted for 16 hours');
|
||||
expect(result).toBe('Fast Complete. You fasted for 16 hours');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPositiveBreakMessage', () => {
|
||||
it('returns a positive message', () => {
|
||||
const msg = getPositiveBreakMessage(50);
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clamps to valid range', () => {
|
||||
const msgNeg = getPositiveBreakMessage(-10);
|
||||
const msg0 = getPositiveBreakMessage(0);
|
||||
expect(msgNeg).toBe(msg0);
|
||||
});
|
||||
});
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* VoiceOver/TalkBack accessibility label generators for common UI patterns.
|
||||
*
|
||||
* Returns A11yProps objects compatible with React Native accessibilityLabel/Role.
|
||||
* Web apps can map to aria-label / role.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type { A11yProps } from './types.js';
|
||||
|
||||
export function buttonLabel(label: string, hint?: string): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityHint: hint,
|
||||
accessibilityRole: 'button',
|
||||
};
|
||||
}
|
||||
|
||||
export function timerLabel(status: string, elapsedText: string, context?: string): A11yProps {
|
||||
const parts = [`Timer ${status}`, elapsedText];
|
||||
if (context) parts.push(context);
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: parts.join(', '),
|
||||
accessibilityRole: 'timer',
|
||||
};
|
||||
}
|
||||
|
||||
export function progressLabel(name: string, percent: number, description?: string): A11yProps {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
const label = description
|
||||
? `${name}: ${clamped} percent complete. ${description}`
|
||||
: `${name}: ${clamped} percent complete`;
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: 'progressbar',
|
||||
accessibilityValue: { min: 0, max: 100, now: clamped, text: `${clamped}%` },
|
||||
};
|
||||
}
|
||||
|
||||
export function sliderLabel(metric: string, value: number, max?: number): A11yProps {
|
||||
const label = max !== undefined ? `${metric}: ${value} of ${max}` : `${metric}: ${value}`;
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: 'adjustable',
|
||||
accessibilityValue: { now: value, max, text: String(value) },
|
||||
};
|
||||
}
|
||||
|
||||
export function alertLabel(severity: string, message: string): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `${severity} alert: ${message}`,
|
||||
accessibilityRole: 'alert',
|
||||
};
|
||||
}
|
||||
|
||||
export function achievementLabel(name: string, description: string, earned: boolean): A11yProps {
|
||||
const status = earned ? 'Earned' : 'Locked';
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `${status} achievement: ${name}. ${description}`,
|
||||
accessibilityRole: 'image',
|
||||
accessibilityState: { selected: earned },
|
||||
};
|
||||
}
|
||||
|
||||
export function streakLabel(current: number, longest: number): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `Current streak: ${current} days. Longest streak: ${longest} days`,
|
||||
accessibilityRole: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
export function listItemLabel(title: string, subtitle?: string, badge?: string): A11yProps {
|
||||
const parts = [title];
|
||||
if (subtitle) parts.push(subtitle);
|
||||
if (badge) parts.push(badge);
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: parts.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDurationForA11y(ms: number): string {
|
||||
const safeMs = Math.max(0, ms);
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
|
||||
if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
|
||||
if (seconds > 0 || parts.length === 0)
|
||||
parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
const ONES = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
|
||||
const TEENS = [
|
||||
'ten',
|
||||
'eleven',
|
||||
'twelve',
|
||||
'thirteen',
|
||||
'fourteen',
|
||||
'fifteen',
|
||||
'sixteen',
|
||||
'seventeen',
|
||||
'eighteen',
|
||||
'nineteen',
|
||||
];
|
||||
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
||||
|
||||
function numberToWords(n: number): string {
|
||||
if (n < 0) return `negative ${numberToWords(-n)}`;
|
||||
if (n === 0) return 'zero';
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = n;
|
||||
|
||||
if (remaining >= 1_000_000) {
|
||||
parts.push(`${numberToWords(Math.floor(remaining / 1_000_000))} million`);
|
||||
remaining %= 1_000_000;
|
||||
}
|
||||
if (remaining >= 1000) {
|
||||
parts.push(`${numberToWords(Math.floor(remaining / 1000))} thousand`);
|
||||
remaining %= 1000;
|
||||
}
|
||||
if (remaining >= 100) {
|
||||
parts.push(`${ONES[Math.floor(remaining / 100)]} hundred`);
|
||||
remaining %= 100;
|
||||
}
|
||||
if (remaining >= 20) {
|
||||
const t = TENS[Math.floor(remaining / 10)];
|
||||
const o = ONES[remaining % 10];
|
||||
parts.push(o ? `${t} ${o}` : t);
|
||||
} else if (remaining >= 10) {
|
||||
parts.push(TEENS[remaining - 10]);
|
||||
} else if (remaining > 0) {
|
||||
parts.push(ONES[remaining]);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatNumberForA11y(n: number): string {
|
||||
return numberToWords(Math.round(n));
|
||||
}
|
||||
|
||||
export function buildAnnouncement(headline: string, detail: string): string {
|
||||
return `${headline}. ${detail}`;
|
||||
}
|
||||
|
||||
const POSITIVE_BREAK_MESSAGES = [
|
||||
'Every bit of progress counts!',
|
||||
'You showed up today — that matters!',
|
||||
'Rest is part of the journey. You are doing great!',
|
||||
'You can always come back — no pressure!',
|
||||
'Progress, not perfection. Well done!',
|
||||
];
|
||||
|
||||
export function getPositiveBreakMessage(progressPercent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, progressPercent));
|
||||
const index = Math.floor((clamped / 100) * (POSITIVE_BREAK_MESSAGES.length - 1));
|
||||
return POSITIVE_BREAK_MESSAGES[index];
|
||||
}
|
||||
@ -1,15 +1,124 @@
|
||||
export {
|
||||
buttonLabel,
|
||||
timerLabel,
|
||||
progressLabel,
|
||||
sliderLabel,
|
||||
alertLabel,
|
||||
achievementLabel,
|
||||
streakLabel,
|
||||
listItemLabel,
|
||||
formatDurationForA11y,
|
||||
formatNumberForA11y,
|
||||
buildAnnouncement,
|
||||
getPositiveBreakMessage,
|
||||
} from './client.js';
|
||||
export type { A11yProps } from './types.js';
|
||||
export type AlertA11yProps = {
|
||||
role: 'alert';
|
||||
'aria-live': 'assertive' | 'polite';
|
||||
'aria-label': string;
|
||||
};
|
||||
|
||||
export function alertLabel(level: string, description: string): AlertA11yProps {
|
||||
return {
|
||||
role: 'alert',
|
||||
'aria-live': level === 'danger' ? 'assertive' : 'polite',
|
||||
'aria-label': description,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProgressbarA11yProps = {
|
||||
role: 'progressbar';
|
||||
'aria-label': string;
|
||||
'aria-valuenow': number;
|
||||
'aria-valuemin': number;
|
||||
'aria-valuemax': number;
|
||||
'aria-valuetext': string;
|
||||
};
|
||||
|
||||
export function progressLabel(
|
||||
label: string,
|
||||
valuePct: number,
|
||||
description: string,
|
||||
): ProgressbarA11yProps {
|
||||
return {
|
||||
role: 'progressbar',
|
||||
'aria-label': label,
|
||||
'aria-valuenow': valuePct,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
'aria-valuetext': description,
|
||||
};
|
||||
}
|
||||
|
||||
export type AriaLabelOnly = { 'aria-label': string };
|
||||
|
||||
export function streakLabel(days: number): AriaLabelOnly {
|
||||
return { 'aria-label': `${days} day streak` };
|
||||
}
|
||||
|
||||
export type ButtonA11yProps = {
|
||||
'aria-label': string;
|
||||
'aria-roledescription'?: string;
|
||||
};
|
||||
|
||||
export function buttonLabel(text: string, hint?: string): ButtonA11yProps {
|
||||
return {
|
||||
'aria-label': text,
|
||||
...(hint ? { 'aria-roledescription': hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function achievementLabel(name: string, description: string): AriaLabelOnly {
|
||||
return { 'aria-label': `Achievement: ${name} — ${description}` };
|
||||
}
|
||||
|
||||
export type TimerA11yProps = {
|
||||
'aria-label': string;
|
||||
'aria-live': 'polite';
|
||||
};
|
||||
|
||||
export function timerLabel(
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
status: string,
|
||||
): TimerA11yProps {
|
||||
return {
|
||||
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
||||
'aria-live': 'polite',
|
||||
};
|
||||
}
|
||||
|
||||
export type SliderA11yProps = {
|
||||
role: 'slider';
|
||||
'aria-label': string;
|
||||
'aria-valuenow': number;
|
||||
'aria-valuemin': number;
|
||||
'aria-valuemax': number;
|
||||
};
|
||||
|
||||
export function sliderLabel(
|
||||
label: string,
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): SliderA11yProps {
|
||||
return {
|
||||
role: 'slider',
|
||||
'aria-label': label,
|
||||
'aria-valuenow': value,
|
||||
'aria-valuemin': min,
|
||||
'aria-valuemax': max,
|
||||
};
|
||||
}
|
||||
|
||||
function plural(n: number, singular: string, pluralForm: string): string {
|
||||
const word = n === 1 ? singular : pluralForm;
|
||||
return `${n} ${word}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes".
|
||||
*/
|
||||
export function formatDurationForA11y(hours: number): string {
|
||||
const totalMinutes = Math.round(hours * 60);
|
||||
const h = Math.floor(totalMinutes / 60);
|
||||
const m = totalMinutes % 60;
|
||||
|
||||
if (h === 0 && m === 0) {
|
||||
return '0 minutes';
|
||||
}
|
||||
if (m === 0) {
|
||||
return plural(h, 'hour', 'hours');
|
||||
}
|
||||
if (h === 0) {
|
||||
return plural(m, 'minute', 'minutes');
|
||||
}
|
||||
return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`;
|
||||
}
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Types for @bytelyst/accessibility.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export interface A11yProps {
|
||||
accessible: boolean;
|
||||
accessibilityLabel: string;
|
||||
accessibilityHint?: string;
|
||||
accessibilityRole?:
|
||||
| 'button'
|
||||
| 'header'
|
||||
| 'text'
|
||||
| 'timer'
|
||||
| 'progressbar'
|
||||
| 'image'
|
||||
| 'alert'
|
||||
| 'summary'
|
||||
| 'adjustable';
|
||||
accessibilityState?: {
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
checked?: boolean;
|
||||
busy?: boolean;
|
||||
expanded?: boolean;
|
||||
};
|
||||
accessibilityValue?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
now?: number;
|
||||
text?: string;
|
||||
};
|
||||
}
|
||||
@ -1,10 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,18 @@
|
||||
"name": "@bytelyst/celebrations",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Product-agnostic celebration engine — milestones, haptics, confetti, positive messages",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
"build": "tsc"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createCelebrationEngine } from './client.js';
|
||||
import type { Celebration } from './types.js';
|
||||
|
||||
describe('createCelebrationEngine', () => {
|
||||
it('should return default celebration for known triggers', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const c = engine.getCelebration('level_up');
|
||||
expect(c.id).toBe('level_up');
|
||||
expect(c.title).toContain('Level Up');
|
||||
expect(c.confetti).toBe(true);
|
||||
expect(c.sound).toBe('level_up');
|
||||
});
|
||||
|
||||
it('should return fallback for unknown triggers', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const c = engine.getCelebration('custom_unknown');
|
||||
expect(c.id).toBe('custom_unknown');
|
||||
expect(c.title).toBe('Nice!');
|
||||
});
|
||||
|
||||
it('should use custom triggers when provided', () => {
|
||||
const custom: Celebration = {
|
||||
id: 'fasting_complete',
|
||||
title: 'Fast Complete!',
|
||||
body: 'You did it!',
|
||||
emoji: '🍃',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
};
|
||||
const engine = createCelebrationEngine({ customTriggers: { fasting_complete: custom } });
|
||||
const c = engine.getCelebration('fasting_complete');
|
||||
expect(c).toEqual(custom);
|
||||
});
|
||||
|
||||
it('should prefer custom triggers over defaults', () => {
|
||||
const custom: Celebration = {
|
||||
id: 'level_up',
|
||||
title: 'Custom Level Up!',
|
||||
body: 'Custom body',
|
||||
emoji: '🎮',
|
||||
hapticType: 'light',
|
||||
confetti: false,
|
||||
sound: 'none',
|
||||
};
|
||||
const engine = createCelebrationEngine({ customTriggers: { level_up: custom } });
|
||||
const c = engine.getCelebration('level_up');
|
||||
expect(c.title).toBe('Custom Level Up!');
|
||||
});
|
||||
|
||||
it('should return timed celebrations based on progress', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const shown = new Set<string>();
|
||||
|
||||
const at25 = engine.getTimedCelebrations(250, 1000, shown);
|
||||
expect(at25).toHaveLength(1);
|
||||
expect(at25[0].id).toBe('timed_25');
|
||||
shown.add('timed_25');
|
||||
|
||||
const at50 = engine.getTimedCelebrations(500, 1000, shown);
|
||||
expect(at50).toHaveLength(1);
|
||||
expect(at50[0].id).toBe('timed_50');
|
||||
});
|
||||
|
||||
it('should not repeat shown timed celebrations', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const shown = new Set(['timed_25', 'timed_50']);
|
||||
|
||||
const results = engine.getTimedCelebrations(500, 1000, shown);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty for zero target', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
expect(engine.getTimedCelebrations(100, 0, new Set())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect personal best', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
expect(engine.isPersonalBest(10, 5)).toBe(true);
|
||||
expect(engine.isPersonalBest(5, 10)).toBe(false);
|
||||
expect(engine.isPersonalBest(5, 5)).toBe(false);
|
||||
expect(engine.isPersonalBest(1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return positive messages', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msg0 = engine.getPositiveMessage(0);
|
||||
const msg100 = engine.getPositiveMessage(100);
|
||||
expect(msg0.length).toBeGreaterThan(0);
|
||||
expect(msg100.length).toBeGreaterThan(0);
|
||||
expect(msg0).not.toBe(msg100);
|
||||
});
|
||||
|
||||
it('should return positive incomplete messages', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msg = engine.getPositiveIncompleteMessage(30);
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should clamp progress percent', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msgNeg = engine.getPositiveMessage(-10);
|
||||
const msg0 = engine.getPositiveMessage(0);
|
||||
expect(msgNeg).toBe(msg0);
|
||||
|
||||
const msgOver = engine.getPositiveMessage(200);
|
||||
const msg100 = engine.getPositiveMessage(100);
|
||||
expect(msgOver).toBe(msg100);
|
||||
});
|
||||
});
|
||||
@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,25 @@
|
||||
export { createCelebrationEngine } from './client.js';
|
||||
export type {
|
||||
Celebration,
|
||||
CelebrationConfig,
|
||||
CelebrationEngine,
|
||||
CelebrationTrigger,
|
||||
} from './types.js';
|
||||
export interface Celebration {
|
||||
emoji: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CELEBRATION: Celebration = {
|
||||
emoji: '👏',
|
||||
title: 'Great Job!',
|
||||
};
|
||||
|
||||
const BY_TYPE: Record<string, Celebration> = {
|
||||
session_completed: { emoji: '🎉', title: 'Fast Complete!' },
|
||||
task_completed: { emoji: '✅', title: 'Well Done!' },
|
||||
streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' },
|
||||
achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' },
|
||||
level_up: { emoji: '⬆️', title: 'Level Up!' },
|
||||
};
|
||||
|
||||
export function createCelebrationEngine() {
|
||||
return {
|
||||
getCelebration(type: string): Celebration {
|
||||
return BY_TYPE[type] ?? DEFAULT_CELEBRATION;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Types for @bytelyst/celebrations.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export type CelebrationTrigger =
|
||||
| 'task_completed'
|
||||
| 'streak_continued'
|
||||
| 'streak_milestone'
|
||||
| 'achievement_unlocked'
|
||||
| 'level_up'
|
||||
| 'personal_best'
|
||||
| 'milestone_reached'
|
||||
| 'goal_completed'
|
||||
| 'first_action'
|
||||
| 'halfway'
|
||||
| 'session_completed'
|
||||
| 'session_started';
|
||||
|
||||
export interface Celebration {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
emoji: string;
|
||||
hapticType: 'light' | 'medium' | 'heavy' | 'success' | 'warning';
|
||||
confetti: boolean;
|
||||
sound: 'chime' | 'success' | 'level_up' | 'none';
|
||||
}
|
||||
|
||||
export interface CelebrationConfig {
|
||||
/** Products register their own trigger→message mappings. */
|
||||
customTriggers?: Record<string, Celebration>;
|
||||
}
|
||||
|
||||
export interface CelebrationEngine {
|
||||
getCelebration(trigger: CelebrationTrigger | string): Celebration;
|
||||
getTimedCelebrations(elapsedMs: number, targetMs: number, shownIds: Set<string>): Celebration[];
|
||||
isPersonalBest(current: number, previous: number): boolean;
|
||||
getPositiveMessage(progressPercent: number): string;
|
||||
getPositiveIncompleteMessage(progressPercent: number): string;
|
||||
}
|
||||
@ -3,8 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/gentle-notifications",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Neurodivergent-friendly notification messaging — encouraging tone, adaptive frequency, forbidden phrases",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,2 +1,44 @@
|
||||
export { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js';
|
||||
export type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js';
|
||||
export interface GentleConfig {
|
||||
maxPerDay: number;
|
||||
quietHoursStart: number;
|
||||
quietHoursEnd: number;
|
||||
minIntervalMinutes: number;
|
||||
dismissCount?: number;
|
||||
}
|
||||
|
||||
const FORBIDDEN_PHRASES = [
|
||||
"you failed",
|
||||
"you broke",
|
||||
"you gave up",
|
||||
"disappointed",
|
||||
"shame",
|
||||
"guilt",
|
||||
"lazy",
|
||||
"weak",
|
||||
"cheat",
|
||||
] as const;
|
||||
|
||||
export function createGentleNotificationEngine() {
|
||||
return {
|
||||
containsForbiddenPhrase(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return FORBIDDEN_PHRASES.some((phrase) => lower.includes(phrase));
|
||||
},
|
||||
|
||||
getDefaultConfig(): GentleConfig {
|
||||
return {
|
||||
maxPerDay: 8,
|
||||
quietHoursStart: 22,
|
||||
quietHoursEnd: 7,
|
||||
minIntervalMinutes: 30,
|
||||
};
|
||||
},
|
||||
|
||||
recordDismissal(config: GentleConfig): GentleConfig {
|
||||
return {
|
||||
...config,
|
||||
dismissCount: (config.dismissCount ?? 0) + 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/marketplace-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe marketplace client for platform-service listings, reviews, installs",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,9 +1,75 @@
|
||||
export { createMarketplaceClient } from './client.js';
|
||||
export type {
|
||||
MarketplaceClient,
|
||||
MarketplaceClientConfig,
|
||||
MarketplaceListingDoc,
|
||||
MarketplaceReviewDoc,
|
||||
MarketplaceInstallDoc,
|
||||
CreateListingInput,
|
||||
} from './types.js';
|
||||
export interface MarketplaceClientOptions {
|
||||
baseUrl: string;
|
||||
productId: string;
|
||||
getAccessToken: () => string;
|
||||
}
|
||||
|
||||
export interface MarketplaceListing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
author: string;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
const b = base.replace(/\/$/, "");
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${b}${p}`;
|
||||
}
|
||||
|
||||
function headers(opts: MarketplaceClientOptions): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${opts.getAccessToken()}`,
|
||||
"X-Product-Id": opts.productId,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJson<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function createMarketplaceClient(opts: MarketplaceClientOptions) {
|
||||
const { baseUrl } = opts;
|
||||
|
||||
return {
|
||||
async listListings(listOpts?: {
|
||||
category?: string;
|
||||
}): Promise<MarketplaceListing[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (listOpts?.category !== undefined && listOpts.category !== "") {
|
||||
q.set("category", listOpts.category);
|
||||
}
|
||||
const query = q.toString();
|
||||
const path =
|
||||
query.length > 0
|
||||
? `/marketplace/listings?${query}`
|
||||
: "/marketplace/listings";
|
||||
const res = await fetch(joinUrl(baseUrl, path), {
|
||||
method: "GET",
|
||||
headers: headers(opts),
|
||||
});
|
||||
return parseJson<MarketplaceListing[]>(res);
|
||||
},
|
||||
|
||||
async installListing(
|
||||
listingId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const res = await fetch(
|
||||
joinUrl(
|
||||
baseUrl,
|
||||
`/marketplace/listings/${encodeURIComponent(listingId)}/install`
|
||||
),
|
||||
{ method: "POST", headers: headers(opts), body: "{}" }
|
||||
);
|
||||
return parseJson<{ success: boolean }>(res);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/org-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe org, workspace, membership, and license client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,9 +1,58 @@
|
||||
export { createOrgClient } from './client.js';
|
||||
export type {
|
||||
OrgClient,
|
||||
OrgClientConfig,
|
||||
OrganizationDoc,
|
||||
WorkspaceDoc,
|
||||
MembershipDoc,
|
||||
LicenseDoc,
|
||||
} from './types.js';
|
||||
export interface OrgClientOptions {
|
||||
baseUrl: string;
|
||||
productId: string;
|
||||
getAccessToken: () => string;
|
||||
}
|
||||
|
||||
export interface OrgDoc {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
memberCount: number;
|
||||
plan: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
const b = base.replace(/\/$/, "");
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${b}${p}`;
|
||||
}
|
||||
|
||||
function headers(opts: OrgClientOptions): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${opts.getAccessToken()}`,
|
||||
"X-Product-Id": opts.productId,
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJson<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function createOrgClient(opts: OrgClientOptions) {
|
||||
const { baseUrl } = opts;
|
||||
|
||||
return {
|
||||
async listOrgs(): Promise<OrgDoc[]> {
|
||||
const res = await fetch(joinUrl(baseUrl, "/organizations"), {
|
||||
method: "GET",
|
||||
headers: headers(opts),
|
||||
});
|
||||
return parseJson<OrgDoc[]>(res);
|
||||
},
|
||||
|
||||
async getOrg(orgId: string): Promise<OrgDoc> {
|
||||
const res = await fetch(
|
||||
joinUrl(baseUrl, `/organizations/${encodeURIComponent(orgId)}`),
|
||||
{ method: "GET", headers: headers(opts) }
|
||||
);
|
||||
return parseJson<OrgDoc>(res);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/quick-actions",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Progressive disclosure system, smart defaults, quick action definitions",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,8 +1,27 @@
|
||||
export {
|
||||
getVisibleSections,
|
||||
getAvailableActions,
|
||||
pickSmartDefault,
|
||||
MAX_VISIBLE_ITEMS,
|
||||
MAX_VISIBLE_LIST,
|
||||
} from './client.js';
|
||||
export type { QuickAction, ProgressiveSection, SmartDefault } from './types.js';
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
requiresAuth?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export const MAX_VISIBLE_ITEMS = 4;
|
||||
export const MAX_VISIBLE_LIST = 8;
|
||||
|
||||
export function getAvailableActions(
|
||||
actions: QuickAction[],
|
||||
opts?: { isAuthenticated?: boolean },
|
||||
): QuickAction[] {
|
||||
const isAuthenticated = opts?.isAuthenticated ?? false;
|
||||
return actions.filter(
|
||||
(a) => !a.requiresAuth || isAuthenticated,
|
||||
);
|
||||
}
|
||||
|
||||
export function pickSmartDefault(defaults: QuickAction[]): QuickAction | null {
|
||||
if (defaults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return defaults[0] ?? null;
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/referral-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe referral client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,2 +1,72 @@
|
||||
export { createReferralClient } from './client.js';
|
||||
export type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js';
|
||||
export interface ReferralClientOptions {
|
||||
baseUrl: string;
|
||||
productId: string;
|
||||
getAccessToken: () => string;
|
||||
}
|
||||
|
||||
export interface ReferralStats {
|
||||
totalReferrals: number;
|
||||
successfulReferrals: number;
|
||||
pendingReferrals: number;
|
||||
rewardsEarned: number;
|
||||
referralCode: string;
|
||||
}
|
||||
|
||||
export interface ReferralInfo {
|
||||
code: string;
|
||||
referrerEmail: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
const b = base.replace(/\/$/, "");
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${b}${p}`;
|
||||
}
|
||||
|
||||
function headers(opts: ReferralClientOptions): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${opts.getAccessToken()}`,
|
||||
"X-Product-Id": opts.productId,
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJson<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function createReferralClient(opts: ReferralClientOptions) {
|
||||
const { baseUrl } = opts;
|
||||
|
||||
return {
|
||||
async getReferralStats(): Promise<ReferralStats> {
|
||||
const res = await fetch(joinUrl(baseUrl, "/referrals/stats"), {
|
||||
method: "GET",
|
||||
headers: headers(opts),
|
||||
});
|
||||
return parseJson<ReferralStats>(res);
|
||||
},
|
||||
|
||||
buildShareLink(code: string): string {
|
||||
const b = baseUrl.replace(/\/$/, "");
|
||||
return `${b}/r/${code}`;
|
||||
},
|
||||
|
||||
buildShareMessage(code: string, productName: string): string {
|
||||
return `Try ${productName}! Use my referral code: ${code}`;
|
||||
},
|
||||
|
||||
async getByEmail(code: string): Promise<ReferralInfo> {
|
||||
const res = await fetch(
|
||||
joinUrl(baseUrl, `/referrals/${encodeURIComponent(code)}`),
|
||||
{ method: "GET", headers: headers(opts) }
|
||||
);
|
||||
return parseJson<ReferralInfo>(res);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/subscription-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe subscription and plan client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,7 +1,157 @@
|
||||
export { createSubscriptionClient } from './client.js';
|
||||
export type {
|
||||
SubscriptionClient,
|
||||
SubscriptionClientConfig,
|
||||
SubscriptionDoc,
|
||||
PlanConfig,
|
||||
} from './types.js';
|
||||
export interface SubscriptionDoc {
|
||||
id: string;
|
||||
userId: string;
|
||||
plan: string;
|
||||
status: "active" | "trialing" | "past_due" | "cancelled" | "none";
|
||||
currentPeriodEnd: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export interface PlanConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
price: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface SubscriptionClientOptions {
|
||||
baseUrl: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
getAccessToken: () => string;
|
||||
}
|
||||
|
||||
export interface SubscriptionClient {
|
||||
getMySubscription(): Promise<SubscriptionDoc | null>;
|
||||
getPlans(): Promise<PlanConfig[]>;
|
||||
cancelSubscription(): Promise<SubscriptionDoc>;
|
||||
isPro(): boolean;
|
||||
isTrialing(): boolean;
|
||||
hasFeature(feature: string): boolean;
|
||||
daysRemaining(): number | null;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function createSubscriptionClient(
|
||||
opts: SubscriptionClientOptions,
|
||||
): SubscriptionClient {
|
||||
const base = trimTrailingSlash(opts.baseUrl);
|
||||
let cached: SubscriptionDoc | null | undefined;
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const token = opts.getAccessToken();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (!headers.has("Content-Type") && init?.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetch(`${base}${path}`, { ...init, headers });
|
||||
if (res.status === 404) {
|
||||
return null as T;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`Subscription API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (res.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return null as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function subscriptionFromCache(): SubscriptionDoc | null {
|
||||
if (cached === undefined || cached === null) {
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
return {
|
||||
async getMySubscription(): Promise<SubscriptionDoc | null> {
|
||||
const data = await request<SubscriptionDoc | null>(
|
||||
"/billing/subscriptions/me",
|
||||
);
|
||||
cached = data;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPlans(): Promise<PlanConfig[]> {
|
||||
const data = await request<
|
||||
PlanConfig[] | { plans?: PlanConfig[] } | null
|
||||
>("/billing/plans");
|
||||
if (data == null) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data && typeof data === "object" && "plans" in data && Array.isArray(data.plans)) {
|
||||
return data.plans;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
async cancelSubscription(): Promise<SubscriptionDoc> {
|
||||
const data = await request<SubscriptionDoc>(
|
||||
"/billing/subscriptions/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (data === null) {
|
||||
throw new Error("Cancel subscription returned no body");
|
||||
}
|
||||
cached = data;
|
||||
return data;
|
||||
},
|
||||
|
||||
isPro(): boolean {
|
||||
const sub = subscriptionFromCache();
|
||||
if (!sub) {
|
||||
return false;
|
||||
}
|
||||
const paid =
|
||||
sub.status === "active" || sub.status === "trialing";
|
||||
const planLower = sub.plan.toLowerCase();
|
||||
const notFree = planLower !== "free" && planLower !== "none";
|
||||
return paid && notFree;
|
||||
},
|
||||
|
||||
isTrialing(): boolean {
|
||||
return subscriptionFromCache()?.status === "trialing";
|
||||
},
|
||||
|
||||
hasFeature(feature: string): boolean {
|
||||
const sub = subscriptionFromCache();
|
||||
if (!sub?.features?.length) {
|
||||
return false;
|
||||
}
|
||||
return sub.features.includes(feature);
|
||||
},
|
||||
|
||||
daysRemaining(): number | null {
|
||||
const sub = subscriptionFromCache();
|
||||
if (!sub?.currentPeriodEnd) {
|
||||
return null;
|
||||
}
|
||||
const end = new Date(sub.currentPeriodEnd).getTime();
|
||||
if (Number.isNaN(end)) {
|
||||
return null;
|
||||
}
|
||||
const ms = end - Date.now();
|
||||
if (ms <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -2,23 +2,9 @@
|
||||
"name": "@bytelyst/time-references",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Familiar duration references for time-blindness aids",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
||||
"scripts": { "build": "tsc" },
|
||||
"devDependencies": { "typescript": "^5.7.3" }
|
||||
}
|
||||
|
||||
@ -1,8 +1,54 @@
|
||||
export {
|
||||
getTimeReference,
|
||||
getEpisodeComparison,
|
||||
getEncouragingMessage,
|
||||
registerReferences,
|
||||
clearCustomReferences,
|
||||
} from './client.js';
|
||||
export type { TimeReference, TimeRangeEntry } from './types.js';
|
||||
export interface TimeReference {
|
||||
emoji: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function getTimeReference(hours: number): TimeReference {
|
||||
if (hours < 1) {
|
||||
return { emoji: "⏱️", text: "A quick meditation" };
|
||||
}
|
||||
if (hours < 4) {
|
||||
return { emoji: "🎬", text: "A movie marathon" };
|
||||
}
|
||||
if (hours < 8) {
|
||||
return { emoji: "✈️", text: "A cross-country flight" };
|
||||
}
|
||||
if (hours < 12) {
|
||||
return { emoji: "🌙", text: "A full night's sleep" };
|
||||
}
|
||||
if (hours < 16) {
|
||||
return { emoji: "🏔️", text: "A day hike" };
|
||||
}
|
||||
if (hours < 24) {
|
||||
return { emoji: "🌍", text: "A day trip abroad" };
|
||||
}
|
||||
if (hours < 36) {
|
||||
return { emoji: "🚂", text: "A train across the country" };
|
||||
}
|
||||
if (hours < 48) {
|
||||
return { emoji: "⛵", text: "A weekend sailing trip" };
|
||||
}
|
||||
return { emoji: "🏕️", text: "A multi-day adventure" };
|
||||
}
|
||||
|
||||
export function getEncouragingMessage(hours: number): string {
|
||||
if (hours < 4) {
|
||||
return "Great start! Your body is beginning to adjust.";
|
||||
}
|
||||
if (hours < 8) {
|
||||
return "You're doing well. Insulin is dropping.";
|
||||
}
|
||||
if (hours < 12) {
|
||||
return "Halfway through a standard fast. Fat burning is ramping up!";
|
||||
}
|
||||
if (hours < 16) {
|
||||
return "You've passed 12 hours. Autophagy is beginning.";
|
||||
}
|
||||
if (hours < 24) {
|
||||
return "Deep into your fast. Your body is thriving.";
|
||||
}
|
||||
if (hours < 36) {
|
||||
return "Incredible discipline! Growth hormone is surging.";
|
||||
}
|
||||
return "Extraordinary commitment. Your body is deeply healing.";
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user