@@ -20,3 +20,6 @@ CALIBRE_READ_ONLY=true
|
||||
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# LLAMACPP_API_KEY=...
|
||||
|
||||
# Optional Obsidian/resource backlog export path
|
||||
# RESOURCE_BACKLOG_PATH=/path/to/vault/resources.md
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -16,4 +16,6 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
|
||||
@@ -35,7 +35,9 @@ npm run db:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3060 — default password: `adventure` (set `AUTH_PASSWORD` in `.env`).
|
||||
Open http://localhost:3000 — default password: `adventure` (set `AUTH_PASSWORD` in `.env`).
|
||||
|
||||
Docker production serves the app at http://localhost:3060.
|
||||
|
||||
### Production (Docker)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"public/sw.js",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adventureos/application": "*",
|
||||
"@adventureos/db": "*",
|
||||
"@adventureos/domain": "*",
|
||||
"@adventureos/shared": "*",
|
||||
"@serwist/next": "^9.0.12",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
|
||||
2
apps/web/public/sw.js
Executable file → Normal file
2
apps/web/public/sw.js
Executable file → Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type {
|
||||
ActionEventsRepository,
|
||||
ActionEventRecord,
|
||||
RecordActionInput,
|
||||
} from "@adventureos/application";
|
||||
import { db, actionEvents } from "@/lib/db";
|
||||
|
||||
function mapActionEvent(row: typeof actionEvents.$inferSelect): ActionEventRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
actionType: row.actionType,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
summary: row.summary,
|
||||
beforeState: row.beforeState as Record<string, unknown>,
|
||||
afterState: row.afterState as Record<string, unknown>,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? null,
|
||||
createdAt: row.createdAt,
|
||||
undoneAt: row.undoneAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const drizzleActionEventsRepository: ActionEventsRepository = {
|
||||
async recordAction(input: RecordActionInput) {
|
||||
const [row] = await db
|
||||
.insert(actionEvents)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
actionType: input.actionType,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: input.summary,
|
||||
beforeState: input.beforeState,
|
||||
afterState: input.afterState,
|
||||
metadata: input.metadata ?? null,
|
||||
})
|
||||
.returning();
|
||||
return mapActionEvent(row);
|
||||
},
|
||||
|
||||
async getActionEvent(id, userId) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(actionEvents)
|
||||
.where(and(eq(actionEvents.id, id), eq(actionEvents.userId, userId)));
|
||||
return row ? mapActionEvent(row) : null;
|
||||
},
|
||||
|
||||
async markUndone(id) {
|
||||
await db.update(actionEvents).set({ undoneAt: new Date() }).where(eq(actionEvents.id, id));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type {
|
||||
AdventureRepository,
|
||||
AdventureTemplateItemRecord,
|
||||
AdventureTemplateRecord,
|
||||
DailyAdventureItemRecord,
|
||||
DailyAdventureRecord,
|
||||
DailyTodoRecord,
|
||||
} from "@adventureos/application";
|
||||
import type { AdventureItemState } from "@adventureos/domain";
|
||||
import {
|
||||
db,
|
||||
adventureItems,
|
||||
adventureTemplates,
|
||||
dailyAdventureItems,
|
||||
dailyAdventures,
|
||||
dailyTodos,
|
||||
} from "@/lib/db";
|
||||
|
||||
function mapAdventure(row: typeof dailyAdventures.$inferSelect): DailyAdventureRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
date: row.date,
|
||||
templateId: row.templateId,
|
||||
isRestDay: row.isRestDay,
|
||||
isCustomized: row.isCustomized,
|
||||
workHoursTarget: row.workHoursTarget,
|
||||
dayMode: row.dayMode,
|
||||
};
|
||||
}
|
||||
|
||||
function mapItem(row: typeof dailyAdventureItems.$inferSelect): DailyAdventureItemRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
dailyAdventureId: row.dailyAdventureId,
|
||||
type: row.type,
|
||||
label: row.label,
|
||||
config: (row.config as Record<string, unknown>) ?? null,
|
||||
value: (row.value as Record<string, unknown>) ?? null,
|
||||
state: row.state as AdventureItemState,
|
||||
completedAt: row.completedAt,
|
||||
enabled: row.enabled,
|
||||
isCustom: row.isCustom,
|
||||
sortOrder: row.sortOrder,
|
||||
sourceItemId: row.sourceItemId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTodo(row: typeof dailyTodos.$inferSelect): DailyTodoRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
dailyAdventureId: row.dailyAdventureId,
|
||||
label: row.label,
|
||||
done: row.done,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTemplate(row: typeof adventureTemplates.$inferSelect): AdventureTemplateRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
sortPriority: row.sortPriority,
|
||||
isDefault: row.isDefault,
|
||||
deletedAt: row.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTemplateItem(row: typeof adventureItems.$inferSelect): AdventureTemplateItemRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
templateId: row.templateId,
|
||||
type: row.type,
|
||||
label: row.label,
|
||||
config: (row.config as Record<string, unknown>) ?? null,
|
||||
sortOrder: row.sortOrder,
|
||||
enabled: row.enabled,
|
||||
deletedAt: row.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const drizzleAdventureRepository: AdventureRepository = {
|
||||
async findDailyAdventure(userId, date) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(dailyAdventures)
|
||||
.where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, date)));
|
||||
return row ? mapAdventure(row) : null;
|
||||
},
|
||||
|
||||
async createDailyAdventure(userId, date, templateId = null) {
|
||||
const [row] = await db
|
||||
.insert(dailyAdventures)
|
||||
.values({ userId, date, templateId })
|
||||
.returning();
|
||||
return mapAdventure(row);
|
||||
},
|
||||
|
||||
async listDailyAdventureItems(adventureId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(dailyAdventureItems)
|
||||
.where(
|
||||
and(
|
||||
eq(dailyAdventureItems.dailyAdventureId, adventureId),
|
||||
isNull(dailyAdventureItems.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(dailyAdventureItems.sortOrder);
|
||||
return rows.map(mapItem);
|
||||
},
|
||||
|
||||
async listDailyTodos(adventureId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(dailyTodos)
|
||||
.where(and(eq(dailyTodos.dailyAdventureId, adventureId), isNull(dailyTodos.deletedAt)))
|
||||
.orderBy(dailyTodos.sortOrder);
|
||||
return rows.map(mapTodo);
|
||||
},
|
||||
|
||||
async updateDailyAdventureItem(itemId, updates) {
|
||||
await db.update(dailyAdventureItems).set(updates).where(eq(dailyAdventureItems.id, itemId));
|
||||
},
|
||||
|
||||
async softDeleteDailyAdventureItems(adventureId) {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyAdventureItems.dailyAdventureId, adventureId));
|
||||
},
|
||||
|
||||
async insertDailyAdventureItem(adventureId, item) {
|
||||
const [row] = await db
|
||||
.insert(dailyAdventureItems)
|
||||
.values({
|
||||
dailyAdventureId: adventureId,
|
||||
sourceItemId: item.sourceItemId,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: item.config ?? {},
|
||||
value: item.value ?? {},
|
||||
state: item.state,
|
||||
completedAt: item.completedAt,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled,
|
||||
isCustom: item.isCustom,
|
||||
})
|
||||
.returning();
|
||||
return mapItem(row);
|
||||
},
|
||||
|
||||
async updateDailyAdventure(adventureId, updates) {
|
||||
await db.update(dailyAdventures).set(updates).where(eq(dailyAdventures.id, adventureId));
|
||||
},
|
||||
|
||||
async listTemplates(userId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
|
||||
return rows.map(mapTemplate);
|
||||
},
|
||||
|
||||
async listTemplateItems(templateId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
return rows.map(mapTemplateItem);
|
||||
},
|
||||
|
||||
async findTemplate(userId, templateId) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
|
||||
return row ? mapTemplate(row) : null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ScoringService } from "@adventureos/application";
|
||||
import { buildDaySnapshot, refreshScores } from "@/lib/services/adventure/scoring";
|
||||
|
||||
export const drizzleScoringService: ScoringService = {
|
||||
buildDaySnapshot,
|
||||
refreshScores,
|
||||
};
|
||||
71
apps/web/src/adapters/persistence/drizzle-xp.repository.ts
Normal file
71
apps/web/src/adapters/persistence/drizzle-xp.repository.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { and, eq, gte, lte, sum } from "drizzle-orm";
|
||||
import type { XpRepository, XpEventRecord, UserProgressRecord } from "@adventureos/application";
|
||||
import type { XpSource } from "@adventureos/domain";
|
||||
import { db, userProgress, xpEvents } from "@/lib/db";
|
||||
|
||||
function mapProgress(row: typeof userProgress.$inferSelect): UserProgressRecord {
|
||||
return {
|
||||
userId: row.userId,
|
||||
totalXp: row.totalXp,
|
||||
level: row.level,
|
||||
currentChapter: row.currentChapter,
|
||||
consistencyScore: row.consistencyScore,
|
||||
disciplineScore: row.disciplineScore,
|
||||
learningScore: row.learningScore,
|
||||
spiritualScore: row.spiritualScore,
|
||||
healthScore: row.healthScore,
|
||||
readingScore: row.readingScore,
|
||||
};
|
||||
}
|
||||
|
||||
function mapXpEvent(row: typeof xpEvents.$inferSelect): XpEventRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
date: row.date,
|
||||
source: row.source as XpSource,
|
||||
amount: row.amount,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const drizzleXpRepository: XpRepository = {
|
||||
async getDailyXpEarned(userId, date, source) {
|
||||
const conditions = [eq(xpEvents.userId, userId), eq(xpEvents.date, date)];
|
||||
if (source) conditions.push(eq(xpEvents.source, source));
|
||||
const [row] = await db
|
||||
.select({ total: sum(xpEvents.amount) })
|
||||
.from(xpEvents)
|
||||
.where(and(...conditions));
|
||||
return Number(row?.total ?? 0);
|
||||
},
|
||||
|
||||
async insertXpEvent(event) {
|
||||
const [row] = await db.insert(xpEvents).values(event).returning();
|
||||
return mapXpEvent(row);
|
||||
},
|
||||
|
||||
async getUserProgress(userId) {
|
||||
const [row] = await db.select().from(userProgress).where(eq(userProgress.userId, userId));
|
||||
return row ? mapProgress(row) : null;
|
||||
},
|
||||
|
||||
async updateUserProgress(userId, updates) {
|
||||
await db.update(userProgress).set(updates).where(eq(userProgress.userId, userId));
|
||||
},
|
||||
|
||||
async listXpEventsByDateAndSource(userId, date, source) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(eq(xpEvents.userId, userId), eq(xpEvents.date, date), eq(xpEvents.source, source))
|
||||
);
|
||||
return rows.map(mapXpEvent);
|
||||
},
|
||||
|
||||
async listAllXpEvents(userId) {
|
||||
const rows = await db.select().from(xpEvents).where(eq(xpEvents.userId, userId));
|
||||
return rows.map(mapXpEvent);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getDashboard } from "@/lib/services/dashboard";
|
||||
import { getDashboardForUser } from "@/lib/services/dashboard";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const date = searchParams.get("date") ?? undefined;
|
||||
return handleApi(() => getDashboard(date));
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getDashboardForUser(user.id, date);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ export async function GET() {
|
||||
const aiData = await exportMemories(user.id);
|
||||
const tables = {
|
||||
user: await db.select().from(schema.users).where(eq(schema.users.id, user.id)),
|
||||
progress: await db.select().from(schema.userProgress),
|
||||
templates: await db.select().from(schema.adventureTemplates),
|
||||
books: await db.select().from(schema.books),
|
||||
achievements: await db.select().from(schema.achievements),
|
||||
reflections: await db.select().from(schema.reflections),
|
||||
progress: await db.select().from(schema.userProgress).where(eq(schema.userProgress.userId, user.id)),
|
||||
templates: await db.select().from(schema.adventureTemplates).where(eq(schema.adventureTemplates.userId, user.id)),
|
||||
books: await db.select().from(schema.books).where(eq(schema.books.userId, user.id)),
|
||||
achievements: await db.select().from(schema.achievements).where(eq(schema.achievements.userId, user.id)),
|
||||
reflections: await db.select().from(schema.reflections).where(eq(schema.reflections.userId, user.id)),
|
||||
aiMemories: aiData.memories,
|
||||
aiProfileSummary: aiData.summary,
|
||||
aiMemoryLearning: aiData.learning,
|
||||
|
||||
@@ -8,14 +8,9 @@ import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/use
|
||||
import Link from "next/link";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
|
||||
const SUGGESTED = [
|
||||
"What should I focus on today?",
|
||||
"Why do I keep falling off my reading?",
|
||||
"Give me a gentle plan for tomorrow.",
|
||||
"What have I been worried about recently?",
|
||||
"What do you know about me?",
|
||||
"What goals am I working towards?",
|
||||
];
|
||||
import { MENTOR_SUGGESTED_PROMPTS } from "@/lib/constants/mentor-prompts";
|
||||
|
||||
const SUGGESTED = MENTOR_SUGGESTED_PROMPTS;
|
||||
|
||||
type MemorySuggestion = {
|
||||
id: string;
|
||||
|
||||
@@ -5,13 +5,9 @@ import Link from "next/link";
|
||||
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
|
||||
import { createChatSession, useMentorChat } from "@/hooks/useMentorChat";
|
||||
|
||||
const SUGGESTED = [
|
||||
"What should I focus on today?",
|
||||
"Why do I keep falling off my reading?",
|
||||
"Give me a gentle plan for tomorrow.",
|
||||
"What do you know about me?",
|
||||
"What goals am I working towards?",
|
||||
];
|
||||
import { MENTOR_SUGGESTED_PROMPTS } from "@/lib/constants/mentor-prompts";
|
||||
|
||||
const SUGGESTED = MENTOR_SUGGESTED_PROMPTS.slice(0, 5);
|
||||
|
||||
export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
8
apps/web/src/infrastructure/composition.test.ts
Normal file
8
apps/web/src/infrastructure/composition.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { updateAdventureItemViaUseCase } from "@/infrastructure/composition";
|
||||
|
||||
describe("composition root", () => {
|
||||
it("exports updateAdventureItemViaUseCase", () => {
|
||||
expect(typeof updateAdventureItemViaUseCase).toBe("function");
|
||||
});
|
||||
});
|
||||
33
apps/web/src/infrastructure/composition.ts
Normal file
33
apps/web/src/infrastructure/composition.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
updateAdventureItem as updateAdventureItemUseCase,
|
||||
type UpdateAdventureItemDeps,
|
||||
} from "@adventureos/application";
|
||||
import { drizzleActionEventsRepository } from "@/adapters/persistence/drizzle-action-events.repository";
|
||||
import { drizzleAdventureRepository } from "@/adapters/persistence/drizzle-adventure.repository";
|
||||
import { drizzleScoringService } from "@/adapters/persistence/drizzle-scoring.service";
|
||||
import { drizzleXpRepository } from "@/adapters/persistence/drizzle-xp.repository";
|
||||
import { dayOfWeek as getDayOfWeek } from "@/lib/dates";
|
||||
|
||||
const adventureDeps: UpdateAdventureItemDeps = {
|
||||
adventureRepository: drizzleAdventureRepository,
|
||||
xpRepository: drizzleXpRepository,
|
||||
actionEventsRepository: drizzleActionEventsRepository,
|
||||
scoringService: drizzleScoringService,
|
||||
};
|
||||
|
||||
export async function updateAdventureItemViaUseCase(
|
||||
userId: string,
|
||||
date: string,
|
||||
itemId: string,
|
||||
updates: Parameters<typeof updateAdventureItemUseCase>[1]["updates"]
|
||||
) {
|
||||
return updateAdventureItemUseCase(adventureDeps, {
|
||||
userId,
|
||||
date,
|
||||
itemId,
|
||||
dayOfWeek: getDayOfWeek(date),
|
||||
updates,
|
||||
});
|
||||
}
|
||||
|
||||
export { adventureDeps };
|
||||
1
apps/web/src/infrastructure/env.ts
Normal file
1
apps/web/src/infrastructure/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { env } from "@/lib/config/env";
|
||||
8
apps/web/src/lib/constants/mentor-prompts.ts
Normal file
8
apps/web/src/lib/constants/mentor-prompts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const MENTOR_SUGGESTED_PROMPTS = [
|
||||
"What should I focus on today?",
|
||||
"Why do I keep falling off my reading?",
|
||||
"Give me a gentle plan for tomorrow.",
|
||||
"What have I been worried about recently?",
|
||||
"What do you know about me?",
|
||||
"What goals am I working towards?",
|
||||
] as const;
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
getLogicalToday,
|
||||
getLogicalYesterday,
|
||||
isWithinGraceWindow,
|
||||
weekStartForDate,
|
||||
DEFAULT_DAY_BOUNDARY_HOUR,
|
||||
} from "@adventureos/shared";
|
||||
} from "@adventureos/domain";
|
||||
|
||||
export function todayString(): string {
|
||||
return format(new Date(), "yyyy-MM-dd");
|
||||
@@ -17,9 +18,5 @@ export function formatDisplayDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
export function weekStartString(date = new Date()): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return format(d, "yyyy-MM-dd");
|
||||
return weekStartForDate(date);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { format, subDays, startOfWeek, parseISO } from "date-fns";
|
||||
import { format, subDays, parseISO } from "date-fns";
|
||||
import {
|
||||
getLogicalToday as sharedLogicalToday,
|
||||
getLogicalYesterday as sharedLogicalYesterday,
|
||||
isWithinGraceWindow as sharedGraceWindow,
|
||||
weekStartForDate,
|
||||
DEFAULT_DAY_BOUNDARY_HOUR,
|
||||
} from "@adventureos/shared";
|
||||
} from "@adventureos/domain";
|
||||
|
||||
export function todayString(): string {
|
||||
return format(new Date(), "yyyy-MM-dd");
|
||||
@@ -31,7 +32,7 @@ export function formatDisplayDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
export function weekStartString(date = new Date()): string {
|
||||
return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd");
|
||||
return weekStartForDate(date);
|
||||
}
|
||||
|
||||
export function lastNDays(n: number): string[] {
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
export type ReflectionData = {
|
||||
wentWell: string;
|
||||
learned: string;
|
||||
improveTomorrow: string;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
export {
|
||||
hasMeaningfulReflectionContent,
|
||||
shouldAwardReflectionXp,
|
||||
type ReflectionData,
|
||||
} from "@adventureos/domain/reflection";
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { db, adventureTemplates, adventureItems } from "@/lib/db";
|
||||
|
||||
export async function listTemplates(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)))
|
||||
.orderBy(desc(adventureTemplates.sortPriority));
|
||||
}
|
||||
|
||||
export async function findTemplateById(templateId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(eq(adventureTemplates.id, templateId));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function listTemplateItems(templateId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./settings.repository";
|
||||
export * from "./teacher.repository";
|
||||
export * from "./adventure-templates.repository";
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
xpForAdventureState,
|
||||
type AdventureItemState,
|
||||
type DaySnapshot,
|
||||
XP_AWARDS,
|
||||
computeAllScores,
|
||||
} from "@adventureos/shared";
|
||||
import {
|
||||
db,
|
||||
dailyAdventureItems,
|
||||
dailyAdventures,
|
||||
dailyTodos,
|
||||
userProgress,
|
||||
readingLogs,
|
||||
books,
|
||||
xpEvents,
|
||||
} from "@/lib/db";
|
||||
import { lastNDays } from "@/lib/dates";
|
||||
import { awardXp } from "@/lib/services/xp";
|
||||
import { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
import { deriveState } from "./derive-state";
|
||||
import { getDailyAdventure, materializeDay } from "./materialization";
|
||||
import { refreshScores } from "./scoring";
|
||||
import { updateAdventureItemViaUseCase } from "@/infrastructure/composition";
|
||||
|
||||
export async function updateAdventureItem(
|
||||
userId: string,
|
||||
@@ -30,71 +21,7 @@ export async function updateAdventureItem(
|
||||
itemId: string,
|
||||
updates: { value?: Record<string, unknown>; state?: AdventureItemState }
|
||||
) {
|
||||
const { items } = await getDailyAdventure(userId, date);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const newValue = { ...(item.value as Record<string, unknown>), ...updates.value };
|
||||
const newState =
|
||||
updates.state ?? deriveState(item.type, newValue, item.config as Record<string, unknown>);
|
||||
|
||||
const oldState = item.state as AdventureItemState;
|
||||
const beforeState = {
|
||||
value: item.value,
|
||||
state: item.state,
|
||||
completedAt: item.completedAt,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({
|
||||
value: newValue,
|
||||
state: newState,
|
||||
completedAt: newState === "done" ? new Date() : null,
|
||||
})
|
||||
.where(eq(dailyAdventureItems.id, itemId));
|
||||
|
||||
const xpEventIds: string[] = [];
|
||||
|
||||
if (newState !== oldState && newState !== "blank") {
|
||||
const xp = xpForAdventureState(newState);
|
||||
if (xp > 0) {
|
||||
const result = await awardXp(userId, date, "adventure_item", xp, {
|
||||
itemId,
|
||||
state: newState,
|
||||
});
|
||||
if (result.xpEventId) xpEventIds.push(result.xpEventId);
|
||||
}
|
||||
if (item.type === "checkbox" && item.label.toLowerCase().includes("exercise") && newState === "done") {
|
||||
const ex = await awardXp(userId, date, "exercise", XP_AWARDS.exercise, { itemId });
|
||||
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
|
||||
}
|
||||
if (item.type === "checklist") {
|
||||
const oldChecks = ((item.value as Record<string, unknown>).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(userId, date, "spiritual", added * XP_AWARDS.spiritual_per_check, {
|
||||
itemId,
|
||||
});
|
||||
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureItemUpdate,
|
||||
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 refreshScores(userId);
|
||||
return { state: newState, value: newValue, actionEventId: actionEvent.id };
|
||||
return updateAdventureItemViaUseCase(userId, date, itemId, updates);
|
||||
}
|
||||
|
||||
export async function setRestDay(userId: string, date: string) {
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
import type { AdventureItemState } from "@adventureos/shared";
|
||||
|
||||
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";
|
||||
}
|
||||
export { deriveState } from "@adventureos/domain/adventure/derive-state";
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./materialization";
|
||||
export * from "./daily";
|
||||
export * from "./scoring";
|
||||
export * from "./templates";
|
||||
11
apps/web/src/lib/services/adventure/scoring.test.ts
Normal file
11
apps/web/src/lib/services/adventure/scoring.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { matchesSemanticKey } from "@adventureos/domain";
|
||||
|
||||
describe("scoring label heuristics characterization", () => {
|
||||
it("maps seed template labels to semantic keys", () => {
|
||||
expect(matchesSemanticKey({ type: "checkbox", label: "Exercise" }, "exercise")).toBe(true);
|
||||
expect(matchesSemanticKey({ type: "checklist", label: "Prayer" }, "prayer")).toBe(true);
|
||||
expect(matchesSemanticKey({ type: "checklist", label: "Litanies" }, "litanies")).toBe(true);
|
||||
expect(matchesSemanticKey({ type: "duration", label: "Work" }, "work")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { computeAllScores, type DaySnapshot } from "@adventureos/shared";
|
||||
import { computeAllScores, matchesSemanticKey, type DaySnapshot } from "@adventureos/domain";
|
||||
import { db, userProgress, readingLogs, books, xpEvents } from "@/lib/db";
|
||||
import { lastNDays } from "@/lib/dates";
|
||||
import { getDailyAdventure } from "./materialization";
|
||||
@@ -10,19 +10,13 @@ export async function buildDaySnapshot(userId: string, date: string): Promise<Da
|
||||
const scorable = items.filter((i) => i.type !== "note" && i.enabled);
|
||||
const touched = scorable.filter((i) => i.state !== "blank").length;
|
||||
|
||||
const exerciseItem = items.find(
|
||||
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise")
|
||||
);
|
||||
const workItem = items.find((i) => i.type === "duration");
|
||||
const teachingItem = items.find(
|
||||
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("teaching")
|
||||
);
|
||||
const classItems = items.filter(
|
||||
(i) => i.type === "timeblock" || i.label.toLowerCase().includes("class")
|
||||
);
|
||||
const exerciseItem = items.find((i) => matchesSemanticKey(i, "exercise"));
|
||||
const workItem = items.find((i) => matchesSemanticKey(i, "work"));
|
||||
const teachingItem = items.find((i) => matchesSemanticKey(i, "teaching"));
|
||||
const classItems = items.filter((i) => matchesSemanticKey(i, "class"));
|
||||
|
||||
const prayerItem = items.find((i) => i.label === "Prayer");
|
||||
const litanyItem = items.find((i) => i.label === "Litanies");
|
||||
const prayerItem = items.find((i) => matchesSemanticKey(i, "prayer"));
|
||||
const litanyItem = items.find((i) => matchesSemanticKey(i, "litanies"));
|
||||
|
||||
const prayerChecks =
|
||||
((prayerItem?.value as Record<string, unknown>)?.checks as boolean[])?.filter(Boolean)
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
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;
|
||||
}
|
||||
export {
|
||||
pickTemplateForDay,
|
||||
type TemplateCandidate,
|
||||
} from "@adventureos/domain/adventure/template-matching";
|
||||
|
||||
@@ -14,13 +14,22 @@ vi.mock("./ai-templates", () => ({
|
||||
getTemplateBody: vi.fn(async () => "topic: {{topic}} difficulty: {{difficulty}}"),
|
||||
}));
|
||||
|
||||
vi.mock("../db", () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ displayName: "Traveler" }]),
|
||||
limit: vi.fn().mockResolvedValue([{ id: "user-1" }]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
users: {},
|
||||
}));
|
||||
|
||||
vi.mock("../ai/provider-registry", () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./user", () => ({
|
||||
requireUser: vi.fn(async () => ({ user: { id: "user-1", displayName: "Traveler" } })),
|
||||
}));
|
||||
|
||||
import { getAiBehaviorConfig, getAiProviderConfig, getAiProviderSettings, generateTextWithFallback } from "./ai-config";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
@@ -28,9 +37,10 @@ import { getProvider } from "../ai/provider-registry";
|
||||
const onlineAvailability = {
|
||||
enabled: true,
|
||||
creativity: 0.7,
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
personality: "supportive_mentor" as const,
|
||||
verbosity: "balanced" as const,
|
||||
strictMode: false,
|
||||
frequency: "daily" as const,
|
||||
};
|
||||
|
||||
const onlineProviderConfig = {
|
||||
@@ -130,7 +140,19 @@ describe("generateTeacherContent", () => {
|
||||
});
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
healthCheck: vi.fn(async () => ({
|
||||
status: "online" as const,
|
||||
provider: "ollama" as const,
|
||||
model: "test",
|
||||
latencyMs: 1,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: null,
|
||||
lastError: null,
|
||||
baseUrlSafe: null,
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
})),
|
||||
listModels: vi.fn(async () => []),
|
||||
generateText: vi.fn(),
|
||||
});
|
||||
@@ -170,7 +192,19 @@ describe("generateTeacherContent", () => {
|
||||
});
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
healthCheck: vi.fn(async () => ({
|
||||
status: "online" as const,
|
||||
provider: "ollama" as const,
|
||||
model: "test",
|
||||
latencyMs: 1,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: null,
|
||||
lastError: null,
|
||||
baseUrlSafe: null,
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
})),
|
||||
listModels: vi.fn(async () => []),
|
||||
generateText: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pickRandomExplorations,
|
||||
pickRandomQuests,
|
||||
} from "@adventureos/shared";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
import type { TeacherDifficulty, TeacherLength } from "@adventureos/shared";
|
||||
import {
|
||||
debugAiLog,
|
||||
isAiTimeoutError,
|
||||
@@ -24,123 +22,51 @@ import {
|
||||
} from "./ai-config";
|
||||
import { getTemplateBody } from "./ai-templates";
|
||||
import { renderTemplate } from "../ai/prompts/render";
|
||||
import { requireUser } from "./user";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, users } from "../db";
|
||||
import {
|
||||
explorationSchema,
|
||||
mentorSchema,
|
||||
questSchema,
|
||||
teacherSchema,
|
||||
TeacherAiUnavailableError,
|
||||
type AiFallbackReason,
|
||||
type ExplorationGenerationResult,
|
||||
type TeacherContent,
|
||||
type TeacherGenerationOptions,
|
||||
type TeacherGenerationResult,
|
||||
} from "./ai/schemas";
|
||||
|
||||
const questSchema = z.object({
|
||||
quests: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
reason: z.string(),
|
||||
xp_hint: z.string(),
|
||||
category: z.string(),
|
||||
})
|
||||
)
|
||||
.max(3),
|
||||
});
|
||||
export {
|
||||
explorationSchema,
|
||||
mentorSchema,
|
||||
questSchema,
|
||||
teacherSchema,
|
||||
TeacherAiUnavailableError,
|
||||
type AiContentSource,
|
||||
type AiFallbackReason,
|
||||
type ExplorationGenerationResult,
|
||||
type TeacherContent,
|
||||
type TeacherGenerationOptions,
|
||||
type TeacherGenerationResult,
|
||||
} from "./ai/schemas";
|
||||
|
||||
const explorationSchema = z.object({
|
||||
explorations: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
hook: z.string(),
|
||||
category: z.string(),
|
||||
minutes: z.coerce.number(),
|
||||
})
|
||||
)
|
||||
.max(5),
|
||||
});
|
||||
|
||||
const mentorSchema = z.object({
|
||||
patterns: z.array(z.string()),
|
||||
encouragement: z.string(),
|
||||
focus_suggestion: z.string(),
|
||||
letter: z.string(),
|
||||
});
|
||||
|
||||
const teacherResearchAssignmentSchema = z.object({
|
||||
steps: z.array(z.string()),
|
||||
expectedOutcome: z.string().optional(),
|
||||
estimatedTimeMinutes: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
const teacherHomeworkSchema = z.object({
|
||||
task: z.string(),
|
||||
instructions: z.array(z.string()),
|
||||
});
|
||||
|
||||
const teacherCalibreSuggestionSchema = z.object({
|
||||
title: z.string(),
|
||||
authors: z.array(z.string()),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const teacherSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
introduction: z.string().optional(),
|
||||
overview: z.string().optional(),
|
||||
whyItMatters: z.string().optional(),
|
||||
objectives: z.array(z.string()).optional(),
|
||||
learningObjectives: z.array(z.string()).optional(),
|
||||
explanation: z.string().optional(),
|
||||
researchAssignment: teacherResearchAssignmentSchema.optional(),
|
||||
readingSteps: z.array(z.string()).optional(),
|
||||
calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(),
|
||||
externalReading: z.array(z.string()).optional(),
|
||||
homework: teacherHomeworkSchema.optional(),
|
||||
reflectionPrompt: z.string().optional(),
|
||||
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
||||
quiz: z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
type: z.enum(["multiple_choice", "short_answer"]).optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
answer: z.coerce.number(),
|
||||
answerText: z.string().optional(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
),
|
||||
answerKey: z
|
||||
.array(
|
||||
z.object({
|
||||
questionIndex: z.coerce.number(),
|
||||
answer: z.string(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
nextLessonSuggestion: z.string().optional(),
|
||||
assignment: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TeacherContent = z.infer<typeof teacherSchema>;
|
||||
export type AiContentSource = "ai" | "fallback";
|
||||
export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
|
||||
|
||||
export type TeacherGenerationResult = {
|
||||
content: TeacherContent;
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
|
||||
export type ExplorationGenerationResult = {
|
||||
explorations: z.infer<typeof explorationSchema>["explorations"];
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
async function getDisplayName(userId: string): Promise<string> {
|
||||
const [user] = await db
|
||||
.select({ displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
return user?.displayName ?? "Traveler";
|
||||
}
|
||||
|
||||
async function getUserId(): Promise<string> {
|
||||
const { user } = await requireUser();
|
||||
const [user] = await db.select({ id: users.id }).from(users).limit(1);
|
||||
if (!user) throw new Error("No user found. Run db:seed first.");
|
||||
return user.id;
|
||||
}
|
||||
|
||||
let lastAiFallbackReason: AiFallbackReason | null = null;
|
||||
|
||||
export function getLastAiFallbackReason(): AiFallbackReason | null {
|
||||
return lastAiFallbackReason;
|
||||
}
|
||||
|
||||
export async function getAiAvailability(userId: string) {
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
if (!behavior.enabled) return { canUse: false, online: false };
|
||||
@@ -187,10 +113,10 @@ async function aiGenerate(
|
||||
const coreSystem = await getTemplateBody(userId, systemKey);
|
||||
const roleSystem = roleKey ? await getTemplateBody(userId, roleKey) : undefined;
|
||||
const system = buildSystemPrompt(behavior, coreSystem, roleSystem);
|
||||
const { user } = await requireUser();
|
||||
const displayName = await getDisplayName(userId);
|
||||
const prompt = renderTemplate(templateBody, {
|
||||
...data,
|
||||
user_name: user.displayName,
|
||||
user_name: displayName,
|
||||
context: data.context ?? data,
|
||||
});
|
||||
|
||||
@@ -312,20 +238,6 @@ function teacherFallback(topic: string): TeacherContent {
|
||||
};
|
||||
}
|
||||
|
||||
export type TeacherGenerationOptions = {
|
||||
personalContext?: string;
|
||||
libraryContext?: string;
|
||||
difficulty?: TeacherDifficulty;
|
||||
length?: TeacherLength;
|
||||
};
|
||||
|
||||
export class TeacherAiUnavailableError extends Error {
|
||||
constructor(message = "AI is unavailable. Check Settings or your local model.") {
|
||||
super(message);
|
||||
this.name = "TeacherAiUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuests(context: Record<string, unknown>, userId?: string) {
|
||||
const uid = userId ?? (await getUserId());
|
||||
const raw = await aiGenerate(
|
||||
@@ -487,20 +399,16 @@ export async function generateTeacherContent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function isOllamaAvailable(): Promise<boolean> {
|
||||
export async function isOllamaAvailable(userId?: string): Promise<boolean> {
|
||||
try {
|
||||
const userId = await getUserId();
|
||||
const availability = await getAiAvailability(userId);
|
||||
const uid = userId ?? (await getUserId());
|
||||
const availability = await getAiAvailability(uid);
|
||||
return availability.online;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAiAvailable(): Promise<boolean> {
|
||||
return isOllamaAvailable();
|
||||
}
|
||||
|
||||
export async function generateChatReply(
|
||||
userId: string,
|
||||
input: { userMessage: string; contextBlock: string; history?: string; memoryAction?: string }
|
||||
|
||||
119
apps/web/src/lib/services/ai/schemas.ts
Normal file
119
apps/web/src/lib/services/ai/schemas.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const questSchema = z.object({
|
||||
quests: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
reason: z.string(),
|
||||
xp_hint: z.string(),
|
||||
category: z.string(),
|
||||
})
|
||||
)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
export const explorationSchema = z.object({
|
||||
explorations: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
hook: z.string(),
|
||||
category: z.string(),
|
||||
minutes: z.coerce.number(),
|
||||
})
|
||||
)
|
||||
.max(5),
|
||||
});
|
||||
|
||||
export const mentorSchema = z.object({
|
||||
patterns: z.array(z.string()),
|
||||
encouragement: z.string(),
|
||||
focus_suggestion: z.string(),
|
||||
letter: z.string(),
|
||||
});
|
||||
|
||||
const teacherResearchAssignmentSchema = z.object({
|
||||
steps: z.array(z.string()),
|
||||
expectedOutcome: z.string().optional(),
|
||||
estimatedTimeMinutes: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
const teacherHomeworkSchema = z.object({
|
||||
task: z.string(),
|
||||
instructions: z.array(z.string()),
|
||||
});
|
||||
|
||||
const teacherCalibreSuggestionSchema = z.object({
|
||||
title: z.string(),
|
||||
authors: z.array(z.string()),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const teacherSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
introduction: z.string().optional(),
|
||||
overview: z.string().optional(),
|
||||
whyItMatters: z.string().optional(),
|
||||
objectives: z.array(z.string()).optional(),
|
||||
learningObjectives: z.array(z.string()).optional(),
|
||||
explanation: z.string().optional(),
|
||||
researchAssignment: teacherResearchAssignmentSchema.optional(),
|
||||
readingSteps: z.array(z.string()).optional(),
|
||||
calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(),
|
||||
externalReading: z.array(z.string()).optional(),
|
||||
homework: teacherHomeworkSchema.optional(),
|
||||
reflectionPrompt: z.string().optional(),
|
||||
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
||||
quiz: z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
type: z.enum(["multiple_choice", "short_answer"]).optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
answer: z.coerce.number(),
|
||||
answerText: z.string().optional(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
),
|
||||
answerKey: z
|
||||
.array(
|
||||
z.object({
|
||||
questionIndex: z.coerce.number(),
|
||||
answer: z.string(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
nextLessonSuggestion: z.string().optional(),
|
||||
assignment: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TeacherContent = z.infer<typeof teacherSchema>;
|
||||
export type AiContentSource = "ai" | "fallback";
|
||||
export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
|
||||
|
||||
export type TeacherGenerationResult = {
|
||||
content: TeacherContent;
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
|
||||
export type ExplorationGenerationResult = {
|
||||
explorations: z.infer<typeof explorationSchema>["explorations"];
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
|
||||
export type TeacherGenerationOptions = {
|
||||
personalContext?: string;
|
||||
libraryContext?: string;
|
||||
difficulty?: import("@adventureos/shared").TeacherDifficulty;
|
||||
length?: import("@adventureos/shared").TeacherLength;
|
||||
};
|
||||
|
||||
export class TeacherAiUnavailableError extends Error {
|
||||
constructor(message = "AI is unavailable. Check Settings or your local model.") {
|
||||
super(message);
|
||||
this.name = "TeacherAiUnavailableError";
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { getSuggestions } from "./explorations";
|
||||
import { awardDailyVisit } from "./xp";
|
||||
import { getAchievements } from "./achievements";
|
||||
import { todayString, getLogicalToday, getLogicalYesterday, isWithinGraceWindow } from "../dates";
|
||||
import { db, settings } from "../db";
|
||||
import { db, settings, users, userProgress } from "../db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
import { getXpBySource, getXpInRange } from "./xp";
|
||||
@@ -16,8 +16,20 @@ import { getDayBoundaryHour } from "./day-boundary";
|
||||
import { detectCatchUpGaps } from "./catch-up";
|
||||
|
||||
export async function getDashboard(date?: string) {
|
||||
const { user, progress } = await requireUser();
|
||||
const boundaryHour = await getDayBoundaryHour(user.id);
|
||||
const { user } = await requireUser();
|
||||
return getDashboardForUser(user.id, date);
|
||||
}
|
||||
|
||||
export async function getDashboardForUser(userId: string, date?: string) {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
|
||||
const boundaryHour = await getDayBoundaryHour(userId);
|
||||
const logicalToday = getLogicalToday(boundaryHour);
|
||||
const activeDate = date ?? logicalToday;
|
||||
const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd");
|
||||
@@ -32,22 +44,22 @@ export async function getDashboard(date?: string) {
|
||||
catchUpGaps,
|
||||
goalRows,
|
||||
] = await Promise.all([
|
||||
getDailyAdventure(user.id, activeDate),
|
||||
getReflection(user.id, activeDate),
|
||||
getSuggestions(user.id, "quest_giver"),
|
||||
getBooks(user.id),
|
||||
getReadingStreak(user.id),
|
||||
getWeeklyPages(user.id, weekStart, activeDate),
|
||||
detectCatchUpGaps(user.id),
|
||||
getDailyAdventure(userId, activeDate),
|
||||
getReflection(userId, activeDate),
|
||||
getSuggestions(userId, "quest_giver"),
|
||||
getBooks(userId),
|
||||
getReadingStreak(userId),
|
||||
getWeeklyPages(userId, weekStart, activeDate),
|
||||
detectCatchUpGaps(userId),
|
||||
db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
),
|
||||
]);
|
||||
|
||||
void recordDashboardVisit(user.id, activeDate);
|
||||
void recordDashboardVisit(userId, activeDate);
|
||||
|
||||
const [goalRow] = goalRows;
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { and, eq, desc } from "drizzle-orm";
|
||||
import { subDays, format } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { XP_AWARDS } from "@adventureos/shared";
|
||||
import { db, explorations, aiSuggestions, weeklyReviews } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
import { generateExplorations, generateMentorReview, generateQuests } from "./ai";
|
||||
import { awardXp, getXpInRange } from "./xp";
|
||||
import { buildDaySnapshot, refreshScores } from "./adventure";
|
||||
import { refreshScores } from "./adventure";
|
||||
import { buildAiContext } from "./ai-context";
|
||||
import { requireUser } from "./user";
|
||||
import { recordAction } from "./action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
|
||||
@@ -71,14 +70,6 @@ export async function generateWeeklyExplorations(userId: string) {
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf)));
|
||||
|
||||
const suggested = existing.filter((e) => e.status === "suggested");
|
||||
const statusCounts = existing.reduce<Record<string, number>>((acc, e) => {
|
||||
acc[e.status] = (acc[e.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate guard check',data:{weekOf,statusCounts,suggestedCount:suggested.length},timestamp:Date.now(),hypothesisId:'C1'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
if (suggested.length > 0) {
|
||||
const visible = existing.filter((e) => e.status !== "dismissed");
|
||||
@@ -111,10 +102,6 @@ export async function generateWeeklyExplorations(userId: string) {
|
||||
created.push(row);
|
||||
}
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate complete',data:{source,generatedCount:generated.length,createdCount:created.length,filteredCount:items.length},timestamp:Date.now(),hypothesisId:'C3'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
const visible = [
|
||||
...existing.filter((e) => e.status !== "dismissed"),
|
||||
...created,
|
||||
|
||||
@@ -4,9 +4,7 @@ import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db, resourceLinks } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
|
||||
const RESOURCE_BACKLOG_PATH =
|
||||
process.env.RESOURCE_BACKLOG_PATH ??
|
||||
"/home/zaine/master-folder/vault-master/Non Technical/z resources.md";
|
||||
const RESOURCE_BACKLOG_PATH = process.env.RESOURCE_BACKLOG_PATH;
|
||||
|
||||
export const DEFAULT_RESOURCE_CATEGORY = "Interesting things to read up:";
|
||||
|
||||
@@ -143,7 +141,7 @@ export async function exportResourceLinksToBacklog(input: {
|
||||
markdown = appendLinksToSection(markdown, category, weekLabel, lines);
|
||||
}
|
||||
|
||||
await fs.writeFile(RESOURCE_BACKLOG_PATH, markdown, "utf8");
|
||||
await fs.writeFile(requireBacklogPath(), markdown, "utf8");
|
||||
await db
|
||||
.update(resourceLinks)
|
||||
.set({ status: "exported", exportedAt: new Date() })
|
||||
@@ -156,14 +154,22 @@ export async function exportResourceLinksToBacklog(input: {
|
||||
|
||||
return {
|
||||
exported: links.length,
|
||||
path: RESOURCE_BACKLOG_PATH,
|
||||
path: requireBacklogPath(),
|
||||
weekLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function requireBacklogPath(): string {
|
||||
if (!RESOURCE_BACKLOG_PATH) {
|
||||
throw new Error("RESOURCE_BACKLOG_PATH is not configured");
|
||||
}
|
||||
return RESOURCE_BACKLOG_PATH;
|
||||
}
|
||||
|
||||
async function readBacklog() {
|
||||
const path = requireBacklogPath();
|
||||
try {
|
||||
return await fs.readFile(RESOURCE_BACKLOG_PATH, "utf8");
|
||||
return await fs.readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
||||
throw error;
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
userProgress,
|
||||
xpEvents,
|
||||
} from "../db";
|
||||
import {
|
||||
getActionEvent,
|
||||
markUndone,
|
||||
type RecordActionInput,
|
||||
} from "./action-events";
|
||||
import { getActionEvent, markUndone } from "./action-events";
|
||||
import { refreshScores } from "./adventure";
|
||||
import { levelFromXp, chapterForLevel } from "@adventureos/shared";
|
||||
|
||||
@@ -26,19 +22,23 @@ export type UndoResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function recalculateProgress(userId: string) {
|
||||
const events = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(eq(xpEvents.userId, userId));
|
||||
type UndoHandlerContext = {
|
||||
userId: string;
|
||||
event: NonNullable<Awaited<ReturnType<typeof getActionEvent>>>;
|
||||
before: Record<string, unknown>;
|
||||
meta: Record<string, unknown>;
|
||||
xpEventIds: string[];
|
||||
revokeXpEvents: (userId: string, xpEventIds: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
const active = events.filter(
|
||||
(e) => !(e.metadata as Record<string, unknown>)?.revoked
|
||||
);
|
||||
type UndoHandler = (ctx: UndoHandlerContext) => Promise<void>;
|
||||
|
||||
async function recalculateProgress(userId: string) {
|
||||
const events = await db.select().from(xpEvents).where(eq(xpEvents.userId, userId));
|
||||
const active = events.filter((e) => !(e.metadata as Record<string, unknown>)?.revoked);
|
||||
const totalXp = active.reduce((s, e) => s + e.amount, 0);
|
||||
const newLevel = levelFromXp(totalXp);
|
||||
const chapter = chapterForLevel(newLevel);
|
||||
|
||||
await db
|
||||
.update(userProgress)
|
||||
.set({ totalXp, level: newLevel, currentChapter: chapter.name })
|
||||
@@ -50,9 +50,7 @@ async function revokeXpEvents(userId: string, xpEventIds: string[]) {
|
||||
const events = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds))
|
||||
);
|
||||
.where(and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds)));
|
||||
|
||||
for (const event of events) {
|
||||
await db.insert(xpEvents).values({
|
||||
@@ -60,40 +58,20 @@ async function revokeXpEvents(userId: string, xpEventIds: string[]) {
|
||||
date: event.date,
|
||||
source: event.source,
|
||||
amount: -event.amount,
|
||||
metadata: {
|
||||
revokedEventId: event.id,
|
||||
actionUndo: true,
|
||||
},
|
||||
metadata: { revokedEventId: event.id, actionUndo: true },
|
||||
});
|
||||
await db
|
||||
.update(xpEvents)
|
||||
.set({
|
||||
metadata: {
|
||||
...(event.metadata as Record<string, unknown>),
|
||||
revoked: true,
|
||||
},
|
||||
metadata: { ...(event.metadata as Record<string, unknown>), revoked: true },
|
||||
})
|
||||
.where(eq(xpEvents.id, event.id));
|
||||
}
|
||||
await recalculateProgress(userId);
|
||||
}
|
||||
|
||||
export async function undoAction(
|
||||
userId: string,
|
||||
actionEventId: string
|
||||
): Promise<UndoResult> {
|
||||
const event = await getActionEvent(userId, actionEventId);
|
||||
if (!event) return { ok: false, error: "Action not found" };
|
||||
if (event.undoneAt) return { ok: false, error: "Already undone" };
|
||||
if (!event.undoable) return { ok: false, error: "Action cannot be undone" };
|
||||
|
||||
const before = event.beforeState as Record<string, unknown>;
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const xpEventIds = (meta.xpEventIds as string[]) ?? [];
|
||||
|
||||
try {
|
||||
switch (event.actionType) {
|
||||
case "adventure_item.update": {
|
||||
const undoHandlers: Record<string, UndoHandler> = {
|
||||
"adventure_item.update": async ({ event, before, userId, xpEventIds, revokeXpEvents: revoke }) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if ("value" in before) updates.value = before.value as Record<string, unknown>;
|
||||
if ("state" in before) updates.state = before.state as string;
|
||||
@@ -102,66 +80,48 @@ export async function undoAction(
|
||||
if ("enabled" in before) updates.enabled = before.enabled as boolean;
|
||||
if ("config" in before) updates.config = before.config as Record<string, unknown>;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set(updates)
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
await db.update(dailyAdventureItems).set(updates).where(eq(dailyAdventureItems.id, event.entityId));
|
||||
}
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await revoke(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "adventure_item.create": {
|
||||
},
|
||||
"adventure_item.create": async ({ event }) => {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "adventure_item.delete": {
|
||||
},
|
||||
"adventure_item.delete": async ({ event }) => {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "adventure.work_hours.update": {
|
||||
},
|
||||
"adventure.work_hours.update": async ({ event, before }) => {
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null })
|
||||
.where(eq(dailyAdventures.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.create": {
|
||||
},
|
||||
"daily_todo.create": async ({ event }) => {
|
||||
await db.update(dailyTodos).set({ deletedAt: new Date() }).where(eq(dailyTodos.id, event.entityId));
|
||||
},
|
||||
"daily_todo.update": async ({ event, before }) => {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({ deletedAt: new Date() })
|
||||
.set({ label: before.label as string, done: before.done as boolean })
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.update": {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({
|
||||
label: before.label as string,
|
||||
done: before.done as boolean,
|
||||
})
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.delete": {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "reading.log_pages": {
|
||||
const bookBefore = before.book as Record<string, unknown>;
|
||||
},
|
||||
"daily_todo.delete": async ({ event }) => {
|
||||
await db.update(dailyTodos).set({ deletedAt: null }).where(eq(dailyTodos.id, event.entityId));
|
||||
},
|
||||
"reading.log_pages": async ({ event, before, userId, meta, xpEventIds, revokeXpEvents: revoke }) => {
|
||||
const bookBefore = before.book as Record<string, unknown> | undefined;
|
||||
const logId = meta.readingLogId as string;
|
||||
if (logId) {
|
||||
await db.delete(readingLogs).where(eq(readingLogs.id, logId));
|
||||
}
|
||||
if (bookBefore) {
|
||||
await db
|
||||
.update(books)
|
||||
.set({
|
||||
@@ -170,22 +130,27 @@ export async function undoAction(
|
||||
finishedAt: (bookBefore.finishedAt as Date | null) ?? null,
|
||||
})
|
||||
.where(eq(books.id, event.entityId));
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
break;
|
||||
} else if ("currentPage" in before) {
|
||||
await db
|
||||
.update(books)
|
||||
.set({
|
||||
currentPage: before.currentPage as number,
|
||||
status: before.status as string,
|
||||
finishedAt: (before.finishedAt as Date | null) ?? null,
|
||||
})
|
||||
.where(eq(books.id, event.entityId));
|
||||
}
|
||||
case "adventure.rest_day": {
|
||||
await revoke(userId, xpEventIds);
|
||||
},
|
||||
"adventure.rest_day": async ({ meta, userId, xpEventIds, revokeXpEvents: revoke }) => {
|
||||
const adventureId = meta.adventureId as string;
|
||||
if (adventureId) {
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isRestDay: false })
|
||||
.where(eq(dailyAdventures.id, adventureId));
|
||||
await db.update(dailyAdventures).set({ isRestDay: false }).where(eq(dailyAdventures.id, adventureId));
|
||||
}
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await revoke(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "reflection.save": {
|
||||
},
|
||||
"reflection.save": async ({ event, before }) => {
|
||||
if (before.existed) {
|
||||
await db
|
||||
.update(reflections)
|
||||
@@ -198,9 +163,8 @@ export async function undoAction(
|
||||
} else {
|
||||
await db.delete(reflections).where(eq(reflections.id, event.entityId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "exploration.update": {
|
||||
},
|
||||
"exploration.update": async ({ event, before, userId, xpEventIds, revokeXpEvents: revoke }) => {
|
||||
await db
|
||||
.update(explorations)
|
||||
.set({
|
||||
@@ -209,21 +173,41 @@ export async function undoAction(
|
||||
completedNote: (before.completedNote as string | null) ?? null,
|
||||
})
|
||||
.where(eq(explorations.id, event.entityId));
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await revoke(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "template_item.delete": {
|
||||
},
|
||||
"template_item.delete": async ({ event }) => {
|
||||
await db
|
||||
.update(adventureItems)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(adventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
},
|
||||
};
|
||||
|
||||
export async function undoAction(userId: string, actionEventId: string): Promise<UndoResult> {
|
||||
const event = await getActionEvent(userId, actionEventId);
|
||||
if (!event) return { ok: false, error: "Action not found" };
|
||||
if (event.undoneAt) return { ok: false, error: "Already undone" };
|
||||
if (!event.undoable) return { ok: false, error: "Action cannot be undone" };
|
||||
|
||||
const handler = undoHandlers[event.actionType];
|
||||
if (!handler) {
|
||||
return { ok: false, error: `Unknown action type: ${event.actionType}` };
|
||||
}
|
||||
|
||||
const before = event.beforeState as Record<string, unknown>;
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const xpEventIds = (meta.xpEventIds as string[]) ?? [];
|
||||
|
||||
try {
|
||||
await handler({
|
||||
userId,
|
||||
event,
|
||||
before,
|
||||
meta,
|
||||
xpEventIds,
|
||||
revokeXpEvents,
|
||||
});
|
||||
await markUndone(actionEventId);
|
||||
return { ok: true, summary: event.summary };
|
||||
} catch (e) {
|
||||
@@ -232,4 +216,4 @@ export async function undoAction(
|
||||
}
|
||||
}
|
||||
|
||||
export { type RecordActionInput };
|
||||
export type { RecordActionInput } from "./action-events";
|
||||
|
||||
37
apps/web/src/lib/services/xp.test.ts
Normal file
37
apps/web/src/lib/services/xp.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { awardXp as awardXpService } from "./xp";
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const progress = { userId: "user-1", totalXp: 100, level: 1, currentChapter: "Prologue" };
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([progress]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "xp-event-1" }]),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
userProgress: {},
|
||||
xpEvents: {},
|
||||
};
|
||||
});
|
||||
|
||||
describe("xp service characterization", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns zero award for non-positive amount", async () => {
|
||||
const result = await awardXpService("user-1", "2026-01-01", "exercise", 0);
|
||||
expect(result.awarded).toBe(0);
|
||||
expect(result.levelUp).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ describe("teacher-content envelope", () => {
|
||||
},
|
||||
{ source: "fallback", fallbackReason: "parse_failed" }
|
||||
);
|
||||
const unwrapped = unwrapTeacherContent(wrapped as Record<string, unknown>);
|
||||
const unwrapped = unwrapTeacherContent(wrapped as unknown as Record<string, unknown>);
|
||||
expect(unwrapped.source).toBe("fallback");
|
||||
expect(unwrapped.fallbackReason).toBe("parse_failed");
|
||||
expect(unwrapped.content.assignment).toBe("Read");
|
||||
|
||||
@@ -6,6 +6,8 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"../../packages/domain/src/**/*.test.ts",
|
||||
"../../packages/application/src/**/*.test.ts",
|
||||
"../../packages/shared/src/**/*.test.ts",
|
||||
],
|
||||
},
|
||||
|
||||
86
docs/ARCHITECTURE.md
Normal file
86
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# AdventureOS Architecture
|
||||
|
||||
This document describes the system architecture and behavioural invariants that must be preserved during refactoring.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
domain/ Pure domain rules, entities, value objects — no external deps
|
||||
application/ Use cases and port interfaces — depends on domain only
|
||||
shared/ Deprecated re-export shim → @adventureos/domain
|
||||
db/ Drizzle schema, migrations, connection
|
||||
|
||||
apps/web/
|
||||
src/
|
||||
adapters/ Persistence, AI, Calibre implementations of ports
|
||||
infrastructure/ Composition root, env wiring
|
||||
app/api/ HTTP adapters (Next.js route handlers)
|
||||
components/ UI adapters
|
||||
lib/services/ Legacy service layer (migrating to application use cases)
|
||||
```
|
||||
|
||||
### Dependency Rule
|
||||
|
||||
- `domain` → nothing external
|
||||
- `application` → `domain`
|
||||
- `adapters` / `infrastructure` / `app/api` → `application`, `domain`, `db`
|
||||
- Domain must not import controllers, ORM, HTTP, or config
|
||||
|
||||
## Behavioural Invariants
|
||||
|
||||
Derived from [PRODUCT.md](./PRODUCT.md). These must not change without explicit approval.
|
||||
|
||||
### XP and Progression
|
||||
|
||||
- **No XP loss** under normal operation. Undo revokes XP via compensating negative events, not deletion.
|
||||
- **Soft caps** apply diminishing returns per source (`adventure_item`, `spiritual`, `reading`) — see `DAILY_SOFT_CAPS` in domain.
|
||||
- **Level curve** is defined by `levelFromXp` / `xpForLevel` — cap at level 150.
|
||||
- **Rest days** award `XP_AWARDS.rest_day` without requiring item completion.
|
||||
- **Daily visit** XP is awarded once per logical day.
|
||||
|
||||
### Adventure Items
|
||||
|
||||
- Item states: `blank` → `started` → `partial` → `done` (derived by type-specific rules).
|
||||
- Partial credit is always awarded for non-blank state transitions.
|
||||
- **Semantic keys** (`exercise`, `prayer`, `litanies`, etc.) identify items for scoring/XP. Label-string fallback remains for backward compatibility until all templates use `config.semanticKey`.
|
||||
|
||||
### Day Boundary
|
||||
|
||||
- Logical "today" respects configurable day-boundary hour and grace window (`getLogicalToday`, `isWithinGraceWindow`).
|
||||
- Week starts on Monday (`startOfWeek` with `weekStartsOn: 1`).
|
||||
|
||||
### Undo
|
||||
|
||||
- Actions recorded via `recordAction` with `beforeState` / `afterState`.
|
||||
- Undo creates compensating XP events and marks originals as revoked.
|
||||
- Each `actionType` has a dedicated undo handler — handlers must stay synchronized with record sites.
|
||||
|
||||
### Anti-Burnout
|
||||
|
||||
- No streak destruction, no failure screens.
|
||||
- Grace days and welcome-back flows preserve progress.
|
||||
- Forgiving day modes do not penalize the user.
|
||||
|
||||
### Auth
|
||||
|
||||
- Single-tenant: one user per installation (`requireUser` uses `limit(1)`).
|
||||
- Use cases receive explicit `userId` — auth resolution happens in HTTP/cron adapters only.
|
||||
|
||||
## Migration Status
|
||||
|
||||
| Slice | Status |
|
||||
|-------|--------|
|
||||
| UpdateAdventureItem use case | Migrated via composition root |
|
||||
| Remaining adventure operations | Legacy services |
|
||||
| Reading, dashboard, AI | Legacy services |
|
||||
| Admin CMS (Drizzle schema) | Persistence adapter |
|
||||
|
||||
## Running Checks
|
||||
|
||||
```bash
|
||||
npm run test # domain + application + web + shared
|
||||
npm run lint # ESLint (web)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run build # Next.js production build
|
||||
```
|
||||
61
package-lock.json
generated
61
package-lock.json
generated
@@ -16,7 +16,9 @@
|
||||
"name": "@adventureos/web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@adventureos/application": "*",
|
||||
"@adventureos/db": "*",
|
||||
"@adventureos/domain": "*",
|
||||
"@adventureos/shared": "*",
|
||||
"@serwist/next": "^9.0.12",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
@@ -3941,18 +3943,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"apps/web/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"apps/web/node_modules/typescript-eslint": {
|
||||
"version": "8.62.0",
|
||||
"dev": true,
|
||||
@@ -4180,10 +4170,18 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adventureos/application": {
|
||||
"resolved": "packages/application",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@adventureos/db": {
|
||||
"resolved": "packages/db",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@adventureos/domain": {
|
||||
"resolved": "packages/domain",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@adventureos/shared": {
|
||||
"resolved": "packages/shared",
|
||||
"link": true
|
||||
@@ -8622,6 +8620,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uncrypto": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||
@@ -9398,6 +9410,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/application": {
|
||||
"name": "@adventureos/application",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@adventureos/domain": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@adventureos/db",
|
||||
"version": "1.0.0",
|
||||
@@ -9411,9 +9434,23 @@
|
||||
"tsx": "^4.19.3"
|
||||
}
|
||||
},
|
||||
"packages/domain": {
|
||||
"name": "@adventureos/domain",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@adventureos/shared",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@adventureos/domain": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"db:migrate": "npm run migrate -w @adventureos/db",
|
||||
"db:seed": "npm run seed -w @adventureos/db",
|
||||
"lint": "npm run lint -w @adventureos/web",
|
||||
"test": "npm run test -w @adventureos/web && npm run test -w @adventureos/shared",
|
||||
"typecheck": "npm run typecheck -w @adventureos/web && npm run typecheck -w @adventureos/application && npm run typecheck -w @adventureos/domain",
|
||||
"test": "npm run test -w @adventureos/domain && npm run test -w @adventureos/application && npm run test -w @adventureos/web && npm run test -w @adventureos/shared",
|
||||
"test:web": "npm run test -w @adventureos/web",
|
||||
"test:shared": "npm run test -w @adventureos/shared"
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/application/package.json
Normal file
21
packages/application/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
packages/application/src/index.ts
Normal file
7
packages/application/src/index.ts
Normal 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";
|
||||
30
packages/application/src/ports/action-events.repository.ts
Normal file
30
packages/application/src/ports/action-events.repository.ts
Normal 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>;
|
||||
}
|
||||
86
packages/application/src/ports/adventure.repository.ts
Normal file
86
packages/application/src/ports/adventure.repository.ts
Normal 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>;
|
||||
}
|
||||
6
packages/application/src/ports/scoring.service.ts
Normal file
6
packages/application/src/ports/scoring.service.ts
Normal 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>;
|
||||
}
|
||||
9
packages/application/src/ports/user.repository.ts
Normal file
9
packages/application/src/ports/user.repository.ts
Normal 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>;
|
||||
}
|
||||
39
packages/application/src/ports/xp.repository.ts
Normal file
39
packages/application/src/ports/xp.repository.ts
Normal 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[]>;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
69
packages/application/src/use-cases/xp/award-xp.test.ts
Normal file
69
packages/application/src/use-cases/xp/award-xp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
packages/application/src/use-cases/xp/award-xp.ts
Normal file
81
packages/application/src/use-cases/xp/award-xp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
packages/application/tsconfig.json
Normal file
12
packages/application/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
packages/application/vitest.config.ts
Normal file
8
packages/application/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -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
25
packages/domain/package.json
Executable 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"
|
||||
}
|
||||
}
|
||||
33
packages/domain/src/achievements.ts
Executable file
33
packages/domain/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/domain/src/adventure/derive-state.test.ts
Executable file
37
packages/domain/src/adventure/derive-state.test.ts
Executable 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");
|
||||
});
|
||||
});
|
||||
32
packages/domain/src/adventure/derive-state.ts
Normal file
32
packages/domain/src/adventure/derive-state.ts
Normal 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";
|
||||
}
|
||||
27
packages/domain/src/adventure/semantic-key.test.ts
Normal file
27
packages/domain/src/adventure/semantic-key.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/domain/src/adventure/semantic-key.ts
Normal file
36
packages/domain/src/adventure/semantic-key.ts
Normal 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;
|
||||
}
|
||||
49
packages/domain/src/adventure/template-matching.test.ts
Executable file
49
packages/domain/src/adventure/template-matching.test.ts
Executable 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();
|
||||
});
|
||||
});
|
||||
23
packages/domain/src/adventure/template-matching.ts
Normal file
23
packages/domain/src/adventure/template-matching.ts
Normal 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;
|
||||
}
|
||||
37
packages/domain/src/chapters.test.ts
Executable file
37
packages/domain/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/domain/src/chapters.ts
Executable file
68
packages/domain/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/domain/src/day-boundary.test.ts
Executable file
38
packages/domain/src/day-boundary.test.ts
Executable 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);
|
||||
});
|
||||
});
|
||||
42
packages/domain/src/day-boundary.ts
Executable file
42
packages/domain/src/day-boundary.ts
Executable 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 (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;
|
||||
}
|
||||
|
||||
/** 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
15
packages/domain/src/index.ts
Executable 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";
|
||||
36
packages/domain/src/levels.test.ts
Executable file
36
packages/domain/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/domain/src/levels.ts
Executable file
27
packages/domain/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 };
|
||||
}
|
||||
124
packages/domain/src/memory.ts
Executable file
124
packages/domain/src/memory.ts
Executable 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;
|
||||
43
packages/domain/src/quest-pool.ts
Executable file
43
packages/domain/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);
|
||||
}
|
||||
18
packages/domain/src/reflection/index.ts
Normal file
18
packages/domain/src/reflection/index.ts
Normal 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);
|
||||
}
|
||||
70
packages/domain/src/scores.test.ts
Executable file
70
packages/domain/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/domain/src/scores.ts
Executable file
137
packages/domain/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/domain/src/titles.ts
Executable file
53
packages/domain/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";
|
||||
283
packages/domain/src/types.ts
Executable file
283
packages/domain/src/types.ts
Executable 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
39
packages/domain/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/domain/src/xp.ts
Executable file
57
packages/domain/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;
|
||||
}
|
||||
12
packages/domain/tsconfig.json
Normal file
12
packages/domain/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
packages/domain/vitest.config.ts
Executable file
8
packages/domain/vitest.config.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,9 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adventureos/domain": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user