change
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-07-03 16:26:54 +01:00
parent 873c9a5f51
commit 8cff315496
84 changed files with 3063 additions and 603 deletions

View File

@@ -0,0 +1,21 @@
{
"name": "@adventureos/application",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@adventureos/domain": "*"
},
"devDependencies": {
"typescript": "^5",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,7 @@
export * from "./ports/adventure.repository";
export * from "./ports/xp.repository";
export * from "./ports/action-events.repository";
export * from "./ports/user.repository";
export * from "./ports/scoring.service";
export * from "./use-cases/xp/award-xp";
export * from "./use-cases/adventure/update-adventure-item";

View File

@@ -0,0 +1,30 @@
export type ActionEventRecord = {
id: string;
userId: string;
actionType: string;
entityType: string;
entityId: string;
summary: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
metadata: Record<string, unknown> | null;
createdAt: Date;
undoneAt: Date | null;
};
export type RecordActionInput = {
userId: string;
actionType: string;
entityType: string;
entityId: string;
summary: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export interface ActionEventsRepository {
recordAction(input: RecordActionInput): Promise<ActionEventRecord>;
getActionEvent(id: string, userId: string): Promise<ActionEventRecord | null>;
markUndone(id: string): Promise<void>;
}

View File

@@ -0,0 +1,86 @@
import type { AdventureItemState } from "@adventureos/domain";
export type DailyAdventureItemRecord = {
id: string;
dailyAdventureId: string;
type: string;
label: string;
config: Record<string, unknown> | null;
value: Record<string, unknown> | null;
state: AdventureItemState;
completedAt: Date | null;
enabled: boolean;
isCustom: boolean;
sortOrder: number;
sourceItemId: string | null;
};
export type DailyAdventureRecord = {
id: string;
userId: string;
date: string;
templateId: string | null;
isRestDay: boolean;
isCustomized: boolean;
workHoursTarget: string | null;
dayMode: string | null;
};
export type DailyTodoRecord = {
id: string;
dailyAdventureId: string;
label: string;
done: boolean;
sortOrder: number;
};
export type AdventureTemplateRecord = {
id: string;
userId: string;
daysOfWeek: number[];
sortPriority: number;
isDefault: boolean;
deletedAt: Date | null;
};
export type AdventureTemplateItemRecord = {
id: string;
templateId: string;
type: string;
label: string;
config: Record<string, unknown> | null;
sortOrder: number;
enabled: boolean;
deletedAt: Date | null;
};
export interface AdventureRepository {
findDailyAdventure(userId: string, date: string): Promise<DailyAdventureRecord | null>;
createDailyAdventure(
userId: string,
date: string,
templateId?: string | null
): Promise<DailyAdventureRecord>;
listDailyAdventureItems(adventureId: string): Promise<DailyAdventureItemRecord[]>;
listDailyTodos(adventureId: string): Promise<DailyTodoRecord[]>;
updateDailyAdventureItem(
itemId: string,
updates: {
value?: Record<string, unknown>;
state?: AdventureItemState;
completedAt?: Date | null;
}
): Promise<void>;
softDeleteDailyAdventureItems(adventureId: string): Promise<void>;
insertDailyAdventureItem(
adventureId: string,
item: Omit<DailyAdventureItemRecord, "id" | "dailyAdventureId">
): Promise<DailyAdventureItemRecord>;
updateDailyAdventure(
adventureId: string,
updates: Record<string, unknown>
): Promise<void>;
listTemplates(userId: string): Promise<AdventureTemplateRecord[]>;
listTemplateItems(templateId: string): Promise<AdventureTemplateItemRecord[]>;
findTemplate(userId: string, templateId: string): Promise<AdventureTemplateRecord | null>;
}

View File

@@ -0,0 +1,6 @@
import type { DaySnapshot } from "@adventureos/domain";
export interface ScoringService {
refreshScores(userId: string): Promise<void>;
buildDaySnapshot(userId: string, date: string): Promise<DaySnapshot>;
}

View File

@@ -0,0 +1,9 @@
export type UserRecord = {
id: string;
displayName: string;
};
export interface UserRepository {
getUser(userId: string): Promise<UserRecord | null>;
getSingleUser(): Promise<UserRecord | null>;
}

View File

@@ -0,0 +1,39 @@
import type { XpSource } from "@adventureos/domain";
export type UserProgressRecord = {
userId: string;
totalXp: number;
level: number;
currentChapter: string;
consistencyScore: number;
disciplineScore: number;
learningScore: number;
spiritualScore: number;
healthScore: number;
readingScore: number;
};
export type XpEventRecord = {
id: string;
userId: string;
date: string;
source: XpSource;
amount: number;
metadata: Record<string, unknown> | null;
};
export interface XpRepository {
getDailyXpEarned(userId: string, date: string, source?: XpSource): Promise<number>;
insertXpEvent(event: Omit<XpEventRecord, "id">): Promise<XpEventRecord>;
getUserProgress(userId: string): Promise<UserProgressRecord | null>;
updateUserProgress(
userId: string,
updates: Partial<UserProgressRecord>
): Promise<void>;
listXpEventsByDateAndSource(
userId: string,
date: string,
source: XpSource
): Promise<XpEventRecord[]>;
listAllXpEvents(userId: string): Promise<XpEventRecord[]>;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { updateAdventureItem } from "./update-adventure-item";
import type { AdventureRepository, DailyAdventureItemRecord } from "../../ports/adventure.repository";
import type { ActionEventsRepository } from "../../ports/action-events.repository";
import type { ScoringService } from "../../ports/scoring.service";
import type { XpRepository } from "../../ports/xp.repository";
const baseItem: DailyAdventureItemRecord = {
id: "item-1",
dailyAdventureId: "adv-1",
type: "checkbox",
label: "Exercise",
config: { semanticKey: "exercise" },
value: {},
state: "blank",
completedAt: null,
enabled: true,
isCustom: false,
sortOrder: 1,
sourceItemId: null,
};
function createDeps(overrides: {
adventureRepository?: Partial<AdventureRepository>;
} = {}) {
const adventureRepository: AdventureRepository = {
findDailyAdventure: vi.fn().mockResolvedValue({ id: "adv-1", userId: "u1", date: "2026-01-01", templateId: null, isRestDay: false, isCustomized: false, workHoursTarget: null, dayMode: null }),
createDailyAdventure: vi.fn(),
listDailyAdventureItems: vi.fn().mockResolvedValue([baseItem]),
listDailyTodos: vi.fn().mockResolvedValue([]),
updateDailyAdventureItem: vi.fn().mockResolvedValue(undefined),
softDeleteDailyAdventureItems: vi.fn(),
insertDailyAdventureItem: vi.fn(),
updateDailyAdventure: vi.fn(),
listTemplates: vi.fn().mockResolvedValue([]),
listTemplateItems: vi.fn().mockResolvedValue([]),
findTemplate: vi.fn(),
...overrides.adventureRepository,
};
const xpRepository: XpRepository = {
getDailyXpEarned: vi.fn().mockResolvedValue(0),
insertXpEvent: vi.fn().mockImplementation(async (event) => ({
id: `xp-${event.source}`,
...event,
})),
getUserProgress: vi.fn().mockResolvedValue({
userId: "u1",
totalXp: 0,
level: 1,
currentChapter: "Prologue",
consistencyScore: 0,
disciplineScore: 0,
learningScore: 0,
spiritualScore: 0,
healthScore: 0,
readingScore: 0,
}),
updateUserProgress: vi.fn(),
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
listAllXpEvents: vi.fn().mockResolvedValue([]),
};
const actionEventsRepository: ActionEventsRepository = {
recordAction: vi.fn().mockResolvedValue({
id: "action-1",
userId: "u1",
actionType: "adventure_item.update",
entityType: "daily_adventure_item",
entityId: "item-1",
summary: "Updated Exercise",
beforeState: {},
afterState: {},
metadata: null,
createdAt: new Date(),
undoneAt: null,
}),
getActionEvent: vi.fn(),
markUndone: vi.fn(),
};
const scoringService: ScoringService = {
refreshScores: vi.fn(),
buildDaySnapshot: vi.fn(),
};
return { adventureRepository, xpRepository, actionEventsRepository, scoringService };
}
describe("updateAdventureItem use case", () => {
beforeEach(() => vi.clearAllMocks());
it("marks exercise checkbox done and awards exercise XP", async () => {
const deps = createDeps();
const result = await updateAdventureItem(deps, {
userId: "u1",
date: "2026-01-01",
itemId: "item-1",
dayOfWeek: 1,
updates: { value: { done: true } },
});
expect(result.state).toBe("done");
expect(deps.xpRepository.insertXpEvent).toHaveBeenCalledTimes(2);
expect(deps.scoringService.refreshScores).toHaveBeenCalledWith("u1");
expect(result.actionEventId).toBe("action-1");
});
});

View File

@@ -0,0 +1,185 @@
import {
deriveState,
matchesSemanticKey,
pickTemplateForDay,
xpForAdventureState,
XP_AWARDS,
type AdventureItemState,
} from "@adventureos/domain";
import type { AdventureRepository } from "../../ports/adventure.repository";
import type { ActionEventsRepository } from "../../ports/action-events.repository";
import type { ScoringService } from "../../ports/scoring.service";
import { awardXp, type AwardXpDeps } from "../xp/award-xp";
export const ADVENTURE_ITEM_UPDATE_ACTION = "adventure_item.update";
export type UpdateAdventureItemInput = {
userId: string;
date: string;
itemId: string;
dayOfWeek: number;
updates: { value?: Record<string, unknown>; state?: AdventureItemState };
};
export type UpdateAdventureItemDeps = {
adventureRepository: AdventureRepository;
actionEventsRepository: ActionEventsRepository;
scoringService: ScoringService;
} & AwardXpDeps;
export async function updateAdventureItem(
deps: UpdateAdventureItemDeps,
input: UpdateAdventureItemInput
) {
const { adventureRepository, actionEventsRepository, scoringService } = deps;
const { userId, date, itemId, dayOfWeek, updates } = input;
const { items } = await getDailyAdventure(deps, userId, date, dayOfWeek);
const item = items.find((i) => i.id === itemId);
if (!item) throw new Error("Item not found");
const newValue = { ...(item.value ?? {}), ...updates.value };
const newState =
updates.state ?? deriveState(item.type, newValue, item.config ?? {});
const oldState = item.state;
const beforeState = {
value: item.value,
state: item.state,
completedAt: item.completedAt,
};
await adventureRepository.updateDailyAdventureItem(itemId, {
value: newValue,
state: newState,
completedAt: newState === "done" ? new Date() : null,
});
const xpEventIds: string[] = [];
if (newState !== oldState && newState !== "blank") {
const xp = xpForAdventureState(newState);
if (xp > 0) {
const result = await awardXp(deps, {
userId,
date,
source: "adventure_item",
amount: xp,
metadata: { itemId, state: newState },
});
if (result.xpEventId) xpEventIds.push(result.xpEventId);
}
if (
matchesSemanticKey(item, "exercise") &&
newState === "done"
) {
const ex = await awardXp(deps, {
userId,
date,
source: "exercise",
amount: XP_AWARDS.exercise,
metadata: { itemId },
});
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
}
if (item.type === "checklist") {
const oldChecks = ((item.value ?? {}).checks as boolean[]) ?? [];
const newChecks = (newValue.checks as boolean[]) ?? [];
const added = newChecks.filter((c, i) => c && !oldChecks[i]).length;
if (added > 0) {
const sp = await awardXp(deps, {
userId,
date,
source: "spiritual",
amount: added * XP_AWARDS.spiritual_per_check,
metadata: { itemId },
});
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
}
}
}
const actionEvent = await actionEventsRepository.recordAction({
userId,
actionType: ADVENTURE_ITEM_UPDATE_ACTION,
entityType: "daily_adventure_item",
entityId: itemId,
summary: `Updated ${item.label}`,
beforeState,
afterState: {
value: newValue,
state: newState,
completedAt: newState === "done" ? new Date() : null,
},
metadata: { date, xpEventIds, itemLabel: item.label },
});
await scoringService.refreshScores(userId);
return { state: newState, value: newValue, actionEventId: actionEvent.id };
}
function initialChecklistValue(config: Record<string, unknown>): Record<string, unknown> {
const size = (config.checklistSize as number) ?? 5;
return { checks: Array(size).fill(false) };
}
export async function materializeDay(
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
userId: string,
date: string,
dayOfWeek: number
) {
const { adventureRepository } = deps;
const existing = await adventureRepository.findDailyAdventure(userId, date);
if (existing) return existing;
const templates = await adventureRepository.listTemplates(userId);
const template = pickTemplateForDay(templates, dayOfWeek);
if (!template) {
return adventureRepository.createDailyAdventure(userId, date);
}
const items = await adventureRepository.listTemplateItems(template.id);
const adventure = await adventureRepository.createDailyAdventure(
userId,
date,
template.id
);
for (const item of items) {
const config = item.config ?? {};
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
await adventureRepository.insertDailyAdventureItem(adventure.id, {
sourceItemId: item.id,
type: item.type,
label: item.label,
config,
value,
state: "blank",
completedAt: null,
sortOrder: item.sortOrder,
enabled: item.enabled,
isCustom: false,
});
}
return adventure;
}
export async function getDailyAdventure(
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
userId: string,
date: string,
dayOfWeek: number
) {
const adventure =
(await deps.adventureRepository.findDailyAdventure(userId, date)) ??
(await materializeDay(deps, userId, date, dayOfWeek));
const [items, todos] = await Promise.all([
deps.adventureRepository.listDailyAdventureItems(adventure.id),
deps.adventureRepository.listDailyTodos(adventure.id),
]);
return { adventure, items, todos };
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { awardXp } from "./award-xp";
import type { XpRepository } from "../../ports/xp.repository";
function createMockXpRepository(overrides: Partial<XpRepository> = {}): XpRepository {
return {
getDailyXpEarned: vi.fn().mockResolvedValue(0),
insertXpEvent: vi.fn().mockResolvedValue({
id: "xp-1",
userId: "user-1",
date: "2026-01-01",
source: "adventure_item",
amount: 40,
metadata: null,
}),
getUserProgress: vi.fn().mockResolvedValue({
userId: "user-1",
totalXp: 100,
level: 1,
currentChapter: "Prologue",
consistencyScore: 0,
disciplineScore: 0,
learningScore: 0,
spiritualScore: 0,
healthScore: 0,
readingScore: 0,
}),
updateUserProgress: vi.fn().mockResolvedValue(undefined),
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
listAllXpEvents: vi.fn().mockResolvedValue([]),
...overrides,
};
}
describe("awardXp use case", () => {
beforeEach(() => vi.clearAllMocks());
it("returns zero for non-positive amounts", async () => {
const xpRepository = createMockXpRepository();
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 0 }
);
expect(result.awarded).toBe(0);
expect(xpRepository.insertXpEvent).not.toHaveBeenCalled();
});
it("awards XP and updates progress", async () => {
const xpRepository = createMockXpRepository();
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 50 }
);
expect(result.awarded).toBe(50);
expect(xpRepository.insertXpEvent).toHaveBeenCalled();
expect(xpRepository.updateUserProgress).toHaveBeenCalled();
});
it("applies soft cap diminishing returns", async () => {
const xpRepository = createMockXpRepository({
getDailyXpEarned: vi.fn().mockResolvedValue(200),
});
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "adventure_item", amount: 40 }
);
expect(result.awarded).toBe(10);
});
});

View File

@@ -0,0 +1,81 @@
import {
applyDiminishingReturns,
chapterForLevel,
DAILY_SOFT_CAPS,
levelFromXp,
type XpSource,
} from "@adventureos/domain";
import type { XpRepository } from "../../ports/xp.repository";
export type AwardXpInput = {
userId: string;
date: string;
source: XpSource;
amount: number;
metadata?: Record<string, unknown>;
};
export type AwardXpResult = {
awarded: number;
levelUp: boolean;
newLevel: number;
xpEventId?: string;
};
export type AwardXpDeps = {
xpRepository: XpRepository;
};
export async function awardXp(
deps: AwardXpDeps,
input: AwardXpInput
): Promise<AwardXpResult> {
const { xpRepository } = deps;
const { userId, date, source, amount, metadata } = input;
const progress = await xpRepository.getUserProgress(userId);
const currentLevel = progress?.level ?? 1;
if (amount <= 0) {
return { awarded: 0, levelUp: false, newLevel: currentLevel };
}
const softCap = DAILY_SOFT_CAPS[source];
let finalAmount = amount;
if (softCap) {
const earned = await xpRepository.getDailyXpEarned(userId, date, source);
finalAmount = applyDiminishingReturns(amount, earned, softCap);
}
if (finalAmount <= 0) {
return { awarded: 0, levelUp: false, newLevel: currentLevel };
}
const inserted = await xpRepository.insertXpEvent({
userId,
date,
source,
amount: finalAmount,
metadata: metadata ?? null,
});
const updatedProgress = await xpRepository.getUserProgress(userId);
const oldLevel = updatedProgress?.level ?? currentLevel;
const oldTotal = updatedProgress?.totalXp ?? 0;
const newTotal = oldTotal + finalAmount;
const newLevel = levelFromXp(newTotal);
const chapter = chapterForLevel(newLevel);
await xpRepository.updateUserProgress(userId, {
totalXp: newTotal,
level: newLevel,
currentChapter: chapter.name,
});
return {
awarded: finalAmount,
levelUp: newLevel > oldLevel,
newLevel,
xpEventId: inserted.id,
};
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});

View File

@@ -25,6 +25,23 @@ const DEFAULT_LITANIES = [
"Litany 6",
];
function itemConfig(
label: string,
config: Record<string, unknown> = {}
): Record<string, unknown> {
const semanticByLabel: Record<string, string> = {
Work: "work",
Exercise: "exercise",
Prayer: "prayer",
Litanies: "litanies",
Teaching: "teaching",
"Class 1": "class",
"Class 2": "class",
};
const semanticKey = semanticByLabel[label];
return semanticKey ? { ...config, semanticKey } : config;
}
async function seed() {
const existing = await db.select().from(users).limit(1);
if (existing.length > 0) {
@@ -58,13 +75,13 @@ async function seed() {
isSystem: true,
sortPriority: 0,
items: [
{ type: "duration", label: "Work", config: { targetHours: 7.5 }, sortOrder: 0 },
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 1 },
{ type: "duration", label: "Work", config: itemConfig("Work", { targetHours: 7.5 }), sortOrder: 0 },
{ type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), sortOrder: 1 },
{ type: "reading", label: "Read", config: {}, sortOrder: 2 },
{ type: "timeblock", label: "Class 1", config: { scheduledTime: "18:00" }, sortOrder: 3 },
{ type: "timeblock", label: "Class 2", config: { scheduledTime: "20:00" }, sortOrder: 4 },
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 5 },
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 6 },
{ type: "timeblock", label: "Class 1", config: itemConfig("Class 1", { scheduledTime: "18:00" }), sortOrder: 3 },
{ type: "timeblock", label: "Class 2", config: itemConfig("Class 2", { scheduledTime: "20:00" }), sortOrder: 4 },
{ type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 5 },
{ type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 6 },
{ type: "note", label: "Notes", config: {}, sortOrder: 7 },
],
},
@@ -75,10 +92,10 @@ async function seed() {
isSystem: true,
sortPriority: 0,
items: [
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 0 },
{ type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), sortOrder: 0 },
{ type: "reading", label: "Read", config: {}, sortOrder: 1 },
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 2 },
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 3 },
{ type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 2 },
{ type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 3 },
{ type: "note", label: "Notes", config: {}, sortOrder: 4 },
],
},
@@ -90,9 +107,9 @@ async function seed() {
sortPriority: 0,
items: [
{ type: "reading", label: "Read", config: {}, sortOrder: 0 },
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 1 },
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 2 },
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 3 },
{ type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), sortOrder: 1 },
{ type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 2 },
{ type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 3 },
{ type: "note", label: "Notes", config: {}, sortOrder: 4 },
],
},
@@ -104,11 +121,11 @@ async function seed() {
sortPriority: 10,
items: [
{ type: "duration", label: "Work", config: { targetHours: 7.5 }, sortOrder: 0 },
{ type: "checkbox", label: "Teaching", config: {}, sortOrder: 1 },
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 2 },
{ type: "checkbox", label: "Teaching", config: itemConfig("Teaching"), sortOrder: 1 },
{ type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), sortOrder: 2 },
{ type: "reading", label: "Read", config: {}, sortOrder: 3 },
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 4 },
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 5 },
{ type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 4 },
{ type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 5 },
],
},
];

25
packages/domain/package.json Executable file
View File

@@ -0,0 +1,25 @@
{
"name": "@adventureos/domain",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts",
"./adventure/derive-state": "./src/adventure/derive-state.ts",
"./adventure/template-matching": "./src/adventure/template-matching.ts",
"./adventure/semantic-key": "./src/adventure/semantic-key.ts",
"./reflection": "./src/reflection/index.ts"
},
"dependencies": {
"date-fns": "^4.1.0"
},
"devDependencies": {
"typescript": "^5",
"vitest": "^3.2.6"
}
}

View 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);
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { deriveState } from "./derive-state";
describe("deriveState", () => {
it("checkbox: blank when not done", () => {
expect(deriveState("checkbox", {}, {})).toBe("blank");
});
it("checkbox: done when checked", () => {
expect(deriveState("checkbox", { done: true }, {})).toBe("done");
});
it("duration: done at target", () => {
expect(deriveState("duration", { hours: 8 }, { targetHours: 8 })).toBe("done");
});
it("duration: partial at half target", () => {
expect(deriveState("duration", { hours: 4 }, { targetHours: 8 })).toBe("partial");
});
it("duration: started below half", () => {
expect(deriveState("duration", { hours: 2 }, { targetHours: 8 })).toBe("started");
});
it("checklist: partial when some checked", () => {
expect(deriveState("checklist", { checks: [true, false, false] }, {})).toBe("partial");
});
it("checklist: done when all checked", () => {
expect(deriveState("checklist", { checks: [true, true] }, {})).toBe("done");
});
it("reading: done at 10+ pages", () => {
expect(deriveState("reading", { pages: 10 }, {})).toBe("done");
expect(deriveState("reading", { pages: 5 }, {})).toBe("partial");
});
});

View File

@@ -0,0 +1,32 @@
import type { AdventureItemState } from "../types";
export function deriveState(
type: string,
value: Record<string, unknown>,
config: Record<string, unknown>
): AdventureItemState {
if (type === "checkbox" || type === "timeblock") {
return value.done ? "done" : "blank";
}
if (type === "duration") {
const hours = (value.hours as number) ?? 0;
const target = (config.targetHours as number) ?? 0;
if (hours <= 0) return "blank";
if (target > 0 && hours >= target) return "done";
if (hours > 0) return hours >= target * 0.5 ? "partial" : "started";
return "started";
}
if (type === "checklist") {
const checks = (value.checks as boolean[]) ?? [];
const done = checks.filter(Boolean).length;
if (done === 0) return "blank";
if (done === checks.length) return "done";
return "partial";
}
if (type === "reading") {
const pages = (value.pages as number) ?? 0;
if (pages <= 0) return "blank";
return pages >= 10 ? "done" : "partial";
}
return "blank";
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { matchesSemanticKey, resolveSemanticKey } from "./semantic-key";
describe("semantic-key", () => {
it("resolves exercise from label fallback", () => {
expect(resolveSemanticKey({ type: "checkbox", label: "Exercise" })).toBe("exercise");
});
it("prefers config semanticKey over label", () => {
expect(
resolveSemanticKey({
type: "checkbox",
label: "Morning Run",
config: { semanticKey: "exercise" },
})
).toBe("exercise");
});
it("matches prayer and litanies by exact label fallback", () => {
expect(matchesSemanticKey({ type: "checklist", label: "Prayer" }, "prayer")).toBe(true);
expect(matchesSemanticKey({ type: "checklist", label: "Litanies" }, "litanies")).toBe(true);
});
it("resolves work from duration type", () => {
expect(matchesSemanticKey({ type: "duration", label: "Work" }, "work")).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
export type AdventureSemanticKey =
| "exercise"
| "prayer"
| "litanies"
| "teaching"
| "work"
| "class";
export type SemanticItem = {
type: string;
label: string;
config?: Record<string, unknown> | null;
};
export function resolveSemanticKey(item: SemanticItem): AdventureSemanticKey | null {
const fromConfig = item.config?.semanticKey;
if (typeof fromConfig === "string") {
return fromConfig as AdventureSemanticKey;
}
const label = item.label.toLowerCase();
if (item.type === "checkbox" && label.includes("exercise")) return "exercise";
if (item.type === "checkbox" && label.includes("teaching")) return "teaching";
if (item.label === "Prayer") return "prayer";
if (item.label === "Litanies") return "litanies";
if (item.type === "duration") return "work";
if (item.type === "timeblock" || label.includes("class")) return "class";
return null;
}
export function matchesSemanticKey(
item: SemanticItem,
key: AdventureSemanticKey
): boolean {
return resolveSemanticKey(item) === key;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { pickTemplateForDay } from "./template-matching";
describe("pickTemplateForDay", () => {
const templates = [
{
id: "weekday",
daysOfWeek: [1, 2, 3, 4, 5],
sortPriority: 0,
isDefault: true,
deletedAt: null,
},
{
id: "tuesday-thursday",
daysOfWeek: [2, 4],
sortPriority: 10,
isDefault: false,
deletedAt: null,
},
{
id: "deleted",
daysOfWeek: [2],
sortPriority: 100,
isDefault: false,
deletedAt: new Date(),
},
];
it("prefers higher sortPriority on overlapping days", () => {
expect(pickTemplateForDay(templates, 2)?.id).toBe("tuesday-thursday");
});
it("breaks ties by fewer daysOfWeek entries", () => {
const tied = [
{ id: "broad", daysOfWeek: [1, 2, 3, 4, 5], sortPriority: 5, isDefault: false, deletedAt: null },
{ id: "narrow", daysOfWeek: [2], sortPriority: 5, isDefault: false, deletedAt: null },
];
expect(pickTemplateForDay(tied, 2)?.id).toBe("narrow");
});
it("falls back to default when no day match", () => {
expect(pickTemplateForDay(templates, 0)?.id).toBe("weekday");
});
it("ignores deleted templates", () => {
const onlyDeleted = templates.filter((t) => t.id === "deleted");
expect(pickTemplateForDay(onlyDeleted, 2)).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
export type TemplateCandidate = {
id: string;
daysOfWeek: number[];
sortPriority: number;
isDefault: boolean;
deletedAt: Date | null;
};
export function pickTemplateForDay(
templates: TemplateCandidate[],
dayOfWeek: number
): TemplateCandidate | null {
const active = templates.filter((t) => t.deletedAt === null);
const matching = active
.filter((t) => t.daysOfWeek.includes(dayOfWeek))
.sort(
(a, b) =>
b.sortPriority - a.sortPriority || a.daysOfWeek.length - b.daysOfWeek.length
);
if (matching.length > 0) return matching[0];
return active.find((t) => t.isDefault) ?? active[0] ?? null;
}

View 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/domain/src/chapters.ts Executable file
View 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;
}

View 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);
});
});

View File

@@ -0,0 +1,42 @@
import { format, subDays, parseISO, startOfWeek } from "date-fns";
/** Calendar date string for the current moment given a day-boundary hour (05). */
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. midnight4am). */
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;
}
/** Monday-start week containing the given date (yyyy-MM-dd). */
export function weekStartForDate(date: Date = new Date()): string {
return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd");
}

15
packages/domain/src/index.ts Executable file
View File

@@ -0,0 +1,15 @@
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";
export * from "./adventure/derive-state";
export * from "./adventure/template-matching";
export * from "./adventure/semantic-key";
export * from "./reflection";
export { weekStartForDate } from "./day-boundary";

View 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/domain/src/levels.ts Executable file
View 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 };
}

124
packages/domain/src/memory.ts Executable file
View File

@@ -0,0 +1,124 @@
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[] = [
"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: true,
maxPendingSuggestions: 10,
minDaysBetweenNudges: 3,
allowSensitiveCategories: false,
};
export const DEFAULT_DAY_BOUNDARY_HOUR = 0;

View 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);
}

View File

@@ -0,0 +1,18 @@
import type { ReflectionData } from "../types";
export type { ReflectionData };
export function hasMeaningfulReflectionContent(data: ReflectionData): boolean {
return [data.wentWell, data.learned, data.improveTomorrow].some(
(field) => field.trim().length > 0
);
}
export function shouldAwardReflectionXp(
existing: ReflectionData | null | undefined,
data: ReflectionData
): boolean {
if (!hasMeaningfulReflectionContent(data)) return false;
if (!existing) return true;
return !hasMeaningfulReflectionContent(existing);
}

View 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/domain/src/scores.ts Executable file
View 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/domain/src/titles.ts Executable file
View 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";

283
packages/domain/src/types.ts Executable file
View File

@@ -0,0 +1,283 @@
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 type TeacherDifficulty = "beginner" | "intermediate" | "advanced";
export type TeacherLength = "short" | "standard" | "deep";
export interface TeacherResearchAssignment {
steps: string[];
expectedOutcome?: string;
estimatedTimeMinutes?: number;
}
export interface TeacherHomework {
task: string;
instructions: string[];
}
export interface TeacherCalibreSuggestion {
title: string;
authors: string[];
reason: string;
}
export interface TeacherQuizQuestion {
question: string;
type?: "multiple_choice" | "short_answer";
options?: string[];
/** 0-based index for multiple choice (legacy + normalized MC) */
answer: number;
answerText?: string;
explanation?: string;
}
export interface TeacherAnswerKeyEntry {
questionIndex: number;
answer: string;
explanation?: string;
}
export interface TeacherLessonContent {
title?: string;
/** Alias kept for legacy stored lessons */
introduction?: string;
overview?: string;
whyItMatters?: string;
/** Alias kept for legacy stored lessons */
objectives?: string[];
learningObjectives?: string[];
explanation?: string;
researchAssignment?: TeacherResearchAssignment;
readingSteps?: string[];
calibreSuggestions?: TeacherCalibreSuggestion[];
externalReading?: string[];
homework?: TeacherHomework;
flashcards: { front: string; back: string }[];
quiz: TeacherQuizQuestion[];
assignment?: string;
reflectionPrompt?: string;
answerKey?: TeacherAnswerKeyEntry[];
nextLessonSuggestion?: string;
}
export interface TeacherLessonData {
id: string;
topic: string;
content: TeacherLessonContent;
status: string;
explorationId?: string | null;
completedNote?: string | null;
createdAt: string;
source?: "ai" | "fallback";
fallbackReason?:
| "offline"
| "model_unavailable"
| "parse_failed"
| "generation_failed"
| "timeout";
}

39
packages/domain/src/xp.test.ts Executable file
View 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/domain/src/xp.ts Executable file
View 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;
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});

View File

@@ -10,6 +10,9 @@
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@adventureos/domain": "*"
},
"devDependencies": {
"vitest": "^3.2.6"
}

View File

@@ -1,10 +1 @@
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";
export * from "@adventureos/domain";