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

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

View File

@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"public/sw.js",
]),
]);

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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));
},
};

View File

@@ -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;
},
};

View File

@@ -0,0 +1,7 @@
import type { ScoringService } from "@adventureos/application";
import { buildDaySnapshot, refreshScores } from "@/lib/services/adventure/scoring";
export const drizzleScoringService: ScoringService = {
buildDaySnapshot,
refreshScores,
};

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

View File

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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);

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

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

View File

@@ -0,0 +1 @@
export { env } from "@/lib/config/env";

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

View File

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

View File

@@ -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[] {

View File

@@ -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";

View File

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

View File

@@ -1,3 +1,2 @@
export * from "./settings.repository";
export * from "./teacher.repository";
export * from "./adventure-templates.repository";

View File

@@ -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) {

View File

@@ -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";

View File

@@ -1,4 +0,0 @@
export * from "./materialization";
export * from "./daily";
export * from "./scoring";
export * from "./templates";

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

View File

@@ -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)

View File

@@ -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";

View File

@@ -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(),
});

View File

@@ -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 }

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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,170 +58,156 @@ 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 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;
if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null;
if ("label" in before) updates.label = before.label as string;
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 revoke(userId, xpEventIds);
await refreshScores(userId);
},
"adventure_item.create": async ({ event }) => {
await db
.update(dailyAdventureItems)
.set({ deletedAt: new Date() })
.where(eq(dailyAdventureItems.id, event.entityId));
},
"adventure_item.delete": async ({ event }) => {
await db
.update(dailyAdventureItems)
.set({ deletedAt: null })
.where(eq(dailyAdventureItems.id, event.entityId));
},
"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));
},
"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({ label: before.label as string, done: before.done as boolean })
.where(eq(dailyTodos.id, event.entityId));
},
"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({
currentPage: bookBefore.currentPage as number,
status: bookBefore.status as string,
finishedAt: (bookBefore.finishedAt as Date | null) ?? null,
})
.where(eq(books.id, event.entityId));
} 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));
}
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 revoke(userId, xpEventIds);
await refreshScores(userId);
},
"reflection.save": async ({ event, before }) => {
if (before.existed) {
await db
.update(reflections)
.set({
wentWell: before.wentWell as string,
learned: before.learned as string,
improveTomorrow: before.improveTomorrow as string,
})
.where(eq(reflections.id, event.entityId));
} else {
await db.delete(reflections).where(eq(reflections.id, event.entityId));
}
},
"exploration.update": async ({ event, before, userId, xpEventIds, revokeXpEvents: revoke }) => {
await db
.update(explorations)
.set({
status: before.status as string,
acceptedAt: (before.acceptedAt as Date | null) ?? null,
completedNote: (before.completedNote as string | null) ?? null,
})
.where(eq(explorations.id, event.entityId));
await revoke(userId, xpEventIds);
await refreshScores(userId);
},
"template_item.delete": async ({ event }) => {
await db
.update(adventureItems)
.set({ deletedAt: null })
.where(eq(adventureItems.id, event.entityId));
},
};
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 {
switch (event.actionType) {
case "adventure_item.update": {
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;
if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null;
if ("label" in before) updates.label = before.label as string;
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 revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "adventure_item.create": {
await db
.update(dailyAdventureItems)
.set({ deletedAt: new Date() })
.where(eq(dailyAdventureItems.id, event.entityId));
break;
}
case "adventure_item.delete": {
await db
.update(dailyAdventureItems)
.set({ deletedAt: null })
.where(eq(dailyAdventureItems.id, event.entityId));
break;
}
case "adventure.work_hours.update": {
await db
.update(dailyAdventures)
.set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null })
.where(eq(dailyAdventures.id, event.entityId));
break;
}
case "daily_todo.create": {
await db
.update(dailyTodos)
.set({ deletedAt: new Date() })
.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>;
const logId = meta.readingLogId as string;
if (logId) {
await db.delete(readingLogs).where(eq(readingLogs.id, logId));
}
await db
.update(books)
.set({
currentPage: bookBefore.currentPage as number,
status: bookBefore.status as string,
finishedAt: (bookBefore.finishedAt as Date | null) ?? null,
})
.where(eq(books.id, event.entityId));
await revokeXpEvents(userId, xpEventIds);
break;
}
case "adventure.rest_day": {
const adventureId = meta.adventureId as string;
if (adventureId) {
await db
.update(dailyAdventures)
.set({ isRestDay: false })
.where(eq(dailyAdventures.id, adventureId));
}
await revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "reflection.save": {
if (before.existed) {
await db
.update(reflections)
.set({
wentWell: before.wentWell as string,
learned: before.learned as string,
improveTomorrow: before.improveTomorrow as string,
})
.where(eq(reflections.id, event.entityId));
} else {
await db.delete(reflections).where(eq(reflections.id, event.entityId));
}
break;
}
case "exploration.update": {
await db
.update(explorations)
.set({
status: before.status as string,
acceptedAt: (before.acceptedAt as Date | null) ?? null,
completedNote: (before.completedNote as string | null) ?? null,
})
.where(eq(explorations.id, event.entityId));
await revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "template_item.delete": {
await db
.update(adventureItems)
.set({ deletedAt: null })
.where(eq(adventureItems.id, event.entityId));
break;
}
default:
return { ok: false, error: `Unknown action type: ${event.actionType}` };
}
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";

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

View File

@@ -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");

View File

@@ -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",
],
},