@@ -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,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";
|
||||
|
||||
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",
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user