16
packages/shared/package.json
Executable file
16
packages/shared/package.json
Executable file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@adventureos/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
33
packages/shared/src/achievements.ts
Executable file
33
packages/shared/src/achievements.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
export interface AchievementDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const ACHIEVEMENTS: AchievementDefinition[] = [
|
||||
{ key: "first_visit", name: "First Steps", description: "Opened AdventureOS for the first time", category: "journey" },
|
||||
{ key: "first_reflection", name: "Quiet Moment", description: "Completed your first daily reflection", category: "journey" },
|
||||
{ key: "first_book", name: "Apprentice Reader", description: "Finished your first book", category: "reading" },
|
||||
{ key: "books_5", name: "Shelf Filler", description: "Completed 5 books", category: "reading" },
|
||||
{ key: "books_10", name: "Library Keeper", description: "Completed 10 books", category: "reading" },
|
||||
{ key: "pages_1000", name: "Thousand Pages", description: "Read 1,000 pages total", category: "reading" },
|
||||
{ key: "reading_streak_7", name: "Weekly Reader", description: "7-day reading streak", category: "reading" },
|
||||
{ key: "reading_streak_30", name: "Monthly Reader", description: "30-day reading streak", category: "reading" },
|
||||
{ key: "level_10", name: "Foundations", description: "Reached level 10", category: "progression" },
|
||||
{ key: "level_25", name: "Consistent Builder", description: "Reached level 25", category: "progression" },
|
||||
{ key: "level_50", name: "Reliable Craftsman", description: "Reached level 50", category: "progression" },
|
||||
{ key: "level_100", name: "Master of Discipline", description: "Reached level 100", category: "progression" },
|
||||
{ key: "exploration_1", name: "Curious Mind", description: "Completed first exploration", category: "learning" },
|
||||
{ key: "exploration_10", name: "Curious Wanderer", description: "Completed 10 explorations", category: "learning" },
|
||||
{ key: "exercise_10", name: "Moving Forward", description: "10 exercise sessions", category: "health" },
|
||||
{ key: "exercise_50", name: "Steady Stride", description: "50 exercise sessions", category: "health" },
|
||||
{ key: "spiritual_30", name: "Steady Pilgrim", description: "30 days of spiritual presence", category: "spiritual" },
|
||||
{ key: "weekly_review_4", name: "Page Turner", description: "Completed 4 weekly reviews", category: "journey" },
|
||||
{ key: "consistency_60", name: "Rhythm Found", description: "30-day consistency at 60+", category: "progression" },
|
||||
{ key: "year_one", name: "One Year Adventurer", description: "One year on your journey", category: "journey" },
|
||||
];
|
||||
|
||||
export function getAchievement(key: string): AchievementDefinition | undefined {
|
||||
return ACHIEVEMENTS.find((a) => a.key === key);
|
||||
}
|
||||
37
packages/shared/src/chapters.test.ts
Executable file
37
packages/shared/src/chapters.test.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chapterForLevel, journeyDay, JOURNEY_CHAPTERS } from "./chapters";
|
||||
|
||||
describe("chapterForLevel", () => {
|
||||
it("returns prologue for level 1", () => {
|
||||
expect(chapterForLevel(1).key).toBe("prologue");
|
||||
});
|
||||
|
||||
it("returns chapter II for level 30", () => {
|
||||
expect(chapterForLevel(30).key).toBe("chapter_2");
|
||||
});
|
||||
|
||||
it("returns epilogue for level 100+", () => {
|
||||
expect(chapterForLevel(100).key).toBe("epilogue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("journeyDay", () => {
|
||||
it("returns 1 on creation day", () => {
|
||||
const created = new Date("2026-06-01T12:00:00");
|
||||
const now = new Date("2026-06-01T18:00:00");
|
||||
expect(journeyDay(created, now)).toBe(1);
|
||||
});
|
||||
|
||||
it("counts calendar days", () => {
|
||||
const created = new Date("2026-06-01");
|
||||
const now = new Date("2026-06-03");
|
||||
expect(journeyDay(created, now)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JOURNEY_CHAPTERS", () => {
|
||||
it("covers all levels without gaps", () => {
|
||||
expect(JOURNEY_CHAPTERS[0].minLevel).toBe(1);
|
||||
expect(JOURNEY_CHAPTERS[JOURNEY_CHAPTERS.length - 1].maxLevel).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
68
packages/shared/src/chapters.ts
Executable file
68
packages/shared/src/chapters.ts
Executable file
@@ -0,0 +1,68 @@
|
||||
export interface JourneyChapter {
|
||||
key: string;
|
||||
name: string;
|
||||
minLevel: number;
|
||||
maxLevel: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export const JOURNEY_CHAPTERS: JourneyChapter[] = [
|
||||
{
|
||||
key: "prologue",
|
||||
name: "Prologue: Awakening",
|
||||
minLevel: 1,
|
||||
maxLevel: 9,
|
||||
theme: "Learning the system",
|
||||
},
|
||||
{
|
||||
key: "chapter_1",
|
||||
name: "Chapter I: Foundations",
|
||||
minLevel: 10,
|
||||
maxLevel: 24,
|
||||
theme: "Building rhythms",
|
||||
},
|
||||
{
|
||||
key: "chapter_2",
|
||||
name: "Chapter II: The Long Road",
|
||||
minLevel: 25,
|
||||
maxLevel: 49,
|
||||
theme: "Consistency through difficulty",
|
||||
},
|
||||
{
|
||||
key: "chapter_3",
|
||||
name: "Chapter III: Deep Craft",
|
||||
minLevel: 50,
|
||||
maxLevel: 74,
|
||||
theme: "Mastery of habits",
|
||||
},
|
||||
{
|
||||
key: "chapter_4",
|
||||
name: "Chapter IV: Stewardship",
|
||||
minLevel: 75,
|
||||
maxLevel: 99,
|
||||
theme: "Teaching others, giving back",
|
||||
},
|
||||
{
|
||||
key: "epilogue",
|
||||
name: "Epilogue: Legacy",
|
||||
minLevel: 100,
|
||||
maxLevel: Infinity,
|
||||
theme: "Lifelong maintainer",
|
||||
},
|
||||
];
|
||||
|
||||
export function chapterForLevel(level: number): JourneyChapter {
|
||||
return (
|
||||
JOURNEY_CHAPTERS.find(
|
||||
(c) => level >= c.minLevel && level <= c.maxLevel
|
||||
) ?? JOURNEY_CHAPTERS[JOURNEY_CHAPTERS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function journeyDay(createdAt: Date, now = new Date()): number {
|
||||
const start = new Date(createdAt);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((today.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
38
packages/shared/src/day-boundary.test.ts
Normal file
38
packages/shared/src/day-boundary.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getLogicalToday,
|
||||
getLogicalYesterday,
|
||||
isWithinGraceWindow,
|
||||
} from "./day-boundary";
|
||||
|
||||
describe("day-boundary", () => {
|
||||
it("returns calendar date when boundary is midnight and after noon", () => {
|
||||
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||
expect(getLogicalToday(0, now)).toBe("2026-06-26");
|
||||
});
|
||||
|
||||
it("returns previous calendar date when before boundary hour", () => {
|
||||
const now = new Date(2026, 5, 26, 1, 30, 0);
|
||||
expect(getLogicalToday(2, now)).toBe("2026-06-25");
|
||||
});
|
||||
|
||||
it("returns current date when at or after boundary hour", () => {
|
||||
const now = new Date(2026, 5, 26, 2, 30, 0);
|
||||
expect(getLogicalToday(2, now)).toBe("2026-06-26");
|
||||
});
|
||||
|
||||
it("computes logical yesterday", () => {
|
||||
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||
expect(getLogicalYesterday(0, now)).toBe("2026-06-25");
|
||||
});
|
||||
|
||||
it("detects grace window after midnight", () => {
|
||||
const now = new Date(2026, 5, 26, 1, 0, 0);
|
||||
expect(isWithinGraceWindow(0, 4, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("is outside grace window mid-day", () => {
|
||||
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||
expect(isWithinGraceWindow(0, 4, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
37
packages/shared/src/day-boundary.ts
Normal file
37
packages/shared/src/day-boundary.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { format, subDays, parseISO } from "date-fns";
|
||||
|
||||
/** Calendar date string for the current moment given a day-boundary hour (0–5). */
|
||||
export function getLogicalDate(now: Date, boundaryHour: number): string {
|
||||
const adjusted = new Date(now);
|
||||
if (adjusted.getHours() < boundaryHour) {
|
||||
adjusted.setDate(adjusted.getDate() - 1);
|
||||
}
|
||||
return format(adjusted, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export function getLogicalToday(boundaryHour: number, now = new Date()): string {
|
||||
return getLogicalDate(now, boundaryHour);
|
||||
}
|
||||
|
||||
export function getLogicalYesterday(boundaryHour: number, now = new Date()): string {
|
||||
const logical = parseISO(getLogicalToday(boundaryHour, now));
|
||||
return format(subDays(logical, 1), "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
/** True during the first few hours after the day boundary (e.g. midnight–4am). */
|
||||
export function isWithinGraceWindow(
|
||||
boundaryHour: number,
|
||||
graceHours = 4,
|
||||
now = new Date()
|
||||
): boolean {
|
||||
const hour = now.getHours();
|
||||
if (hour < boundaryHour) return true;
|
||||
if (hour >= boundaryHour && hour < boundaryHour + graceHours) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isCalendarYesterday(logicalDate: string, boundaryHour: number, now = new Date()): boolean {
|
||||
const calendarToday = format(now, "yyyy-MM-dd");
|
||||
const logicalToday = getLogicalToday(boundaryHour, now);
|
||||
return logicalDate !== calendarToday && logicalDate === logicalToday;
|
||||
}
|
||||
10
packages/shared/src/index.ts
Executable file
10
packages/shared/src/index.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export * from "./types";
|
||||
export * from "./memory";
|
||||
export * from "./day-boundary";
|
||||
export * from "./xp";
|
||||
export * from "./levels";
|
||||
export * from "./scores";
|
||||
export * from "./chapters";
|
||||
export * from "./titles";
|
||||
export * from "./achievements";
|
||||
export * from "./quest-pool";
|
||||
36
packages/shared/src/levels.test.ts
Executable file
36
packages/shared/src/levels.test.ts
Executable file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { xpForLevel, levelFromXp, xpProgressInLevel } from "./levels";
|
||||
|
||||
describe("xpForLevel", () => {
|
||||
it("returns 0 for level 1", () => {
|
||||
expect(xpForLevel(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("increases with level", () => {
|
||||
expect(xpForLevel(2)).toBeGreaterThan(0);
|
||||
expect(xpForLevel(10)).toBeGreaterThan(xpForLevel(5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("levelFromXp", () => {
|
||||
it("returns level 1 at zero XP", () => {
|
||||
expect(levelFromXp(0)).toBe(1);
|
||||
});
|
||||
|
||||
it("levels up at threshold", () => {
|
||||
const level2Xp = xpForLevel(2);
|
||||
expect(levelFromXp(level2Xp)).toBe(2);
|
||||
expect(levelFromXp(level2Xp - 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("xpProgressInLevel", () => {
|
||||
it("computes progress within current level", () => {
|
||||
const totalXp = xpForLevel(3) + 10;
|
||||
const progress = xpProgressInLevel(totalXp);
|
||||
expect(progress.level).toBe(3);
|
||||
expect(progress.current).toBe(10);
|
||||
expect(progress.percent).toBeGreaterThan(0);
|
||||
expect(progress.percent).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
27
packages/shared/src/levels.ts
Executable file
27
packages/shared/src/levels.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
export function xpForLevel(level: number): number {
|
||||
if (level <= 1) return 0;
|
||||
return Math.floor(100 * Math.pow(level - 1, 1.6));
|
||||
}
|
||||
|
||||
export function levelFromXp(totalXp: number): number {
|
||||
let level = 1;
|
||||
while (xpForLevel(level + 1) <= totalXp && level < 150) {
|
||||
level++;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function xpProgressInLevel(totalXp: number): {
|
||||
level: number;
|
||||
current: number;
|
||||
needed: number;
|
||||
percent: number;
|
||||
} {
|
||||
const level = levelFromXp(totalXp);
|
||||
const currentLevelXp = xpForLevel(level);
|
||||
const nextLevelXp = xpForLevel(level + 1);
|
||||
const current = totalXp - currentLevelXp;
|
||||
const needed = nextLevelXp - currentLevelXp;
|
||||
const percent = needed > 0 ? Math.min(100, (current / needed) * 100) : 100;
|
||||
return { level, current, needed, percent };
|
||||
}
|
||||
125
packages/shared/src/memory.ts
Normal file
125
packages/shared/src/memory.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export const MEMORY_CATEGORIES = {
|
||||
identity: "Identity & background",
|
||||
long_term_goals: "Long-term goals",
|
||||
current_goals: "Current goals",
|
||||
likes: "Likes",
|
||||
dislikes: "Dislikes",
|
||||
motivators: "Motivators",
|
||||
discouragers: "Things that discourage",
|
||||
daily_routines: "Daily routines",
|
||||
weekly_routines: "Weekly routines",
|
||||
spiritual_practices: "Spiritual practices",
|
||||
reading_preferences: "Reading preferences",
|
||||
learning_interests: "Learning interests",
|
||||
exercise_preferences: "Exercise preferences",
|
||||
work_study: "Work/study commitments",
|
||||
worries: "Worries & concerns",
|
||||
ai_tone: "Preferred AI tone",
|
||||
boundaries: "Boundaries & avoid",
|
||||
personal_context: "Important personal context",
|
||||
life_season: "Current life season",
|
||||
open_questions: "Open questions about user",
|
||||
} as const;
|
||||
|
||||
export type MemoryCategory = keyof typeof MEMORY_CATEGORIES;
|
||||
|
||||
export const MEMORY_SOURCE_TYPES = [
|
||||
"manual",
|
||||
"chat",
|
||||
"reflection",
|
||||
"adventure",
|
||||
"reading",
|
||||
"quest",
|
||||
"teacher",
|
||||
"cartographer",
|
||||
"weekly_review",
|
||||
"correction",
|
||||
"import",
|
||||
"summary_rebuild",
|
||||
] as const;
|
||||
|
||||
export type MemorySourceType = (typeof MEMORY_SOURCE_TYPES)[number];
|
||||
|
||||
export const MEMORY_SENSITIVITY_LEVELS = ["normal", "private", "sensitive"] as const;
|
||||
export type MemorySensitivity = (typeof MEMORY_SENSITIVITY_LEVELS)[number];
|
||||
|
||||
export const SENSITIVE_MEMORY_CATEGORIES: MemoryCategory[] = [
|
||||
"worries",
|
||||
"personal_context",
|
||||
"boundaries",
|
||||
];
|
||||
|
||||
export const MEMORY_SUGGESTION_STATUSES = [
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"ignored",
|
||||
] as const;
|
||||
|
||||
export type MemorySuggestionStatus = (typeof MEMORY_SUGGESTION_STATUSES)[number];
|
||||
|
||||
export const DAY_MODES = {
|
||||
normal: "Normal day",
|
||||
low_energy: "Low energy",
|
||||
travel: "Travel",
|
||||
illness: "Illness",
|
||||
family: "Family event",
|
||||
exam: "Exam day",
|
||||
work_emergency: "Work emergency",
|
||||
rest: "Rest day",
|
||||
maintenance: "Maintenance day",
|
||||
} as const;
|
||||
|
||||
export type DayMode = keyof typeof DAY_MODES;
|
||||
|
||||
export const FORGIVING_DAY_MODES: DayMode[] = [
|
||||
"low_energy",
|
||||
"travel",
|
||||
"illness",
|
||||
"family",
|
||||
"exam",
|
||||
"work_emergency",
|
||||
"rest",
|
||||
"maintenance",
|
||||
];
|
||||
|
||||
export interface MemorySourceRef {
|
||||
type: string;
|
||||
id?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface AiProfileSummary {
|
||||
summary: string;
|
||||
generatedAt: string;
|
||||
sourceMemoryCount: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface AiMemoryLearningSettings {
|
||||
learningEnabled: boolean;
|
||||
autoSuggestEnabled: boolean;
|
||||
requireApproval: boolean;
|
||||
allowedCategories: MemoryCategory[];
|
||||
suggestAfterReflection: boolean;
|
||||
suggestAfterChat: boolean;
|
||||
maxPendingSuggestions: number;
|
||||
minDaysBetweenNudges: number;
|
||||
allowSensitiveCategories: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_MEMORY_LEARNING: AiMemoryLearningSettings = {
|
||||
learningEnabled: false,
|
||||
autoSuggestEnabled: true,
|
||||
requireApproval: true,
|
||||
allowedCategories: (Object.keys(MEMORY_CATEGORIES) as MemoryCategory[]).filter(
|
||||
(c) => !SENSITIVE_MEMORY_CATEGORIES.includes(c)
|
||||
),
|
||||
suggestAfterReflection: true,
|
||||
suggestAfterChat: false,
|
||||
maxPendingSuggestions: 10,
|
||||
minDaysBetweenNudges: 3,
|
||||
allowSensitiveCategories: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_DAY_BOUNDARY_HOUR = 0;
|
||||
43
packages/shared/src/quest-pool.ts
Executable file
43
packages/shared/src/quest-pool.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
export interface StaticQuest {
|
||||
title: string;
|
||||
reason: string;
|
||||
xp_hint: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface StaticExploration {
|
||||
title: string;
|
||||
hook: string;
|
||||
category: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export const STATIC_QUESTS: StaticQuest[] = [
|
||||
{ title: "Read 10 pages tonight", reason: "A small chapter keeps the story alive", xp_hint: "+15 XP", category: "reading" },
|
||||
{ title: "Walk for 15 minutes", reason: "Movement clears the mind for tomorrow", xp_hint: "+50 XP", category: "health" },
|
||||
{ title: "Learn one new fact today", reason: "Curiosity compounds quietly", xp_hint: "+25 XP", category: "learning" },
|
||||
{ title: "Review yesterday's reflection", reason: "Patterns emerge when you look back gently", xp_hint: "+10 XP", category: "journey" },
|
||||
{ title: "Spend 5 minutes in prayer", reason: "Stillness anchors the day", xp_hint: "+5 XP", category: "spiritual" },
|
||||
{ title: "Log your work hours", reason: "Visibility builds honest rhythm", xp_hint: "+25 XP", category: "discipline" },
|
||||
];
|
||||
|
||||
export const STATIC_EXPLORATIONS: StaticExploration[] = [
|
||||
{ title: "How traceroute works", hook: "Follow a packet's journey across the internet in fifteen minutes.", category: "technology", minutes: 20 },
|
||||
{ title: "Roman road engineering", hook: "Discover how ancient roads outlasted empires.", category: "history", minutes: 25 },
|
||||
{ title: "The periodic table's shape", hook: "Learn why elements sit where they do.", category: "science", minutes: 20 },
|
||||
{ title: "Constellations this season", hook: "Find three stars visible from your window tonight.", category: "nature", minutes: 15 },
|
||||
{ title: "A moment in 1066", hook: "One battle that reshaped a continent — what happened after?", category: "history", minutes: 30 },
|
||||
{ title: "How DNS resolves a name", hook: "The hidden directory that makes the web work.", category: "technology", minutes: 20 },
|
||||
{ title: "Stoic morning practice", hook: "One ancient exercise for starting the day with clarity.", category: "philosophy", minutes: 15 },
|
||||
{ title: "Birdsong at dawn", hook: "Learn which birds you might hear tomorrow morning.", category: "nature", minutes: 15 },
|
||||
];
|
||||
|
||||
export function pickRandomQuests(count: number): StaticQuest[] {
|
||||
const shuffled = [...STATIC_QUESTS].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
export function pickRandomExplorations(count: number): StaticExploration[] {
|
||||
const shuffled = [...STATIC_EXPLORATIONS].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
70
packages/shared/src/scores.test.ts
Executable file
70
packages/shared/src/scores.test.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeConsistencyScore,
|
||||
computeDisciplineScore,
|
||||
computeLearningScore,
|
||||
computeSpiritualScore,
|
||||
computeHealthScore,
|
||||
computeReadingScore,
|
||||
computeAllScores,
|
||||
type DaySnapshot,
|
||||
} from "./scores";
|
||||
|
||||
function makeDay(overrides: Partial<DaySnapshot> = {}): DaySnapshot {
|
||||
return {
|
||||
date: "2026-01-01",
|
||||
adventureSlots: 5,
|
||||
adventureTouched: 3,
|
||||
exerciseDone: true,
|
||||
workHours: 6,
|
||||
workTarget: 8,
|
||||
sleepLogged: true,
|
||||
classesDone: 1,
|
||||
teachingDone: false,
|
||||
explorationsDone: 0,
|
||||
prayerChecks: 2,
|
||||
prayerTotal: 3,
|
||||
litanyChecks: 1,
|
||||
litanyTotal: 2,
|
||||
pagesRead: 20,
|
||||
readingGoalWeekly: 100,
|
||||
hadSpiritualEver: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeConsistencyScore", () => {
|
||||
it("returns 0 for empty days", () => {
|
||||
expect(computeConsistencyScore([])).toBe(0);
|
||||
});
|
||||
|
||||
it("scores touched adventure ratio", () => {
|
||||
const score = computeConsistencyScore([makeDay({ adventureTouched: 5, adventureSlots: 5 })]);
|
||||
expect(score).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 50 when no adventure slots", () => {
|
||||
expect(computeConsistencyScore([makeDay({ adventureSlots: 0 })])).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeReadingScore", () => {
|
||||
it("includes streak bonus", () => {
|
||||
const days = Array.from({ length: 7 }, (_, i) =>
|
||||
makeDay({ date: `2026-01-0${i + 1}`, pagesRead: 10 })
|
||||
);
|
||||
expect(computeReadingScore(days, 100)).toBeGreaterThan(computeReadingScore([makeDay()], 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAllScores", () => {
|
||||
it("returns all score domains", () => {
|
||||
const scores = computeAllScores([makeDay()], 100);
|
||||
expect(scores).toHaveProperty("consistencyScore");
|
||||
expect(scores).toHaveProperty("disciplineScore");
|
||||
expect(scores).toHaveProperty("learningScore");
|
||||
expect(scores).toHaveProperty("spiritualScore");
|
||||
expect(scores).toHaveProperty("healthScore");
|
||||
expect(scores).toHaveProperty("readingScore");
|
||||
});
|
||||
});
|
||||
137
packages/shared/src/scores.ts
Executable file
137
packages/shared/src/scores.ts
Executable file
@@ -0,0 +1,137 @@
|
||||
export interface DaySnapshot {
|
||||
date: string;
|
||||
adventureSlots: number;
|
||||
adventureTouched: number;
|
||||
exerciseDone: boolean;
|
||||
workHours: number;
|
||||
workTarget: number;
|
||||
sleepLogged: boolean;
|
||||
classesDone: number;
|
||||
teachingDone: boolean;
|
||||
explorationsDone: number;
|
||||
prayerChecks: number;
|
||||
prayerTotal: number;
|
||||
litanyChecks: number;
|
||||
litanyTotal: number;
|
||||
pagesRead: number;
|
||||
readingGoalWeekly: number;
|
||||
hadSpiritualEver: boolean;
|
||||
}
|
||||
|
||||
function decayWeight(daysAgo: number): number {
|
||||
return Math.exp(-daysAgo / 10);
|
||||
}
|
||||
|
||||
export function computeConsistencyScore(days: DaySnapshot[]): number {
|
||||
if (days.length === 0) return 0;
|
||||
let weighted = 0;
|
||||
let totalWeight = 0;
|
||||
days.forEach((d, i) => {
|
||||
const w = decayWeight(i);
|
||||
totalWeight += w;
|
||||
if (d.adventureSlots === 0) {
|
||||
weighted += w * 50;
|
||||
return;
|
||||
}
|
||||
weighted += w * (d.adventureTouched / d.adventureSlots) * 100;
|
||||
});
|
||||
return Math.round(weighted / totalWeight);
|
||||
}
|
||||
|
||||
export function computeDisciplineScore(days: DaySnapshot[]): number {
|
||||
if (days.length === 0) return 0;
|
||||
let weighted = 0;
|
||||
let totalWeight = 0;
|
||||
days.forEach((d, i) => {
|
||||
const w = decayWeight(i);
|
||||
totalWeight += w;
|
||||
const exercise = d.exerciseDone ? 100 : 0;
|
||||
const sleep = d.sleepLogged ? 100 : 50;
|
||||
const work =
|
||||
d.workTarget > 0
|
||||
? Math.min(100, (d.workHours / d.workTarget) * 100)
|
||||
: 50;
|
||||
weighted += w * (exercise * 0.4 + sleep * 0.3 + work * 0.3);
|
||||
});
|
||||
return Math.round(weighted / totalWeight);
|
||||
}
|
||||
|
||||
export function computeLearningScore(days: DaySnapshot[]): number {
|
||||
if (days.length === 0) return 0;
|
||||
let weighted = 0;
|
||||
let totalWeight = 0;
|
||||
days.forEach((d, i) => {
|
||||
const w = decayWeight(i);
|
||||
totalWeight += w;
|
||||
let dayScore = 0;
|
||||
if (d.classesDone > 0) dayScore += 40;
|
||||
if (d.teachingDone) dayScore += 30;
|
||||
if (d.explorationsDone > 0) dayScore += 30;
|
||||
weighted += w * Math.min(100, dayScore);
|
||||
});
|
||||
return Math.round(weighted / totalWeight);
|
||||
}
|
||||
|
||||
export function computeSpiritualScore(days: DaySnapshot[]): number {
|
||||
const hadEver = days.some((d) => d.hadSpiritualEver);
|
||||
if (days.length === 0) return hadEver ? 20 : 0;
|
||||
let weighted = 0;
|
||||
let totalWeight = 0;
|
||||
days.forEach((d, i) => {
|
||||
const w = decayWeight(i);
|
||||
totalWeight += w;
|
||||
const total = d.prayerTotal + d.litanyTotal;
|
||||
const checked = d.prayerChecks + d.litanyChecks;
|
||||
const rate = total > 0 ? (checked / total) * 100 : 0;
|
||||
weighted += w * rate;
|
||||
});
|
||||
const score = Math.round(weighted / totalWeight);
|
||||
return hadEver ? Math.max(20, score) : score;
|
||||
}
|
||||
|
||||
export function computeHealthScore(days: DaySnapshot[]): number {
|
||||
if (days.length === 0) return 0;
|
||||
let weighted = 0;
|
||||
let totalWeight = 0;
|
||||
days.forEach((d, i) => {
|
||||
const w = decayWeight(i);
|
||||
totalWeight += w;
|
||||
const exercise = d.exerciseDone ? 100 : 0;
|
||||
const sleep = d.sleepLogged ? 100 : 40;
|
||||
weighted += w * (exercise * 0.6 + sleep * 0.4);
|
||||
});
|
||||
return Math.round(weighted / totalWeight);
|
||||
}
|
||||
|
||||
export function computeReadingScore(
|
||||
days: DaySnapshot[],
|
||||
weeklyGoal: number
|
||||
): number {
|
||||
if (days.length === 0) return 0;
|
||||
const recentWeek = days.slice(0, 7);
|
||||
const pages = recentWeek.reduce((s, d) => s + d.pagesRead, 0);
|
||||
const goalScore = weeklyGoal > 0 ? Math.min(100, (pages / weeklyGoal) * 100) : 50;
|
||||
let streakBonus = 0;
|
||||
let streak = 0;
|
||||
for (const d of days) {
|
||||
if (d.pagesRead > 0) streak++;
|
||||
else break;
|
||||
}
|
||||
if (streak >= 7) streakBonus = 20;
|
||||
else if (streak >= 3) streakBonus = 10;
|
||||
return Math.min(100, Math.round(goalScore * 0.8 + streakBonus));
|
||||
}
|
||||
|
||||
export function computeAllScores(
|
||||
days: DaySnapshot[],
|
||||
weeklyReadingGoal: number
|
||||
) {
|
||||
return {
|
||||
consistencyScore: computeConsistencyScore(days),
|
||||
disciplineScore: computeDisciplineScore(days),
|
||||
learningScore: computeLearningScore(days),
|
||||
spiritualScore: computeSpiritualScore(days),
|
||||
healthScore: computeHealthScore(days),
|
||||
readingScore: computeReadingScore(days, weeklyReadingGoal),
|
||||
};
|
||||
}
|
||||
53
packages/shared/src/titles.ts
Executable file
53
packages/shared/src/titles.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
export interface TitleDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
minLevel: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const TITLES: TitleDefinition[] = [
|
||||
{
|
||||
key: "apprentice_reader",
|
||||
name: "Apprentice Reader",
|
||||
minLevel: 5,
|
||||
description: "Finished your first book",
|
||||
},
|
||||
{
|
||||
key: "early_riser",
|
||||
name: "Early Riser",
|
||||
minLevel: 10,
|
||||
description: "Logged sleep for 14 days",
|
||||
},
|
||||
{
|
||||
key: "consistent_builder",
|
||||
name: "Consistent Builder",
|
||||
minLevel: 25,
|
||||
description: "30-day consistency at 60+",
|
||||
},
|
||||
{
|
||||
key: "curious_wanderer",
|
||||
name: "Curious Wanderer",
|
||||
minLevel: 40,
|
||||
description: "Completed 10 explorations",
|
||||
},
|
||||
{
|
||||
key: "reliable_craftsman",
|
||||
name: "Reliable Craftsman",
|
||||
minLevel: 50,
|
||||
description: "90 days of adventure with steady consistency",
|
||||
},
|
||||
{
|
||||
key: "steady_pilgrim",
|
||||
name: "Steady Pilgrim",
|
||||
minLevel: 75,
|
||||
description: "Spiritual presence over 30 days",
|
||||
},
|
||||
{
|
||||
key: "master_of_discipline",
|
||||
name: "Master of Discipline",
|
||||
minLevel: 100,
|
||||
description: "One year of steady growth",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_TITLE = "New Adventurer";
|
||||
229
packages/shared/src/types.ts
Executable file
229
packages/shared/src/types.ts
Executable file
@@ -0,0 +1,229 @@
|
||||
export type AdventureItemType =
|
||||
| "duration"
|
||||
| "checkbox"
|
||||
| "checklist"
|
||||
| "timeblock"
|
||||
| "reading"
|
||||
| "note";
|
||||
|
||||
export type AdventureItemState =
|
||||
| "blank"
|
||||
| "started"
|
||||
| "partial"
|
||||
| "done";
|
||||
|
||||
export type BookStatus = "reading" | "paused" | "finished";
|
||||
|
||||
export type ExplorationStatus =
|
||||
| "suggested"
|
||||
| "active"
|
||||
| "completed"
|
||||
| "dismissed";
|
||||
|
||||
export type AiRole = "quest_giver" | "mentor" | "teacher";
|
||||
|
||||
export type XpSource =
|
||||
| "daily_visit"
|
||||
| "adventure_item"
|
||||
| "reflection"
|
||||
| "spiritual"
|
||||
| "reading"
|
||||
| "exercise"
|
||||
| "exploration"
|
||||
| "weekly_review"
|
||||
| "rest_day"
|
||||
| "book_complete"
|
||||
| "achievement";
|
||||
|
||||
export interface PortraitConfig {
|
||||
skinTone: string;
|
||||
hairColor: string;
|
||||
clothingColor: string;
|
||||
}
|
||||
|
||||
export interface AdventureItemConfig {
|
||||
targetHours?: number;
|
||||
checklistSize?: number;
|
||||
bookId?: string;
|
||||
scheduledTime?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AdventureItemValue {
|
||||
hours?: number;
|
||||
note?: string;
|
||||
checks?: boolean[];
|
||||
pages?: number;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
portraitConfig: PortraitConfig;
|
||||
currentTitle: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
progress: {
|
||||
totalXp: number;
|
||||
level: number;
|
||||
currentChapter: string;
|
||||
graceDaysRemaining: number;
|
||||
consistencyScore: number;
|
||||
disciplineScore: number;
|
||||
learningScore: number;
|
||||
spiritualScore: number;
|
||||
healthScore: number;
|
||||
readingScore: number;
|
||||
journeyDay: number;
|
||||
};
|
||||
today: DailyAdventureData | null;
|
||||
reflection: ReflectionData | null;
|
||||
suggestions: AiSuggestionData[];
|
||||
reading: {
|
||||
activeBooks: BookSummary[];
|
||||
streak: ReadingStreak;
|
||||
weeklyPages: number;
|
||||
weeklyGoal: number;
|
||||
};
|
||||
}
|
||||
|
||||
import type { DayMode } from "./memory";
|
||||
|
||||
export interface DailyAdventureData {
|
||||
id: string;
|
||||
date: string;
|
||||
isRestDay: boolean;
|
||||
isCustomized: boolean;
|
||||
workHoursTarget: number | null;
|
||||
dayMode: DayMode;
|
||||
isBackfilled: boolean;
|
||||
loggedAt: string | null;
|
||||
items: DailyAdventureItemData[];
|
||||
todos: DailyTodoData[];
|
||||
}
|
||||
|
||||
export interface DailyTodoData {
|
||||
id: string;
|
||||
label: string;
|
||||
done: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DailyAdventureItemData {
|
||||
id: string;
|
||||
type: AdventureItemType;
|
||||
label: string;
|
||||
state: AdventureItemState;
|
||||
value: AdventureItemValue;
|
||||
config: AdventureItemConfig;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
export interface ReflectionData {
|
||||
wentWell: string;
|
||||
learned: string;
|
||||
improveTomorrow: string;
|
||||
}
|
||||
|
||||
export interface AiSuggestionData {
|
||||
id: string;
|
||||
role: AiRole;
|
||||
content: Record<string, unknown>;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
status: BookStatus;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
export interface ReadingStreak {
|
||||
current: number;
|
||||
best: number;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateData {
|
||||
id: string;
|
||||
name: string;
|
||||
daysOfWeek: number[];
|
||||
isDefault: boolean;
|
||||
isSystem: boolean;
|
||||
sortPriority: number;
|
||||
items: TemplateItemData[];
|
||||
}
|
||||
|
||||
export interface TemplateItemData {
|
||||
id: string;
|
||||
type: AdventureItemType;
|
||||
label: string;
|
||||
config: AdventureItemConfig;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewData {
|
||||
id: string;
|
||||
weekStart: string;
|
||||
content: Record<string, unknown>;
|
||||
mentorLetter: string | null;
|
||||
xpEarned: number;
|
||||
userIntention: string | null;
|
||||
}
|
||||
|
||||
export interface ExplorationData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: ExplorationStatus;
|
||||
weekOf: string;
|
||||
completedNote: string | null;
|
||||
}
|
||||
|
||||
export interface AchievementData {
|
||||
id: string;
|
||||
key: string;
|
||||
unlockedAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
totalXp: number;
|
||||
xpThisMonth: number;
|
||||
booksCompleted: number;
|
||||
pagesThisMonth: number;
|
||||
exerciseSessions: number;
|
||||
consistencyTrend: { date: string; value: number }[];
|
||||
xpByCategory: { category: string; amount: number }[];
|
||||
}
|
||||
|
||||
export interface TeacherQuizQuestion {
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: number;
|
||||
}
|
||||
|
||||
export interface TeacherLessonContent {
|
||||
flashcards: { front: string; back: string }[];
|
||||
quiz: TeacherQuizQuestion[];
|
||||
assignment: string;
|
||||
}
|
||||
|
||||
export interface TeacherLessonData {
|
||||
id: string;
|
||||
topic: string;
|
||||
content: TeacherLessonContent;
|
||||
status: string;
|
||||
explorationId?: string | null;
|
||||
completedNote?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
39
packages/shared/src/xp.test.ts
Executable file
39
packages/shared/src/xp.test.ts
Executable file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
XP_AWARDS,
|
||||
xpForAdventureState,
|
||||
applyDiminishingReturns,
|
||||
xpForReadingPages,
|
||||
} from "./xp";
|
||||
|
||||
describe("xpForAdventureState", () => {
|
||||
it("maps states to XP awards", () => {
|
||||
expect(xpForAdventureState("started")).toBe(XP_AWARDS.adventure_started);
|
||||
expect(xpForAdventureState("partial")).toBe(XP_AWARDS.adventure_partial);
|
||||
expect(xpForAdventureState("done")).toBe(XP_AWARDS.adventure_done);
|
||||
expect(xpForAdventureState("blank")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDiminishingReturns", () => {
|
||||
it("returns full amount when under soft cap", () => {
|
||||
expect(applyDiminishingReturns(20, 0, 60)).toBe(20);
|
||||
});
|
||||
|
||||
it("applies 25% when already at cap", () => {
|
||||
expect(applyDiminishingReturns(20, 60, 60)).toBe(5);
|
||||
});
|
||||
|
||||
it("splits amount at cap boundary", () => {
|
||||
expect(applyDiminishingReturns(30, 50, 60)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("xpForReadingPages", () => {
|
||||
it("awards per 10 pages", () => {
|
||||
expect(xpForReadingPages(0)).toBe(0);
|
||||
expect(xpForReadingPages(9)).toBe(0);
|
||||
expect(xpForReadingPages(10)).toBe(XP_AWARDS.reading_per_10_pages);
|
||||
expect(xpForReadingPages(25)).toBe(XP_AWARDS.reading_per_10_pages * 2);
|
||||
});
|
||||
});
|
||||
57
packages/shared/src/xp.ts
Executable file
57
packages/shared/src/xp.ts
Executable file
@@ -0,0 +1,57 @@
|
||||
import type { AdventureItemState, XpSource } from "./types";
|
||||
|
||||
export const XP_AWARDS = {
|
||||
daily_visit: 5,
|
||||
adventure_started: 10,
|
||||
adventure_partial: 25,
|
||||
adventure_done: 40,
|
||||
reflection: 30,
|
||||
spiritual_per_check: 5,
|
||||
spiritual_daily_cap: 25,
|
||||
reading_per_10_pages: 15,
|
||||
reading_daily_cap: 60,
|
||||
exercise: 50,
|
||||
exploration: 100,
|
||||
weekly_review: 150,
|
||||
rest_day: 15,
|
||||
book_complete: 200,
|
||||
} as const;
|
||||
|
||||
export const DAILY_SOFT_CAPS: Partial<Record<XpSource, number>> = {
|
||||
adventure_item: 200,
|
||||
spiritual: 25,
|
||||
reading: 60,
|
||||
};
|
||||
|
||||
export function xpForAdventureState(state: AdventureItemState): number {
|
||||
switch (state) {
|
||||
case "started":
|
||||
return XP_AWARDS.adventure_started;
|
||||
case "partial":
|
||||
return XP_AWARDS.adventure_partial;
|
||||
case "done":
|
||||
return XP_AWARDS.adventure_done;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDiminishingReturns(
|
||||
amount: number,
|
||||
earnedToday: number,
|
||||
softCap: number
|
||||
): number {
|
||||
if (earnedToday >= softCap) {
|
||||
return Math.floor(amount * 0.25);
|
||||
}
|
||||
if (earnedToday + amount > softCap) {
|
||||
const atFull = softCap - earnedToday;
|
||||
const over = amount - atFull;
|
||||
return atFull + Math.floor(over * 0.25);
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function xpForReadingPages(pages: number): number {
|
||||
return Math.floor(pages / 10) * XP_AWARDS.reading_per_10_pages;
|
||||
}
|
||||
8
packages/shared/vitest.config.ts
Executable file
8
packages/shared/vitest.config.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user