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

This commit is contained in:
2026-06-26 09:26:50 +01:00
parent 194330fb47
commit 3b37368e7d
213 changed files with 14688 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
export default function AchievementsPage() {
const { data, isLoading } = useQuery({
queryKey: ["achievements"],
queryFn: async () => {
const res = await fetch("/api/achievements");
return res.json();
},
});
const unlockedKeys = new Set(
(data?.unlocked ?? []).map((a: { key: string }) => a.key)
);
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-4">Achievement Gallery</h1>
<p className="text-sm text-[var(--warm-grey)] mb-6">
Milestones on your long adventure earned through showing up, not perfection.
</p>
{isLoading ? (
<p>Loading trophies...</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(data?.all ?? []).map(
(a: { key: string; name: string; description: string; category: string }) => {
const unlocked = unlockedKeys.has(a.key);
return (
<div
key={a.key}
className={`retro-window p-4 ${
unlocked ? "" : "opacity-50 grayscale"
}`}
>
<div className="flex items-start gap-3">
<span className="text-2xl">{unlocked ? "🏆" : "🔒"}</span>
<div>
<h3 className="font-bold">{a.name}</h3>
<p className="text-xs text-[var(--warm-grey)]">{a.description}</p>
<span className="text-xs text-[var(--gold-trim)]">{a.category}</span>
</div>
</div>
</div>
);
}
)}
</div>
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import {
getAchievements,
getAllAchievementDefinitions,
checkAchievements,
} from "@/lib/services/achievements";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const unlocked = await getAchievements(user.id);
const all = getAllAchievementDefinitions();
return { unlocked, all };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
return checkAchievements(user.id);
});
}

View File

@@ -0,0 +1,16 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { undoAction } from "@/lib/services/undo";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const result = await undoAction(user.id, id);
if (!result.ok) throw new Error(result.error ?? "Undo failed");
return result;
});
}

View File

@@ -0,0 +1,11 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getRecentActions } from "@/lib/services/action-events";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const actions = await getRecentActions(user.id, 30);
return { actions };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { applyTemplateToDate } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return applyTemplateToDate(user.id, date, body.templateId, body.force === true);
});
}

View File

@@ -0,0 +1,29 @@
import { handleApi } from "@/lib/api";
import { updateAdventureItem, updateDailyItemMeta, deleteCustomDailyItem } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.label !== undefined || body.enabled !== undefined || body.config !== undefined) {
return updateDailyItemMeta(user.id, date, id, body);
}
return updateAdventureItem(user.id, date, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteCustomDailyItem(user.id, date, id);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { addCustomDailyItem } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return addCustomDailyItem(user.id, date, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { quickLog } from "@/lib/services/quick-log";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return quickLog(user.id, date, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { setRestDay } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const result = await setRestDay(user.id, date);
return { ok: true, actionEventId: result.actionEventId };
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { getDailyAdventure, updateDaySettings } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getDailyAdventure(user.id, date);
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.workHoursTarget !== undefined || body.dayMode !== undefined) {
return updateDaySettings(user.id, date, {
workHoursTarget: body.workHoursTarget,
dayMode: body.dayMode,
});
}
throw new Error("No valid updates");
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi } from "@/lib/api";
import { updateDailyTodo, deleteDailyTodo } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateDailyTodo(user.id, date, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteDailyTodo(user.id, date, id);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { createDailyTodo } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createDailyTodo(user.id, date, body.label);
});
}

View File

@@ -0,0 +1,11 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { detectCatchUpGaps } from "@/lib/services/catch-up";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const gaps = await detectCatchUpGaps(user.id);
return { gaps };
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getWhatAiKnows } from "@/lib/services/ai-chat";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getWhatAiKnows(user.id);
});
}

View File

@@ -0,0 +1,40 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getChatSession, sendChatMessage, archiveChatSession } from "@/lib/services/ai-chat";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const data = await getChatSession(user.id, id);
if (!data) throw new Error("Session not found");
return data;
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await archiveChatSession(user.id, id);
return { ok: true };
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return sendChatMessage(user.id, id, body.content ?? "");
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listChatSessions, createChatSession } from "@/lib/services/ai-chat";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const sessions = await listChatSessions(user.id);
return { sessions };
});
}
export async function POST(request: Request) {
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
return createChatSession(user.id, body.title, body.featureContext);
});
}

View File

@@ -0,0 +1,33 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getAiBehaviorConfig,
getAiProviderConfig,
saveAiBehaviorConfig,
saveAiProviderConfig,
} from "@/lib/services/ai-config";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const behavior = await getAiBehaviorConfig(user.id);
const provider = await getAiProviderConfig(user.id);
return { behavior, provider };
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
let behavior = await getAiBehaviorConfig(user.id);
let provider = await getAiProviderConfig(user.id);
if (body.behavior) {
behavior = await saveAiBehaviorConfig(user.id, body.behavior);
}
if (body.provider) {
provider = await saveAiProviderConfig(user.id, body.provider);
}
return { behavior, provider };
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { buildMentorContext, formatContextForPrompt } from "@/lib/services/ai-context";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const message = searchParams.get("message") ?? "";
const feature = searchParams.get("feature") ?? "mentor";
return handleApi(async () => {
const { user } = await requireUser();
const ctx = await buildMentorContext(user.id, { userMessage: message, feature, logContext: false });
return {
layers: ctx.layers,
tokenEstimate: ctx.tokenEstimate,
memoryIds: ctx.memoryIds,
formatted: formatContextForPrompt(ctx),
};
});
}

View File

@@ -0,0 +1,21 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getCachedHealth, runHealthCheck } from "@/lib/services/ai-config";
import { getLastHealthLogs } from "@/lib/services/ai-config";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const cached = await getCachedHealth(user.id);
const logs = await getLastHealthLogs(user.id, 5);
return { health: cached, logs };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const health = await runHealthCheck(user.id);
return { health };
});
}

View File

@@ -0,0 +1,40 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getMemory, updateMemory, archiveMemory } from "@/lib/services/ai-memory";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const memory = await getMemory(user.id, id);
if (!memory) throw new Error("Memory not found");
return memory;
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateMemory(user.id, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await archiveMemory(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,21 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getMemoryLearningSettings,
saveMemoryLearningSettings,
} from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getMemoryLearningSettings(user.id);
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveMemoryLearningSettings(user.id, body);
});
}

View File

@@ -0,0 +1,13 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { resetAllMemories } from "@/lib/services/ai-memory";
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (!body.confirm) throw new Error("Confirmation required");
await resetAllMemories(user.id);
return { ok: true };
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
listMemories,
createMemory,
getProfileSummary,
listSuggestions,
exportMemories,
} from "@/lib/services/ai-memory";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
return handleApi(async () => {
const { user } = await requireUser();
const category = searchParams.get("category") ?? undefined;
const q = searchParams.get("q") ?? undefined;
const enabledParam = searchParams.get("enabled");
const enabled = enabledParam === null ? undefined : enabledParam === "true";
const memories = await listMemories(user.id, { category, q, enabled });
const summary = await getProfileSummary(user.id);
const pending = await listSuggestions(user.id, "pending");
return { memories, summary, pendingCount: pending.length };
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createMemory(user.id, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { acceptSuggestion } from "@/lib/services/ai-memory";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
return acceptSuggestion(user.id, id, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { ignoreSuggestion } from "@/lib/services/ai-memory";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await ignoreSuggestion(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { rejectSuggestion } from "@/lib/services/ai-memory";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await rejectSuggestion(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,13 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listSuggestions } from "@/lib/services/ai-memory";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") ?? "pending";
return handleApi(async () => {
const { user } = await requireUser();
const suggestions = await listSuggestions(user.id, status);
return { suggestions };
});
}

View File

@@ -0,0 +1,54 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listMemories, saveProfileSummary } from "@/lib/services/ai-memory";
import { getProvider } from "@/lib/ai/provider-registry";
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "@/lib/services/ai-config";
import { getTemplateBody } from "@/lib/services/ai-templates";
import { renderTemplate } from "@/lib/ai/prompts/render";
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const memories = await listMemories(user.id, { enabled: true });
const verified = memories.filter((m) => m.userVerified);
const bulletList = verified
.map((m) => `- [${m.category}] ${m.title}: ${m.content.slice(0, 120)}`)
.join("\n");
const behavior = await getAiBehaviorConfig(user.id);
const providerConfig = await getAiProviderConfig(user.id);
let summaryText = verified.length === 0
? "No verified memories yet. Add memories manually to build your profile."
: bulletList.slice(0, 1500);
if (behavior.enabled && providerConfig.enabled && verified.length > 0) {
try {
const coreSystem = await getTemplateBody(user.id, "system_core");
const templateBody = await getTemplateBody(user.id, "mentor_summary");
const system = buildSystemPrompt(behavior, coreSystem);
const prompt = renderTemplate(templateBody, {
user_name: user.displayName,
context: bulletList,
});
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system,
format: "text",
temperature: 0.5,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
});
if (res.text.trim()) summaryText = res.text.trim();
} catch {
/* use bullet list fallback */
}
}
const summary = await saveProfileSummary(user.id, summaryText);
return { ...summary, sourceMemoryCount: verified.length };
});
}

View File

@@ -0,0 +1,34 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getProfileSummary,
saveProfileSummary,
exportMemories,
importMemories,
} from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getProfileSummary(user.id);
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveProfileSummary(user.id, body.summary ?? "");
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "import") {
return importMemories(user.id, body.data, body.overwrite);
}
return exportMemories(user.id);
});
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import { listProviderTypes } from "@/lib/ai/provider-registry";
import { requireUser } from "@/lib/services/user";
import { getAiProviderConfig } from "@/lib/services/ai-config";
import { getProvider } from "@/lib/ai/provider-registry";
export async function GET() {
return handleApi(async () => {
const providers = listProviderTypes();
return { providers };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const config = await getAiProviderConfig(user.id);
const provider = getProvider(config.type);
if (!provider.listModels) return { models: [config.model] };
const models = await provider.listModels(config);
return { models };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { dismissSuggestion } from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await dismissSuggestion(id, user.id);
return { ok: true };
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi, jsonError } from "@/lib/api";
import { getSuggestions, dismissSuggestion, generateDailyQuests } from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { isOllamaAvailable } from "@/lib/services/ai";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const role = searchParams.get("role") ?? undefined;
return handleApi(async () => {
const { user } = await requireUser();
const suggestions = await getSuggestions(user.id, role);
const ollamaAvailable = await isOllamaAvailable();
return { suggestions, ollamaAvailable };
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate_quests") {
return generateDailyQuests(user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,64 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getPromptTemplate,
updatePromptTemplate,
resetPromptTemplate,
previewTemplate,
getTemplateVersions,
restoreTemplateVersion,
} from "@/lib/services/ai-templates";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const template = await getPromptTemplate(user.id, key);
const versions = await getTemplateVersions(user.id, key);
return { template, versions };
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
const template = await updatePromptTemplate(user.id, key, body);
return { template };
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
if (action === "reset") {
const template = await resetPromptTemplate(user.id, key);
return { template };
}
if (action === "preview") {
const rendered = await previewTemplate(user.id, key, body.body);
return { rendered };
}
if (action === "restore" && body.version) {
const template = await restoreTemplateVersion(user.id, key, body.version);
return { template };
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,12 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getPromptTemplates } from "@/lib/services/ai-templates";
import { PLACEHOLDER_DOCS } from "@/lib/ai/prompts/defaults";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const templates = await getPromptTemplates(user.id);
return { templates, placeholders: PLACEHOLDER_DOCS };
});
}

View File

@@ -0,0 +1,28 @@
import { handleApi, jsonOk } from "@/lib/api";
import { getSession, verifyPassword } from "@/lib/auth";
export async function POST(request: Request) {
return handleApi(async () => {
const { password } = await request.json();
if (!verifyPassword(password)) {
throw new Error("Invalid password");
}
const session = await getSession();
session.isLoggedIn = true;
await session.save();
return { ok: true };
});
}
export async function DELETE() {
return handleApi(async () => {
const session = await getSession();
session.destroy();
return { ok: true };
});
}
export async function GET() {
const session = await getSession();
return jsonOk({ isLoggedIn: !!session.isLoggedIn });
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { logPages } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return logPages(user.id, id, body.pages, body.date, body.note);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { updateBook } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateBook(id, user.id, body);
});
}

View File

@@ -0,0 +1,18 @@
import { handleApi } from "@/lib/api";
import { getBooks, createBook } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getBooks(user.id);
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createBook(user.id, body);
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { getCartographerDesk } from "@/lib/services/cartographer";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getCartographerDesk(user.id);
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { verifyCronSecret } from "@/lib/auth";
import { requireUser } from "@/lib/services/user";
import { materializeDay } from "@/lib/services/adventure";
import { todayString } from "@/lib/dates";
import { generateDailyQuests, generateWeeklyExplorations } from "@/lib/services/explorations";
import { weekStartString } from "@/lib/dates";
import { generateWeeklyReview } from "@/lib/services/explorations";
import { pruneOldActions } from "@/lib/services/action-events";
export async function POST(request: Request) {
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { user } = await requireUser();
const { searchParams } = new URL(request.url);
const action = searchParams.get("action") ?? "materialize";
if (action === "materialize") {
await materializeDay(user.id, todayString());
return NextResponse.json({ ok: true });
}
if (action === "generate-quests") {
await generateDailyQuests(user.id);
return NextResponse.json({ ok: true });
}
if (action === "generate-explorations") {
await generateWeeklyExplorations(user.id);
return NextResponse.json({ ok: true });
}
if (action === "generate-weekly-review") {
await generateWeeklyReview(user.id, weekStartString());
return NextResponse.json({ ok: true });
}
if (action === "prune-actions") {
await pruneOldActions(user.id, 90);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}

View File

@@ -0,0 +1,8 @@
import { handleApi } from "@/lib/api";
import { getDashboard } from "@/lib/services/dashboard";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const date = searchParams.get("date") ?? undefined;
return handleApi(() => getDashboard(date));
}

View File

@@ -0,0 +1,29 @@
import { handleApi } from "@/lib/api";
import {
acceptExploration,
completeExploration,
dismissExploration,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "accept") {
return acceptExploration(id, user.id);
}
if (body.action === "complete") {
return completeExploration(id, user.id, body.note ?? "", body.date ?? todayString());
}
if (body.action === "dismiss" || body.action === "deny") {
return dismissExploration(id, user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,33 @@
import { handleApi } from "@/lib/api";
import {
getExplorations,
generateWeeklyExplorations,
acceptExploration,
completeExploration,
dismissExploration,
getExplorationHistory,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const weekOf = searchParams.get("week_of") ?? undefined;
const history = searchParams.get("history") === "true";
return handleApi(async () => {
const { user } = await requireUser();
if (history) return getExplorationHistory(user.id);
return getExplorations(user.id, weekOf);
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate") {
return generateWeeklyExplorations(user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,25 @@
import { eq } from "drizzle-orm";
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { db } from "@/lib/db";
import * as schema from "@adventureos/db/schema";
import { exportMemories } from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
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),
aiMemories: aiData.memories,
aiProfileSummary: aiData.summary,
aiMemoryLearning: aiData.learning,
};
return tables;
});
}

View File

@@ -0,0 +1,31 @@
import { handleApi } from "@/lib/api";
import { listCalibreBooks } from "@/lib/services/calibre";
import { getReadingProgress } from "@/lib/services/calibre-reading";
import { requireUser } from "@/lib/services/user";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const search = searchParams.get("search") ?? undefined;
const limit = Number(searchParams.get("limit") ?? "200");
const offset = Number(searchParams.get("offset") ?? "0");
return handleApi(async () => {
const { user } = await requireUser();
const books = await listCalibreBooks({ search, limit, offset });
const progress = await getReadingProgress(user.id);
const progressMap = new Map(progress.map((p) => [p.calibreBookId, p]));
return books.map((b) => {
const p = progressMap.get(b.id);
return {
...b,
currentPage: p?.currentPage ?? 0,
totalPages: p?.totalPages ?? 300,
status: p?.status ?? "unread",
progressPercent: p
? Math.round((p.currentPage / Math.max(p.totalPages, 1)) * 100)
: 0,
};
});
});
}

View File

@@ -0,0 +1,30 @@
import fs from "fs";
import { resolveCoverPath } from "@/lib/services/calibre";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const bookId = Number(id);
if (!Number.isFinite(bookId)) {
return new Response("Invalid id", { status: 400 });
}
await requireUser();
const coverPath = resolveCoverPath(bookId);
if (!coverPath) {
return new Response("Cover not found", { status: 404 });
}
const buffer = fs.readFileSync(coverPath);
return new Response(buffer, {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=3600" },
});
} catch (e) {
const msg = e instanceof Error ? e.message : "Error";
if (msg === "Unauthorized") return new Response(msg, { status: 401 });
return new Response(msg, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import { logCalibrePages } from "@/lib/services/calibre-reading";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const calibreBookId = Number(id);
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return logCalibrePages(
user.id,
calibreBookId,
body.pages ?? 10,
body.date ?? todayString()
);
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { getCalibreStatus } from "@/lib/services/calibre";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
await requireUser();
return getCalibreStatus();
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi } from "@/lib/api";
import { saveReflection, getReflection } from "@/lib/services/reflection";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveReflection(user.id, date, body);
});
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getReflection(user.id, date);
});
}

View File

@@ -0,0 +1,37 @@
import { handleApi } from "@/lib/api";
import {
getWeeklyReview,
generateWeeklyReview,
setReviewIntention,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ weekStart: string }> }
) {
const { weekStart } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getWeeklyReview(user.id, weekStart);
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ weekStart: string }> }
) {
const { weekStart } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate") {
return generateWeeklyReview(user.id, weekStart);
}
if (body.intention) {
await setReviewIntention(user.id, weekStart, body.intention);
return { ok: true };
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,20 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getDayBoundaryHour, setDayBoundaryHour } from "@/lib/services/day-boundary";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const hour = await getDayBoundaryHour(user.id);
return { hour };
});
}
export async function PATCH(request: Request) {
return handleApi(async () => {
const { user } = await requireUser();
const body = await request.json();
const hour = await setDayBoundaryHour(user.id, body.hour ?? 0);
return { hour };
});
}

View File

@@ -0,0 +1,58 @@
import { handleApi } from "@/lib/api";
import { requireUser, updateUserProfile } from "@/lib/services/user";
import { db, spiritualConfig } from "@/lib/db";
import { eq } from "drizzle-orm";
import { normalizeSettingsTheme } from "@/lib/services/theme-migration";
import { migrateUserThemeInDb } from "@/lib/services/theme-migration";
import { seedPromptTemplatesForUser } from "@/lib/services/ai-templates";
import { getSettingsForUser, upsertSetting } from "@/lib/repositories/settings.repository";
import { parseJsonBody, settingsPatchSchema } from "@/lib/validation";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const allSettings = await getSettingsForUser(user.id);
const settingsMap = Object.fromEntries(allSettings.map((s) => [s.key, s.value]));
const migratedTheme = await migrateUserThemeInDb(
user.id,
settingsMap.theme as string | undefined
);
settingsMap.theme = migratedTheme;
const [spiritual] = await db
.select()
.from(spiritualConfig)
.where(eq(spiritualConfig.userId, user.id));
await seedPromptTemplatesForUser(user.id);
return {
user,
settings: normalizeSettingsTheme(settingsMap),
spiritual,
};
});
}
export async function PATCH(request: Request) {
return handleApi(async () => {
const body = await parseJsonBody(request, settingsPatchSchema);
const { user } = await requireUser();
if (body.profile) {
await updateUserProfile(user.id, body.profile);
}
if (body.settings) {
for (const [key, value] of Object.entries(body.settings)) {
await upsertSetting(user.id, key, value);
}
}
if (body.spiritual) {
await db
.update(spiritualConfig)
.set({
prayerLabels: body.spiritual.prayerLabels,
litanyLabels: body.spiritual.litanyLabels,
})
.where(eq(spiritualConfig.userId, user.id));
}
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { getStatsOverview, getStatsDomain } from "@/lib/services/dashboard";
export async function GET(
request: Request,
{ params }: { params: Promise<{ domain: string }> }
) {
const { domain } = await params;
const { searchParams } = new URL(request.url);
const range = Number(searchParams.get("range") ?? 30);
return handleApi(async () => {
if (domain === "overview") return getStatsOverview();
return getStatsDomain(domain, range);
});
}

View File

@@ -0,0 +1,31 @@
import { handleApi } from "@/lib/api";
import { getTeacherLesson, completeTeacherLesson } from "@/lib/services/teacher";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const lesson = await getTeacherLesson(user.id, id);
if (!lesson) throw new Error("Lesson not found");
return lesson;
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "complete") {
return completeTeacherLesson(user.id, id, body.completedNote ?? "");
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,34 @@
import { handleApi } from "@/lib/api";
import { createTeacherLesson, getTeacherHistory } from "@/lib/services/teacher";
import { requireUser } from "@/lib/services/user";
import { parseJsonBody } from "@/lib/validation";
import {
teacherCreateSchema,
normalizeTeacherCreateBody,
} from "@/lib/validation/schemas";
export async function POST(request: Request) {
return handleApi(async () => {
// #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:'api/teacher/route.ts:POST',message:'teacher POST start',data:{},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const parsed = await parseJsonBody(request, teacherCreateSchema);
const body = normalizeTeacherCreateBody(parsed);
// #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:'api/teacher/route.ts:POST',message:'teacher body validated',data:{topicLen:body.topic.length,hasExplorationId:!!body.explorationId},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const { user } = await requireUser();
const result = await createTeacherLesson(user.id, body);
// #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:'api/teacher/route.ts:POST',message:'teacher lesson created',data:{source:result.source,lessonId:result.id},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
// #endregion
return result;
});
}
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getTeacherHistory(user.id);
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { upsertTemplateItem, deleteTemplateItem, verifyTemplateOwnership } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
await verifyTemplateOwnership(user.id, id);
const itemId = await upsertTemplateItem(id, body);
return { id: itemId };
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const itemId = searchParams.get("itemId");
if (!itemId) throw new Error("itemId required");
return handleApi(async () => {
const { user } = await requireUser();
const result = await deleteTemplateItem(itemId, user.id);
return { ok: true, ...result };
});
}

View File

@@ -0,0 +1,47 @@
import { handleApi } from "@/lib/api";
import {
updateTemplate,
deleteTemplate,
duplicateTemplate,
verifyTemplateOwnership,
} from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
await verifyTemplateOwnership(user.id, id);
return updateTemplate(user.id, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteTemplate(user.id, id);
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "duplicate") {
return duplicateTemplate(user.id, id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { getTemplates, createTemplate } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
import { parseJsonBody, templateCreateSchema } from "@/lib/validation";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getTemplates(user.id);
});
}
export async function POST(request: Request) {
return handleApi(async () => {
const body = await parseJsonBody(request, templateCreateSchema);
const { user } = await requireUser();
return createTemplate(user.id, body);
});
}

View File

@@ -0,0 +1,6 @@
import { jsonOk } from "@/lib/api";
import { THEMES } from "@/themes/registry";
export async function GET() {
return jsonOk({ themes: THEMES });
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import Link from "next/link";
import { useState } from "react";
import { useUiStore } from "@/stores/ui";
interface Exploration {
id: string;
title: string;
description: string;
category: string;
status: string;
completedNote?: string | null;
minutes?: number | null;
}
export default function CartographerPage() {
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const showActionToast = useUiStore((s) => s.showActionToast);
const [tab, setTab] = useState<"active" | "history">("active");
const [completeId, setCompleteId] = useState<string | null>(null);
const [note, setNote] = useState("");
const { data: desk, isLoading: deskLoading, error: deskError } = useQuery({
queryKey: ["cartographer-desk"],
queryFn: async () => {
const res = await fetch("/api/cartographer");
if (!res.ok) throw new Error("Failed to load map desk");
return res.json();
},
});
const { data: explorations = [], isLoading, error } = useQuery({
queryKey: ["explorations"],
queryFn: async () => {
const res = await fetch("/api/explorations");
if (!res.ok) throw new Error("Failed to load explorations");
const data = await res.json();
if (data.error) throw new Error(data.error);
return Array.isArray(data) ? data : [];
},
});
const { data: history = [] } = useQuery({
queryKey: ["explorations-history"],
queryFn: async () => {
const res = await fetch("/api/explorations?history=true");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
return Array.isArray(data) ? data : [];
},
enabled: tab === "history",
});
const [generateSource, setGenerateSource] = useState<"ai" | "fallback" | "existing" | null>(
null
);
const generate = useMutation({
mutationFn: async () => {
const res = await fetch("/api/explorations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate" }),
});
if (!res.ok) throw new Error("Generate failed");
const data = await res.json();
if (data.error) throw new Error(data.error);
return data as {
explorations: Exploration[];
source?: "ai" | "fallback" | "existing";
};
},
onSuccess: (data) => {
setGenerateSource(data.source ?? null);
qc.invalidateQueries({ queryKey: ["explorations"] });
},
});
const generateNotice =
generateSource === "existing"
? "You still have suggested quests to review — finish or deny them before generating more."
: generateSource === "fallback"
? "AI unavailable — showing curated offline quest suggestions."
: null;
const action = useMutation({
mutationFn: async ({ id, action, note }: { id: string; action: string; note?: string }) => {
const res = await fetch(`/api/explorations/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, note }),
});
if (!res.ok) throw new Error("Action failed");
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["explorations"] });
qc.invalidateQueries({ queryKey: ["explorations-history"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["cartographer-desk"] });
if (vars.action === "complete") showXpToast(100, "Exploration complete");
if (data.actionEventId) {
showActionToast(`Exploration ${vars.action}`, data.actionEventId);
}
setCompleteId(null);
setNote("");
},
});
const suggested = explorations.filter((e: Exploration) => e.status === "suggested");
const active = explorations.filter((e: Exploration) => e.status === "active");
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
<div className="flex justify-between items-center mb-4">
<h1 className="font-bold text-lg">The Cartographer&apos;s Desk</h1>
<button
className="retro-btn"
onClick={() => generate.mutate()}
disabled={generate.isPending}
>
{generate.isPending ? "Generating..." : "Generate Quests"}
</button>
</div>
{generate.isError && (
<p className="text-sm text-[var(--muted-rose)] mb-3">
Could not generate quests. Check AI Health in Settings.
</p>
)}
{generateNotice && (
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">{generateNotice}</p>
)}
{generateSource === "fallback" && !generateNotice && (
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">
AI is offline showing curated offline quest suggestions.
</p>
)}
{!deskLoading && desk && (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
{desk.domains?.map((d: { key: string; label: string; score: number }) => (
<div key={d.key} className="retro-window-inset p-2 text-center">
<p className="text-xs text-[var(--warm-grey)]">{d.label}</p>
<p className="font-bold text-lg">{d.score}</p>
<div className="skill-bar-track h-1.5 mt-1">
<div className="skill-bar-fill h-full" style={{ width: `${d.score}%` }} />
</div>
</div>
))}
</div>
{desk.horizon?.intention && (
<p className="text-sm italic mb-4 text-[var(--warm-grey)]">
Horizon: {desk.horizon.intention}
</p>
)}
</>
)}
{deskError && (
<p className="text-sm text-[var(--muted-rose)] mb-3">Could not load life map scores.</p>
)}
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
Optional curiosity quests pick what interests you this week.
</p>
<div className="flex gap-2 mb-4">
<button
className={`retro-btn ${tab === "active" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("active")}
>
This Week
</button>
<button
className={`retro-btn ${tab === "history" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("history")}
>
History
</button>
</div>
{tab === "active" && (
<>
{isLoading ? (
<p>Loading map...</p>
) : error ? (
<p className="text-sm text-[var(--muted-rose)]">Failed to load explorations.</p>
) : (
<div className="space-y-4">
{suggested.map((e: Exploration) => (
<QuestCard
key={e.id}
exploration={e}
onAccept={() => action.mutate({ id: e.id, action: "accept" })}
onDismiss={() => action.mutate({ id: e.id, action: "dismiss" })}
/>
))}
{active.map((e: Exploration) => (
<QuestCard
key={e.id}
exploration={e}
active
onComplete={() => setCompleteId(e.id)}
/>
))}
{suggested.length === 0 && active.length === 0 && (
<p className="text-sm">
No explorations this week. Generate some quests dismissed quests won&apos;t block new ones.
</p>
)}
</div>
)}
</>
)}
{tab === "history" && (
<div className="flex flex-wrap gap-2">
{history.map((e: Exploration) => (
<span
key={e.id}
className="retro-window-inset px-3 py-1 text-xs"
title={e.completedNote ?? ""}
>
{e.title}
</span>
))}
</div>
)}
{completeId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="retro-window p-4 w-full max-w-md">
<p className="font-bold mb-2">What did you learn?</p>
<input
className="retro-window-inset w-full p-2 mb-4"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="One thing you discovered..."
/>
<button
className="retro-btn retro-btn-primary w-full"
onClick={() => action.mutate({ id: completeId, action: "complete", note })}
>
Complete Exploration
</button>
</div>
</div>
)}
</div>
</AppShell>
);
}
function QuestCard({
exploration,
active,
onAccept,
onDismiss,
onComplete,
}: {
exploration: Exploration;
active?: boolean;
onAccept?: () => void;
onDismiss?: () => void;
onComplete?: () => void;
}) {
return (
<div className="retro-window p-4 relative">
<span className="absolute top-2 right-2 text-xs bg-[var(--gold-trim)] px-2 py-0.5 rounded">
{exploration.category}
</span>
<h3 className="font-bold mb-1 pr-20">{exploration.title}</h3>
<p className="text-sm mb-3">{exploration.description}</p>
{exploration.minutes && (
<p className="text-xs text-[var(--warm-grey)] mb-2">~{exploration.minutes} min</p>
)}
<div className="flex gap-2 flex-wrap">
{active ? (
<>
<button className="retro-btn retro-btn-primary" onClick={onComplete}>
Complete
</button>
<Link
href={`/teacher?explorationId=${exploration.id}&topic=${encodeURIComponent(exploration.title)}`}
className="retro-btn text-xs"
>
Study with Teacher
</Link>
</>
) : (
<>
<button className="retro-btn retro-btn-primary" onClick={onAccept}>
Accept
</button>
<button className="retro-btn" onClick={onDismiss}>
Deny quest
</button>
</>
)}
</div>
</div>
);
}

BIN
apps/web/src/app/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

215
apps/web/src/app/globals.css Executable file
View File

@@ -0,0 +1,215 @@
@import "tailwindcss";
@import "../themes/index.css";
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: calc(14px * var(--density));
color: var(--color-text);
background-color: var(--color-body-bg);
background-image: var(--color-body-bg-image);
background-size: cover;
background-attachment: fixed;
min-height: 100vh;
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: var(--effect-scanlines);
z-index: 9999;
}
@theme inline {
--color-xp-blue: var(--color-accent);
--color-bliss-green: var(--color-positive);
--color-parchment: var(--color-surface);
--color-gold: var(--color-gold);
--color-ink: var(--color-text);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
}
.retro-window {
background: var(--color-surface);
border: 2px solid;
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
var(--color-border-light);
box-shadow: var(--window-shadow);
}
.retro-window-inset {
border: 2px solid;
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
var(--color-border-dark);
background: var(--color-surface-inset);
color: var(--color-text);
}
.retro-titlebar {
background: linear-gradient(
180deg,
var(--color-titlebar-start) 0%,
var(--color-titlebar-mid) 8%,
var(--color-titlebar-end) 100%
);
color: var(--color-accent-text);
padding: 4px 8px;
font-weight: bold;
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.retro-btn {
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%);
border: 2px solid;
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
var(--color-border-light);
padding: 4px 12px;
font-family: var(--font-sans);
font-size: 13px;
color: var(--color-text);
cursor: pointer;
}
.retro-btn:hover {
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-btn-hover) 100%);
}
.retro-btn:active {
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
var(--color-border-dark);
padding: 5px 11px 3px 13px;
}
.retro-btn-primary {
background: linear-gradient(180deg, var(--color-btn-primary-start) 0%, var(--color-accent) 100%);
color: var(--color-accent-text);
border-color: var(--color-btn-primary-border-light) var(--color-btn-primary-border-dark)
var(--color-btn-primary-border-dark) var(--color-btn-primary-border-light);
}
.skill-bar-track {
height: 14px;
background: var(--color-skill-track);
border: 1px solid var(--color-border-dark);
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.3);
position: relative;
overflow: hidden;
}
.skill-bar-fill {
height: 100%;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-skill-fill) 80%, white) 0%,
var(--color-skill-fill) 50%,
color-mix(in srgb, var(--color-skill-fill) 70%, black) 100%
);
border-right: 1px solid var(--color-gold);
transition: width 600ms ease-out;
}
.skill-bar-fill-gold {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-skill-fill-gold) 80%, white) 0%,
var(--color-skill-fill-gold) 50%,
color-mix(in srgb, var(--color-skill-fill-gold) 60%, black) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.skill-bar-fill {
transition: none;
}
}
.serif {
font-family: var(--font-serif);
}
.xp-toast {
animation: slideUp 200ms ease-out, fadeOut 3s ease-in 2s forwards;
}
.action-toast {
animation: slideUp 200ms ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeOut {
to {
opacity: 0;
}
}
.book-spine {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
min-height: 120px;
padding: 8px 4px;
border-radius: 2px 4px 4px 2px;
cursor: pointer;
transition: transform 150ms;
}
.book-spine:hover {
transform: rotate(180deg) translateY(-4px);
}
.parchment-bg {
background: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
}
.theme-preview-card {
cursor: pointer;
transition: transform 150ms, box-shadow 150ms;
}
.theme-preview-card:hover {
transform: translateY(-2px);
}
.theme-preview-card.selected {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.theme-preview-bar {
height: 20px;
border-radius: 2px 2px 0 0;
}
.theme-preview-body {
padding: 8px;
display: flex;
gap: 4px;
}
.theme-preview-swatch {
width: 16px;
height: 16px;
border-radius: 2px;
}

44
apps/web/src/app/layout.tsx Executable file
View File

@@ -0,0 +1,44 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
import { XpToast, ActionToast, BookCelebration, WelcomeBack } from "@/components/retro/overlays";
export const metadata: Metadata = {
title: "AdventureOS",
description: "Your personal command centre for consistency and growth",
manifest: "/manifest.json",
appleWebApp: { capable: true, title: "AdventureOS" },
};
export const viewport: Viewport = {
themeColor: "#3A6EA5",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-theme="minimal-dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var L={xp:"windows-xp-light",win98:"windows-98",mac:"gnome-2",terminal:"retro-terminal"};var V=["minimal-dark","retro-terminal","windows-xp-dark","windows-xp-light","windows-98","classic-kde","gnome-2","runescape","game-boy","early-web-forum","crt-hacker","library"];var c=localStorage.getItem("adventureos-theme");var t="minimal-dark";if(c){t=L[c]||(V.indexOf(c)>=0?c:"minimal-dark");}document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
}}
/>
</head>
<body>
<Providers>
{children}
<XpToast />
<ActionToast />
<BookCelebration />
<WelcomeBack />
</Providers>
</body>
</html>
);
}

425
apps/web/src/app/library/page.tsx Executable file
View File

@@ -0,0 +1,425 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { useUiStore } from "@/stores/ui";
type CalibreBookRow = {
id: number;
title: string;
authors: string[];
currentPage: number;
totalPages: number;
status: string;
progressPercent: number;
hasCover: boolean;
};
export default function LibraryPage() {
const qc = useQueryClient();
const showBookCelebration = useUiStore((s) => s.showBookCelebration);
const showActionToast = useUiStore((s) => s.showActionToast);
const [tab, setTab] = useState<"reading" | "finished">("reading");
const [showAdd, setShowAdd] = useState(false);
const [newBook, setNewBook] = useState({ title: "", author: "", totalPages: 300 });
const [selectedCalibre, setSelectedCalibre] = useState<number | null>(null);
const [selectedManual, setSelectedManual] = useState<string | null>(null);
const [logPages, setLogPages] = useState(10);
const [search, setSearch] = useState("");
const { data: calibreStatus } = useQuery({
queryKey: ["library-status"],
queryFn: async () => {
const res = await fetch("/api/library/status");
return res.json();
},
});
const useCalibre = calibreStatus?.configured && calibreStatus?.online;
const { data: calibreBooks = [], isLoading: calibreLoading } = useQuery<CalibreBookRow[]>({
queryKey: ["library-books", search],
queryFn: async () => {
const params = search ? `?search=${encodeURIComponent(search)}` : "";
const res = await fetch(`/api/library/books${params}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!useCalibre,
});
const { data: manualBooks = [], isLoading: manualLoading } = useQuery({
queryKey: ["books"],
queryFn: async () => {
const res = await fetch("/api/books");
return res.json();
},
enabled: !useCalibre,
});
const addBook = useMutation({
mutationFn: async () => {
const res = await fetch("/api/books", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newBook),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
setShowAdd(false);
},
});
const logManual = useMutation({
mutationFn: async ({ id, pages }: { id: string; pages: number }) => {
const res = await fetch(`/api/books/${id}/log-pages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pages }),
});
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
const book = manualBooks.find((b: { id: string; title: string }) => b.id === vars.id);
if (data.status === "finished" && book) showBookCelebration(book.title);
if (data.actionEventId && book) {
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
}
},
});
const logCalibre = useMutation({
mutationFn: async ({ id, pages }: { id: number; pages: number }) => {
const res = await fetch(`/api/library/progress/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pages }),
});
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["library-books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
const book = calibreBooks.find((b) => b.id === vars.id);
if (data.status === "finished" && book) showBookCelebration(book.title);
if (data.actionEventId && book) {
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
}
},
});
const books = useCalibre ? calibreBooks : manualBooks;
const isLoading = useCalibre ? calibreLoading : manualLoading;
const filtered = books.filter((b: { status: string }) =>
tab === "reading" ? b.status !== "finished" : b.status === "finished"
);
const spineColors = ["#8B4513", "#2F4F4F", "#800020", "#4A3728", "#1B3A5C"];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<div className="flex justify-between items-center mb-4">
<h1 className="font-bold text-lg">The Library Wing</h1>
{!useCalibre && (
<button className="retro-btn retro-btn-primary" onClick={() => setShowAdd(true)}>
Add Book
</button>
)}
</div>
{calibreStatus?.configured === false && (
<div className="retro-window-inset p-3 mb-4 text-sm">
<p className="font-bold mb-1">Calibre not configured</p>
<p className="text-[var(--warm-grey)]">
Set <code>CALIBRE_LIBRARY_PATH</code> in your environment to connect your Calibre library.
Manual book entry is available below.
</p>
<button className="retro-btn text-xs mt-2" onClick={() => setShowAdd(true)}>
Add manual book
</button>
</div>
)}
{calibreStatus?.configured && !calibreStatus.online && (
<div className="retro-window-inset p-3 mb-4 text-sm text-[var(--muted-rose)]">
{calibreStatus.error ?? "Calibre library unavailable"}
</div>
)}
{useCalibre && (
<input
className="retro-window-inset w-full p-2 mb-4 text-sm"
placeholder="Search titles or authors..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<div className="flex gap-2 mb-4">
<button
className={`retro-btn ${tab === "reading" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("reading")}
>
Reading
</button>
<button
className={`retro-btn ${tab === "finished" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("finished")}
>
Completed Shelf
</button>
</div>
{isLoading ? (
<p>Loading shelf...</p>
) : (
<div
className="parchment-bg retro-window-inset p-6 min-h-[200px] flex flex-wrap gap-3 items-end"
style={{ background: "linear-gradient(180deg, #8B6914 0%, #6B4F10 100%)" }}
>
{filtered.length === 0 && (
<p className="text-white/80 text-sm w-full text-center py-8">
{tab === "reading" ? "No books yet." : "Completed books appear here."}
</p>
)}
{useCalibre
? filtered.map((book: CalibreBookRow, i: number) => (
<button
key={book.id}
onClick={() => setSelectedCalibre(book.id)}
className="book-spine text-white text-xs font-bold shadow-md relative overflow-hidden"
style={{
backgroundColor: spineColors[i % spineColors.length],
width: 36,
minHeight: 120,
}}
title={book.title}
>
{book.hasCover && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/library/cover/${book.id}`}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-40"
/>
)}
<span className="relative">{book.title.slice(0, 20)}</span>
</button>
))
: filtered.map(
(book: { id: string; title: string }, i: number) => (
<button
key={book.id}
onClick={() => setSelectedManual(book.id)}
className="book-spine text-white text-xs font-bold shadow-md"
style={{
backgroundColor: spineColors[i % spineColors.length],
width: 36,
}}
title={book.title}
>
{book.title.slice(0, 20)}
</button>
)
)}
</div>
)}
{useCalibre && calibreStatus?.bookCount != null && (
<p className="text-xs text-[var(--warm-grey)] mt-2">
{calibreStatus.bookCount} books in Calibre catalog
</p>
)}
{showAdd && (
<Modal onClose={() => setShowAdd(false)} title="Add Book">
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Title"
value={newBook.title}
onChange={(e) => setNewBook({ ...newBook, title: e.target.value })}
/>
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Author"
value={newBook.author}
onChange={(e) => setNewBook({ ...newBook, author: e.target.value })}
/>
<input
type="number"
className="retro-window-inset w-full p-2 mb-4"
placeholder="Total pages"
value={newBook.totalPages}
onChange={(e) => setNewBook({ ...newBook, totalPages: Number(e.target.value) })}
/>
<button className="retro-btn retro-btn-primary w-full" onClick={() => addBook.mutate()}>
Add to Shelf
</button>
</Modal>
)}
{selectedCalibre != null && (
<CalibreBookDetail
book={calibreBooks.find((b) => b.id === selectedCalibre)}
logPages={logPages}
setLogPages={setLogPages}
onLog={() => logCalibre.mutate({ id: selectedCalibre, pages: logPages })}
onClose={() => setSelectedCalibre(null)}
/>
)}
{selectedManual && (
<ManualBookDetail
book={manualBooks.find((b: { id: string }) => b.id === selectedManual)}
logPages={logPages}
setLogPages={setLogPages}
onLog={() => logManual.mutate({ id: selectedManual, pages: logPages })}
onClose={() => setSelectedManual(null)}
/>
)}
</div>
</AppShell>
);
}
function CalibreBookDetail({
book,
logPages,
setLogPages,
onLog,
onClose,
}: {
book?: CalibreBookRow;
logPages: number;
setLogPages: (n: number) => void;
onLog: () => void;
onClose: () => void;
}) {
if (!book) return null;
const pct = book.progressPercent;
return (
<Modal onClose={onClose} title={book.title}>
{book.authors.length > 0 && (
<p className="text-sm text-[var(--warm-grey)] mb-2">{book.authors.join(", ")}</p>
)}
<p className="mb-2">
Page {book.currentPage} of {book.totalPages} ({pct}%)
</p>
<div className="skill-bar-track mb-4">
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
</div>
{book.status !== "finished" && (
<div className="flex gap-2 items-center">
<input
type="number"
className="retro-window-inset w-20 p-2"
value={logPages}
onChange={(e) => setLogPages(Number(e.target.value))}
/>
<button className="retro-btn retro-btn-primary" onClick={onLog}>
Log pages
</button>
<button
className="retro-btn"
onClick={() => {
setLogPages(10);
onLog();
}}
>
+10
</button>
</div>
)}
</Modal>
);
}
function ManualBookDetail({
book,
logPages,
setLogPages,
onLog,
onClose,
}: {
book?: {
title: string;
author: string | null;
currentPage: number;
totalPages: number;
status: string;
};
logPages: number;
setLogPages: (n: number) => void;
onLog: () => void;
onClose: () => void;
}) {
if (!book) return null;
const pct = Math.round((book.currentPage / book.totalPages) * 100);
return (
<Modal onClose={onClose} title={book.title}>
{book.author && <p className="text-sm text-[var(--warm-grey)] mb-2">{book.author}</p>}
<p className="mb-2">
Page {book.currentPage} of {book.totalPages} ({pct}%)
</p>
<div className="skill-bar-track mb-4">
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
</div>
{book.status !== "finished" && (
<div className="flex gap-2 items-center">
<input
type="number"
className="retro-window-inset w-20 p-2"
value={logPages}
onChange={(e) => setLogPages(Number(e.target.value))}
/>
<button className="retro-btn retro-btn-primary" onClick={onLog}>
Log pages
</button>
<button
className="retro-btn"
onClick={() => {
setLogPages(10);
onLog();
}}
>
+10
</button>
</div>
)}
</Modal>
);
}
function Modal({
children,
title,
onClose,
}: {
children: React.ReactNode;
title: string;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="retro-window w-full max-w-md p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3 flex justify-between">
<span>{title}</span>
<button onClick={onClose} className="text-white hover:opacity-80">
</button>
</div>
{children}
</div>
</div>
);
}

48
apps/web/src/app/login/page.tsx Executable file
View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/");
router.refresh();
} else {
setError("Invalid password");
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={handleSubmit} className="retro-window w-full max-w-sm p-6">
<div className="retro-titlebar -mx-6 -mt-6 mb-6 px-4">AdventureOS</div>
<p className="serif text-center mb-4 text-sm">
Welcome, traveler. Enter to continue your adventure.
</p>
<input
type="password"
className="retro-window-inset w-full p-2 mb-4"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-[var(--muted-rose)] text-sm mb-2">{error}</p>}
<button type="submit" className="retro-btn retro-btn-primary w-full">
Enter Command Centre
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/layout/app-shell";
import Link from "next/link";
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?",
];
export default function MentorPage() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
const bottomRef = useRef<HTMLDivElement>(null);
const { data: sessionsData } = useQuery({
queryKey: ["chat-sessions"],
queryFn: async () => {
const res = await fetch("/api/ai/chat/sessions");
return res.json();
},
});
useEffect(() => {
if (!sessionId && sessionsData?.sessions?.[0]) {
setSessionId(sessionsData.sessions[0].id);
} else if (!sessionId) {
fetch("/api/ai/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((d) => setSessionId(d.id));
}
}, [sessionId, sessionsData]);
const { data: chatData, refetch } = useQuery({
queryKey: ["chat-session", sessionId],
queryFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
return res.json();
},
enabled: !!sessionId,
});
const { data: knowsData } = useQuery({
queryKey: ["ai-knows"],
queryFn: async () => {
const res = await fetch("/api/ai/chat/knows");
return res.json();
},
enabled: tab === "knows",
});
const { data: previewData, refetch: refetchPreview } = useQuery({
queryKey: ["context-preview", input],
queryFn: async () => {
const params = new URLSearchParams({ message: input || "hello", feature: "mentor" });
const res = await fetch(`/api/ai/context/preview?${params}`);
return res.json();
},
enabled: tab === "preview",
});
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Send failed");
return res.json();
},
onSuccess: () => {
refetch();
setInput("");
},
});
const newSession = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
return res.json();
},
onSuccess: (d) => setSessionId(d.id),
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatData?.messages?.length]);
const messages = chatData?.messages ?? [];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto">
<div className="flex flex-wrap gap-2 mb-4 items-center">
<h1 className="font-bold text-lg">Mentor</h1>
<Link href="/settings?section=ai-memory" className="text-xs underline text-[var(--warm-grey)]">
Edit AI Memory
</Link>
</div>
<div className="flex gap-1 mb-4">
{(["chat", "knows", "preview"] as const).map((t) => (
<button
key={t}
type="button"
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t === "knows" ? "What I know" : t === "preview" ? "Context preview" : "Chat"}
</button>
))}
</div>
{tab === "chat" && (
<div className="grid md:grid-cols-4 gap-4">
<div className="retro-window p-2 md:col-span-1 max-h-64 overflow-y-auto">
<button type="button" className="retro-btn text-xs w-full mb-2" onClick={() => newSession.mutate()}>
New conversation
</button>
{(sessionsData?.sessions ?? []).map((s: { id: string; title: string }) => (
<button
key={s.id}
type="button"
className={`block w-full text-left text-xs p-1 truncate ${sessionId === s.id ? "font-bold" : ""}`}
onClick={() => setSessionId(s.id)}
>
{s.title}
</button>
))}
</div>
<div className="retro-window md:col-span-3 flex flex-col min-h-[400px]">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => (
<div key={m.id} className={`p-3 rounded text-sm ${m.role === "user" ? "bg-white/50 ml-8" : "bg-[var(--xp-blue)]/10 mr-8"}`}>
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span>
{m.metadata?.offline && (
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
)}
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="p-2 flex flex-wrap gap-1 border-t">
{SUGGESTED.map((q) => (
<button
key={q}
type="button"
className="retro-btn text-[10px]"
onClick={() => send.mutate(q)}
disabled={send.isPending}
>
{q}
</button>
))}
</div>
<div className="p-3 flex gap-2 border-t">
<input
className="retro-window-inset flex-1 p-2 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
placeholder="Ask the mentor..."
/>
<button
type="button"
className="retro-btn retro-btn-primary"
disabled={!input.trim() || send.isPending}
onClick={() => send.mutate(input.trim())}
>
Send
</button>
</div>
</div>
</div>
)}
{tab === "knows" && knowsData && (
<div className="retro-window p-4 space-y-4">
{knowsData.summary && (
<div>
<h2 className="font-bold text-sm mb-1">Profile summary</h2>
<p className="text-sm whitespace-pre-wrap">{knowsData.summary}</p>
</div>
)}
{knowsData.grouped?.map((g: { category: string; label: string; items: { title: string; content: string }[] }) => (
<div key={g.category}>
<h3 className="font-bold text-sm">{g.label}</h3>
<ul className="text-sm list-disc pl-4">
{g.items.map((m) => (
<li key={m.title}>{m.title}: {m.content}</li>
))}
</ul>
</div>
))}
{!knowsData.summary && !knowsData.grouped?.length && (
<p className="text-sm italic">No memories saved yet. Add some in Settings AI Memory.</p>
)}
</div>
)}
{tab === "preview" && (
<div className="retro-window p-4 space-y-3">
<input
className="retro-window-inset w-full p-2 text-sm"
placeholder="Sample message to preview context..."
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}>
Refresh preview
</button>
<pre className="text-xs whitespace-pre-wrap bg-black/5 p-3 rounded max-h-96 overflow-y-auto">
{previewData?.formatted ?? "Loading..."}
</pre>
<p className="text-[10px] text-[var(--warm-grey)]">
~{previewData?.tokenEstimate ?? 0} tokens estimated · {previewData?.memoryIds?.length ?? 0} memories selected
</p>
</div>
)}
</div>
</AppShell>
);
}

122
apps/web/src/app/page.tsx Executable file
View File

@@ -0,0 +1,122 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { AppShell } from "@/components/layout/app-shell";
import { CharacterCard } from "@/components/features/character-card";
import { TodaysAdventure } from "@/components/features/todays-adventure";
import { DailyReflection } from "@/components/features/daily-reflection";
import { QuestGiverSidebar, ReadingWidget } from "@/components/features/sidebar-widgets";
import { DaySwitcher } from "@/components/features/day-switcher";
import { CatchUpCard } from "@/components/features/catch-up-card";
import { QuickLogPanel } from "@/components/features/quick-log-panel";
import { formatDisplayDate, todayString } from "@/lib/dates-client";
async function fetchDashboard(date?: string) {
const url = date ? `/api/dashboard?date=${date}` : "/api/dashboard";
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to load dashboard");
return res.json();
}
export default function HomePage() {
const [activeDate, setActiveDate] = useState<string | undefined>(undefined);
const { data, isLoading, error } = useQuery({
queryKey: ["dashboard", activeDate],
queryFn: () => fetchDashboard(activeDate),
});
if (isLoading) {
return (
<AppShell>
<div className="p-8 text-center">Loading your adventure...</div>
</AppShell>
);
}
if (error || !data) {
return (
<AppShell>
<div className="p-8 text-center text-[var(--muted-rose)]">
Could not load dashboard. Is the database running?
</div>
</AppShell>
);
}
const date = data.today?.date ?? data.dateContext?.logicalToday ?? todayString();
const ctx = data.dateContext;
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
{ctx && (
<DaySwitcher
activeDate={date}
logicalToday={ctx.logicalToday}
logicalYesterday={ctx.logicalYesterday}
isGraceWindow={ctx.isGraceWindow}
onSelectDate={setActiveDate}
/>
)}
<p className="text-sm text-[var(--warm-grey)] mb-4">
{formatDisplayDate(date)}
</p>
{data.catchUpGaps?.length > 0 && (
<CatchUpCard gaps={data.catchUpGaps} onSelectDate={setActiveDate} />
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
<div className="lg:col-span-4">
<CharacterCard
displayName={data.user.displayName}
currentTitle={data.user.currentTitle}
level={data.progress.level}
totalXp={data.progress.totalXp}
portraitConfig={data.user.portraitConfig}
scores={{
consistencyScore: data.progress.consistencyScore,
disciplineScore: data.progress.disciplineScore,
learningScore: data.progress.learningScore,
spiritualScore: data.progress.spiritualScore,
healthScore: data.progress.healthScore,
readingScore: data.progress.readingScore,
}}
/>
</div>
<div className="lg:col-span-5 space-y-4">
{data.today && (
<TodaysAdventure
date={date}
chapter={data.progress.currentChapter}
journeyDay={data.progress.journeyDay}
isRestDay={data.today.isRestDay}
isCustomized={data.today.isCustomized}
workHoursTarget={data.today.workHoursTarget}
dayMode={data.today.dayMode ?? "normal"}
isBackfilled={data.today.isBackfilled ?? false}
items={data.today.items}
todos={data.today.todos}
/>
)}
<QuickLogPanel date={date} />
<DailyReflection
date={date}
initial={data.reflection}
dateLabel={formatDisplayDate(date)}
/>
</div>
<div className="lg:col-span-3 space-y-4">
<QuestGiverSidebar suggestions={data.suggestions} />
<ReadingWidget
activeBooks={data.reading.activeBooks}
streak={data.reading.streak}
weeklyPages={data.reading.weeklyPages}
weeklyGoal={data.reading.weeklyGoal}
/>
</div>
</div>
</div>
</AppShell>
);
}

168
apps/web/src/app/review/page.tsx Executable file
View File

@@ -0,0 +1,168 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { weekStartString } from "@/lib/dates-client";
import { useUiStore } from "@/stores/ui";
export default function ReviewPage() {
const weekStart = weekStartString();
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const [intention, setIntention] = useState("");
const [page, setPage] = useState(0);
const { data: review, isLoading } = useQuery({
queryKey: ["review", weekStart],
queryFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`);
const data = await res.json();
if (!data || data.error) return null;
return data;
},
});
const generate = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate" }),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["review", weekStart] });
showXpToast(150, "Weekly review opened");
},
});
const saveIntention = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intention }),
});
return res.json();
},
});
if (isLoading) {
return (
<AppShell>
<div className="p-8 text-center">Turning the page...</div>
</AppShell>
);
}
if (!review) {
return (
<AppShell>
<div className="p-8 text-center max-w-md mx-auto">
<h1 className="font-bold text-lg mb-4">Weekly Review</h1>
<p className="serif mb-4">
Your weekly chapter is ready to be written. Generate your review to see patterns,
progress, and a letter from the Guide.
</p>
<button className="retro-btn retro-btn-primary" onClick={() => generate.mutate()}>
Generate This Week&apos;s Review
</button>
</div>
</AppShell>
);
}
const content = review.content as Record<string, unknown>;
const pages = [
{
title: "XP Earned",
body: (
<div>
<p className="text-3xl font-bold text-[var(--gold-trim)] mb-2">
+{review.xpEarned} XP
</p>
<ul className="text-sm space-y-1">
{((content.patterns as string[]) ?? []).map((p, i) => (
<li key={i}>· {p}</li>
))}
</ul>
</div>
),
},
{
title: "Encouragement",
body: <p className="serif">{content.encouragement as string}</p>,
},
{
title: "Mentor's Letter",
body: (
<div className="serif text-base leading-relaxed whitespace-pre-wrap">
{review.mentorLetter}
</div>
),
},
{
title: "Next Week",
body: (
<div>
<p className="mb-2 text-sm">{content.focus_suggestion as string}</p>
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Your one intention for next week..."
value={intention || review.userIntention || ""}
onChange={(e) => setIntention(e.target.value)}
onBlur={() => saveIntention.mutate()}
/>
</div>
),
},
];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 min-h-full parchment-bg">
<div className="max-w-4xl mx-auto retro-window p-6 md:p-10">
<div className="text-center mb-8 border-b border-[var(--warm-grey)] pb-4">
<p className="text-xs text-[var(--warm-grey)]">Week of {weekStart}</p>
<h1 className="serif text-2xl font-bold">Turning the Page</h1>
</div>
<div className="min-h-[300px]">
<h2 className="font-bold mb-4">{pages[page].title}</h2>
{pages[page].body}
</div>
<div className="flex justify-between items-center mt-8">
<button
className="retro-btn"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Previous
</button>
<div className="flex gap-1">
{pages.map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full ${
i === page ? "bg-[var(--xp-blue)]" : "bg-gray-300"
}`}
onClick={() => setPage(i)}
/>
))}
</div>
<button
className="retro-btn"
disabled={page === pages.length - 1}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,310 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { ThemeGallery } from "@/components/theme/theme-gallery";
import { TemplateEditor } from "@/components/features/template-editor";
import { AiHealthPanel } from "@/components/settings/ai-health-panel";
import { AiConfigPanel } from "@/components/settings/ai-config-panel";
import {
PromptTemplateEditor,
SystemPromptsPanel,
} from "@/components/settings/prompt-template-editor";
import { ActionHistoryPanel } from "@/components/settings/action-history-panel";
import { AiMemoryPanel } from "@/components/settings/ai-memory-panel";
const SECTIONS = [
{ id: "profile", label: "Profile" },
{ id: "spiritual", label: "Spiritual Labels" },
{ id: "templates", label: "Templates" },
{ id: "appearance", label: "Appearance" },
{ id: "ai-config", label: "AI Configuration" },
{ id: "ai-memory", label: "AI Memory" },
{ id: "ai-templates", label: "AI Templates" },
{ id: "system-prompts", label: "System Prompts" },
{ id: "ai-health", label: "AI Health" },
{ id: "action-history", label: "Action History" },
{ id: "notifications", label: "Notifications" },
{ id: "data", label: "Data Export" },
];
export default function SettingsPage() {
return (
<Suspense fallback={<AppShell><div className="p-8">Loading settings...</div></AppShell>}>
<SettingsPageContent />
</Suspense>
);
}
function SettingsPageContent() {
const searchParams = useSearchParams();
const [section, setSection] = useState("profile");
const qc = useQueryClient();
useEffect(() => {
const s = searchParams.get("section");
if (s && SECTIONS.some((sec) => sec.id === s)) setSection(s);
}, [searchParams]);
const { data } = useQuery({
queryKey: ["settings"],
queryFn: async () => {
const res = await fetch("/api/settings");
return res.json();
},
});
const [profile, setProfile] = useState({ displayName: "", currentTitle: "" });
const [spiritual, setSpiritual] = useState({
prayerLabels: [] as string[],
litanyLabels: [] as string[],
});
const [readingGoal, setReadingGoal] = useState(50);
const [soundsEnabled, setSoundsEnabled] = useState(false);
useEffect(() => {
if (data?.user) {
setProfile({
displayName: data.user.displayName,
currentTitle: data.user.currentTitle ?? "",
});
}
if (data?.spiritual) {
setSpiritual({
prayerLabels: data.spiritual.prayerLabels,
litanyLabels: data.spiritual.litanyLabels,
});
}
if (data?.settings) {
setReadingGoal((data.settings.weekly_reading_goal as number) ?? 50);
setSoundsEnabled((data.settings.sounds_enabled as boolean) ?? false);
}
}, [data]);
const save = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 flex flex-col md:flex-row gap-4 min-h-full">
<div className="retro-window w-full md:w-48 flex-shrink-0">
<div className="retro-titlebar">Control Panel</div>
<nav className="p-2 max-h-[70vh] overflow-y-auto">
{SECTIONS.map((s) => (
<button
key={s.id}
className={`block w-full text-left px-2 py-1.5 text-sm hover:bg-white/40 ${
section === s.id ? "bg-white/60 font-bold" : ""
}`}
onClick={() => setSection(s.id)}
>
{s.label}
</button>
))}
</nav>
</div>
<div className="retro-window flex-1 p-4">
{section === "profile" && (
<div className="space-y-3">
<h2 className="font-bold">Profile</h2>
<Field label="Display Name" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} />
<Field label="Title" value={profile.currentTitle} onChange={(v) => setProfile({ ...profile, currentTitle: v })} />
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ profile })}>
Save
</button>
</div>
)}
{section === "spiritual" && (
<div className="space-y-3">
<h2 className="font-bold">Spiritual Labels</h2>
<p className="text-xs text-[var(--color-text-muted)]">Customize your prayer and litany checkboxes.</p>
{spiritual.prayerLabels.map((l, i) => (
<Field
key={`p-${i}`}
label={`Prayer ${i + 1}`}
value={l}
onChange={(v) => {
const next = [...spiritual.prayerLabels];
next[i] = v;
setSpiritual({ ...spiritual, prayerLabels: next });
}}
/>
))}
{spiritual.litanyLabels.map((l, i) => (
<Field
key={`l-${i}`}
label={`Litany ${i + 1}`}
value={l}
onChange={(v) => {
const next = [...spiritual.litanyLabels];
next[i] = v;
setSpiritual({ ...spiritual, litanyLabels: next });
}}
/>
))}
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ spiritual })}>
Save
</button>
</div>
)}
{section === "templates" && <TemplateEditor />}
{section === "appearance" && <ThemeGallery />}
{section === "ai-config" && <AiConfigPanel />}
{section === "ai-memory" && (
<div>
<h2 className="font-bold mb-3">AI Memory</h2>
<AiMemoryPanel />
</div>
)}
{section === "ai-templates" && <PromptTemplateEditor />}
{section === "system-prompts" && <SystemPromptsPanel />}
{section === "ai-health" && <AiHealthPanel />}
{section === "action-history" && <ActionHistoryPanel />}
{section === "notifications" && (
<div className="space-y-6">
<DayBoundarySettings />
<div className="space-y-3">
<h2 className="font-bold">Notifications</h2>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={soundsEnabled}
onChange={(e) => setSoundsEnabled(e.target.checked)}
/>
UI sounds (off by default)
</label>
<Field
label="Weekly reading goal (pages)"
value={String(readingGoal)}
onChange={(v) => setReadingGoal(Number(v))}
/>
<button
className="retro-btn retro-btn-primary"
onClick={() =>
save.mutate({
settings: {
sounds_enabled: soundsEnabled,
weekly_reading_goal: readingGoal,
},
})
}
>
Save
</button>
</div>
</div>
)}
{section === "data" && (
<div className="space-y-3">
<h2 className="font-bold">Data Export</h2>
<a href="/api/export/json" className="retro-btn retro-btn-primary inline-block" download>
Download JSON Backup
</a>
<p className="text-xs text-[var(--color-text-muted)]">
Nightly pg_dump backups can be configured via scripts/backup.sh on your server.
</p>
</div>
)}
<div className="mt-8 pt-4 border-t">
<button
className="retro-btn text-sm"
onClick={async () => {
await fetch("/api/auth/login", { method: "DELETE" });
window.location.href = "/login";
}}
>
Sign Out
</button>
</div>
</div>
</div>
</AppShell>
);
}
function DayBoundarySettings() {
const [hour, setHour] = useState(0);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
fetch("/api/settings/day-boundary")
.then((r) => r.json())
.then((d) => {
setHour(d.hour ?? 0);
setLoaded(true);
});
}, []);
const save = async () => {
await fetch("/api/settings/day-boundary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hour }),
});
};
if (!loaded) return null;
return (
<div className="space-y-3">
<h2 className="font-bold">Day boundary</h2>
<p className="text-xs text-[var(--warm-grey)]">
When your day rolls over. Useful if you often log after midnight.
</p>
<label className="flex items-center gap-2 text-sm">
Day starts at
<select
className="retro-window-inset p-1"
value={hour}
onChange={(e) => setHour(Number(e.target.value))}
>
<option value={0}>Midnight (12am)</option>
<option value={1}>1:00am</option>
<option value={2}>2:00am</option>
<option value={3}>3:00am</option>
</select>
</label>
<button type="button" className="retro-btn retro-btn-primary text-sm" onClick={save}>
Save day boundary
</button>
</div>
);
}
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<label className="text-xs font-bold block mb-1">{label}</label>
<input
className="retro-window-inset w-full p-2"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { SimpleBarChart, SimpleLineChart, HeatmapGrid } from "@/components/charts/simple-charts";
const TABS = ["overview", "reading", "work", "exercise", "spiritual", "learning"] as const;
export default function StatisticsPage() {
const [tab, setTab] = useState<(typeof TABS)[number]>("overview");
const { data: overview } = useQuery({
queryKey: ["stats", "overview"],
queryFn: async () => {
const res = await fetch("/api/stats/overview");
return res.json();
},
enabled: tab === "overview",
});
const { data: domain } = useQuery({
queryKey: ["stats", tab],
queryFn: async () => {
const res = await fetch(`/api/stats/${tab}?range=30`);
return res.json();
},
enabled: tab !== "overview",
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-4">Statistics Hall</h1>
<div className="flex flex-wrap gap-1 mb-4">
{TABS.map((t) => (
<button
key={t}
className={`retro-btn text-xs capitalize ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t}
</button>
))}
<a href="/yearly" className="retro-btn text-xs">
Year in Adventure
</a>
</div>
<div className="retro-window p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">Performance Monitor</div>
{tab === "overview" && overview && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<StatBox label="Total XP" value={overview.totalXp} />
<StatBox label="XP This Month" value={overview.xpThisMonth} />
<StatBox label="Books Done" value={overview.booksCompleted} />
<StatBox label="Pages This Month" value={overview.pagesThisMonth} />
</div>
<ChartFrame title="Consistency (30 days)">
<SimpleLineChart data={overview.consistencyTrend} />
</ChartFrame>
<ChartFrame title="XP by Category">
<SimpleBarChart
data={overview.xpByCategory.map((c: { category: string; amount: number }) => ({
label: c.category,
amount: c.amount,
}))}
dataKey="amount"
color="#3A6EA5"
/>
</ChartFrame>
</div>
)}
{tab === "reading" && domain && (
<ChartFrame title="Pages per day">
<SimpleBarChart data={domain.days} dataKey="pages" color="#C8A951" />
</ChartFrame>
)}
{tab === "work" && domain && (
<ChartFrame title="Work hours">
<SimpleBarChart data={domain.days} dataKey="hours" color="#3A6EA5" />
</ChartFrame>
)}
{tab === "exercise" && domain && (
<ChartFrame title="Exercise sessions">
<HeatmapGrid data={domain.days?.slice(-28) ?? []} />
</ChartFrame>
)}
{tab === "spiritual" && domain && (
<ChartFrame title="Prayer checks per day">
<SimpleBarChart data={domain.days} dataKey="prayer" color="#3A6EA5" />
</ChartFrame>
)}
{tab === "learning" && domain && (
<ChartFrame title="Classes attended">
<SimpleBarChart data={domain.days} dataKey="classes" color="#74B749" />
</ChartFrame>
)}
</div>
</div>
</AppShell>
);
}
function StatBox({ label, value }: { label: string; value: number }) {
return (
<div className="retro-window-inset p-3">
<p className="text-xs text-[var(--warm-grey)]">{label}</p>
<p className="text-xl font-bold">{value.toLocaleString()}</p>
</div>
);
}
function ChartFrame({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border-4 border-gray-500 rounded-lg p-2 bg-gray-800">
<p className="text-white text-xs mb-2 font-mono">{title}</p>
<div className="bg-[#1a1a2e] p-2 rounded">{children}</div>
</div>
);
}

328
apps/web/src/app/teacher/page.tsx Executable file
View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { AppShell } from "@/components/layout/app-shell";
type LessonContent = {
title?: string;
introduction?: string;
objectives?: string[];
readingSteps?: string[];
reflectionPrompt?: string;
flashcards: { front: string; back: string }[];
quiz: { question: string; options: string[]; answer: number }[];
assignment: string;
};
type Lesson = {
id: string;
topic: string;
content: LessonContent;
status: string;
completedNote?: string | null;
createdAt: string;
source?: "ai" | "fallback";
fallbackReason?: "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
};
export default function TeacherPage() {
return (
<Suspense fallback={<AppShell><div className="p-8">Loading...</div></AppShell>}>
<TeacherPageContent />
</Suspense>
);
}
function TeacherPageContent() {
const searchParams = useSearchParams();
const qc = useQueryClient();
const [topic, setTopic] = useState("");
const [explorationId, setExplorationId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [content, setContent] = useState<LessonContent | null>(null);
const [contentSource, setContentSource] = useState<"ai" | "fallback" | null>(null);
const [fallbackReason, setFallbackReason] = useState<string | null>(null);
const [revealedQuiz, setRevealedQuiz] = useState<Record<number, number | null>>({});
const [completionNote, setCompletionNote] = useState("");
useEffect(() => {
const t = searchParams.get("topic");
const e = searchParams.get("explorationId");
if (t) setTopic(t);
if (e) setExplorationId(e);
}, [searchParams]);
const { data: history = [], isLoading } = useQuery<Lesson[]>({
queryKey: ["teacher"],
queryFn: async () => {
const res = await fetch("/api/teacher");
if (!res.ok) throw new Error("Failed to load lessons");
return res.json();
},
});
const loadLesson = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/teacher/${id}`);
if (!res.ok) throw new Error("Failed to load lesson");
return res.json();
},
onSuccess: (data: Lesson) => {
setSelectedId(data.id);
setContent(data.content);
setContentSource(data.source ?? null);
setFallbackReason(data.fallbackReason ?? null);
setTopic(data.topic);
setRevealedQuiz({});
setCompletionNote(data.completedNote ?? "");
},
});
const generate = useMutation({
mutationFn: async () => {
const res = await fetch("/api/teacher", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic,
...(explorationId ? { explorationId } : {}),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? "Generation failed");
}
return res.json();
},
onSuccess: (data: Lesson) => {
setContent(data.content);
setContentSource(data.source ?? null);
setFallbackReason(data.fallbackReason ?? null);
setSelectedId(data.id);
setRevealedQuiz({});
qc.invalidateQueries({ queryKey: ["teacher"] });
},
});
const complete = useMutation({
mutationFn: async () => {
if (!selectedId) throw new Error("No lesson selected");
const res = await fetch(`/api/teacher/${selectedId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "complete", completedNote: completionNote }),
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-2">The Teacher</h1>
<p className="text-sm text-[var(--warm-grey)] mb-4">
Ask the Guide to create flashcards, quizzes, and research assignments on any topic.
</p>
<div className="flex gap-2 mb-6">
<input
className="retro-window-inset flex-1 p-2"
placeholder="e.g. How Roman roads were built"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
<button
className="retro-btn retro-btn-primary"
onClick={() => generate.mutate()}
disabled={!topic || generate.isPending}
>
{generate.isPending ? "Teaching..." : "Teach Me"}
</button>
</div>
{generate.isError && (
<p className="text-sm text-[var(--muted-rose)] mb-4">
{generate.error instanceof Error
? generate.error.message
: "Could not generate lesson. Check AI Health."}
</p>
)}
{contentSource === "fallback" && (
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
{fallbackReason === "timeout"
? "AI timed out — your model may be too large for this machine. Try a smaller model (e.g. llama3.2:1b) in Settings or .env."
: fallbackReason === "generation_failed"
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template."
: "AI is offline or unavailable — showing a basic offline lesson template."}
</p>
)}
{content && (
<div className="space-y-4">
{content.title && (
<h2 className="font-bold text-base">{content.title}</h2>
)}
{content.introduction && (
<Section title="Introduction">
<p className="serif text-sm">{content.introduction}</p>
</Section>
)}
{content.objectives && content.objectives.length > 0 && (
<Section title="Learning Objectives">
<ul className="text-sm list-disc pl-5 space-y-1">
{content.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ul>
</Section>
)}
{content.readingSteps && content.readingSteps.length > 0 && (
<Section title="Reading & Research">
<ol className="text-sm list-decimal pl-5 space-y-1">
{content.readingSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</Section>
)}
<Section title="Flashcards">
<div className="grid gap-2">
{content.flashcards.map((c, i) => (
<Flashcard key={i} front={c.front} back={c.back} />
))}
</div>
</Section>
<Section title="Quiz">
{content.quiz.map((q, i) => (
<QuizQuestion
key={i}
question={q.question}
options={q.options}
answer={q.answer}
selected={revealedQuiz[i] ?? null}
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
/>
))}
</Section>
<Section title="Research Assignment">
<p className="serif">{content.assignment}</p>
{content.reflectionPrompt && (
<p className="text-sm italic mt-2 text-[var(--warm-grey)]">
Reflection: {content.reflectionPrompt}
</p>
)}
{selectedId && (
<div className="mt-4">
<textarea
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
placeholder="What did you learn from this assignment?"
value={completionNote}
onChange={(e) => setCompletionNote(e.target.value)}
/>
<button
className="retro-btn retro-btn-primary mt-2 text-sm"
onClick={() => complete.mutate()}
disabled={complete.isPending}
>
Mark assignment complete
</button>
</div>
)}
</Section>
</div>
)}
{history.length > 0 && (
<div className="mt-8">
<h2 className="font-bold mb-2">Past Lessons</h2>
{isLoading ? (
<p className="text-sm">Loading...</p>
) : (
<ul className="text-sm space-y-1">
{history.map((h) => (
<li key={h.id}>
<button
className={`underline text-left ${selectedId === h.id ? "font-bold" : ""}`}
onClick={() => loadLesson.mutate(h.id)}
>
{h.status === "completed" ? "✓ " : ""}
{h.topic}
</button>
<span className="text-xs text-[var(--warm-grey)] ml-2">
{new Date(h.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</AppShell>
);
}
function Flashcard({ front, back }: { front: string; back: string }) {
const [flipped, setFlipped] = useState(false);
return (
<button
className="retro-window-inset p-3 text-left w-full"
onClick={() => setFlipped(!flipped)}
>
<p className="font-bold text-sm">{flipped ? back : front}</p>
<p className="text-xs text-[var(--warm-grey)] mt-1">{flipped ? "Back" : "Tap to flip"}</p>
</button>
);
}
function QuizQuestion({
question,
options,
answer,
selected,
onSelect,
}: {
question: string;
options: string[];
answer: number;
selected: number | null;
onSelect: (idx: number) => void;
}) {
return (
<div className="retro-window-inset p-3 mb-2">
<p className="font-bold text-sm mb-2">{question}</p>
<ul className="text-sm space-y-1">
{options.map((o, j) => {
const picked = selected === j;
const showResult = selected !== null;
const isCorrect = j === answer;
let cls = "";
if (showResult && picked && isCorrect) cls = "text-[var(--bliss-green)] font-bold";
if (showResult && picked && !isCorrect) cls = "text-[var(--muted-rose)]";
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
return (
<li key={j}>
<button className={`text-left ${cls}`} onClick={() => onSelect(j)}>
{String.fromCharCode(65 + j)}. {o}
</button>
</li>
);
})}
</ul>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="retro-window p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">{title}</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
export default function YearlyPage() {
const year = new Date().getFullYear();
const { data: overview } = useQuery({
queryKey: ["stats", "overview"],
queryFn: async () => {
const res = await fetch("/api/stats/overview");
return res.json();
},
});
const { data: achievements } = useQuery({
queryKey: ["achievements"],
queryFn: async () => {
const res = await fetch("/api/achievements");
return res.json();
},
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-[var(--warm-grey)] mb-2">{year}</p>
<h1 className="serif text-4xl font-bold mb-6">Your Year in Adventure</h1>
<div className="retro-window p-8 text-left space-y-6">
<ScrollSection title="Total XP" value={overview?.totalXp?.toLocaleString() ?? "—"} />
<ScrollSection title="Books Completed" value={overview?.booksCompleted ?? "—"} />
<ScrollSection title="Pages Read" value={overview?.totalPagesRead?.toLocaleString() ?? "—"} />
<ScrollSection
title="Achievements Unlocked"
value={achievements?.unlocked?.length ?? 0}
/>
<p className="serif text-center pt-6 border-t italic">
Another year on the long road. The adventure continues.
</p>
</div>
</div>
</div>
</AppShell>
);
}
function ScrollSection({ title, value }: { title: string; value: string | number }) {
return (
<div className="flex justify-between items-baseline border-b border-[var(--parchment-dark)] pb-3">
<span className="font-bold">{title}</span>
<span className="text-2xl text-[var(--gold-trim)]">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
interface BarChartProps {
data: { label?: string; date?: string; value?: number; amount?: number; hours?: number; pages?: number }[];
dataKey: string;
max?: number;
color?: string;
}
export function SimpleBarChart({ data, dataKey, max, color = "#74B749" }: BarChartProps) {
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
const peak = max ?? Math.max(...values, 1);
return (
<div className="flex items-end gap-0.5 h-[200px] pt-4">
{values.map((v, i) => (
<div
key={i}
className="flex-1 min-w-[4px] rounded-t-sm transition-all"
style={{
height: `${(v / peak) * 100}%`,
backgroundColor: color,
minHeight: v > 0 ? 2 : 0,
}}
title={`${data[i].date ?? data[i].label ?? i}: ${v}`}
/>
))}
</div>
);
}
export function SimpleLineChart({
data,
dataKey = "value",
color = "#3A6EA5",
}: {
data: { date: string; value: number }[];
dataKey?: string;
color?: string;
}) {
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
const max = Math.max(...values, 100);
const width = 100;
const height = 100;
const points = values
.map((v, i) => {
const x = (i / Math.max(values.length - 1, 1)) * width;
const y = height - (v / max) * height;
return `${x},${y}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[200px] bg-[#1a1a2e] rounded p-2">
<polyline
fill="none"
stroke={color}
strokeWidth="1.5"
points={points}
/>
</svg>
);
}
export function HeatmapGrid({ data }: { data: { date: string; done: boolean }[] }) {
return (
<div className="grid grid-cols-7 gap-1">
{data.map((d) => (
<div
key={d.date}
className={`aspect-square rounded-sm ${d.done ? "bg-[var(--bliss-green)]" : "bg-gray-300"}`}
title={d.date}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import type { DailyAdventureItemData } from "@adventureos/shared";
export function AdventureItemRow({
item,
disabled,
customize,
workTarget,
onUpdate,
onMetaUpdate,
onRemove,
}: {
item: DailyAdventureItemData;
disabled: boolean;
customize: boolean;
workTarget: number;
onUpdate: (value?: Record<string, unknown>, state?: string) => void;
onMetaUpdate: (updates: { label?: string; enabled?: boolean }) => void;
onRemove: () => void;
}) {
if (item.type === "duration") {
const hours = (item.value?.hours as number) ?? 0;
const target = workTarget;
return (
<div className="flex items-center gap-3 py-1">
{customize ? (
<input
className="retro-window-inset w-24 p-1 text-sm"
value={item.label}
onChange={(e) => onMetaUpdate({ label: e.target.value })}
/>
) : (
<span className="w-24 text-sm font-medium shrink-0">{item.label}</span>
)}
<div className="flex-1 skill-bar-track h-3">
<div
className="skill-bar-fill h-full"
style={{ width: `${Math.min(100, (hours / target) * 100)}%` }}
/>
</div>
<span className="text-xs w-16 text-right">
{hours.toFixed(1)} / {target}h
</span>
<button
className="retro-btn text-xs px-2"
disabled={disabled}
onClick={() => {
const next = Math.min(target, hours + 0.5);
onUpdate({ hours: next });
}}
>
+30m
</button>
{customize && item.isCustom && (
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
</button>
)}
{customize && !item.isCustom && (
<label className="text-xs flex items-center gap-1">
<input
type="checkbox"
checked={item.enabled}
onChange={() => onMetaUpdate({ enabled: !item.enabled })}
/>
On
</label>
)}
</div>
);
}
if (item.type === "checkbox" || item.type === "timeblock") {
const done = item.state === "done";
return (
<div className="flex items-center gap-2 py-1">
<label className="flex items-center gap-2 cursor-pointer flex-1">
<input
type="checkbox"
checked={done}
disabled={disabled}
onChange={() => onUpdate({ done: !done }, done ? "blank" : "done")}
className="w-4 h-4"
/>
{customize ? (
<input
className="retro-window-inset flex-1 p-1 text-sm"
value={item.label}
onChange={(e) => onMetaUpdate({ label: e.target.value })}
/>
) : (
<span className={done ? "line-through opacity-70" : ""}>{item.label}</span>
)}
{item.config?.scheduledTime && (
<span className="text-xs text-[var(--warm-grey)]">
{item.config.scheduledTime as string}
</span>
)}
</label>
{customize && item.isCustom && (
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
</button>
)}
</div>
);
}
if (item.type === "reading") {
return (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={item.state === "done" || item.state === "partial"}
disabled={disabled}
onChange={() =>
onUpdate(
{ pages: item.state === "blank" ? 10 : 0 },
item.state === "blank" ? "partial" : "blank"
)
}
className="w-4 h-4"
/>
<span>{item.label}</span>
<span className="text-xs text-[var(--warm-grey)]">
{(item.value?.pages as number) ? `${item.value.pages} pages today` : "log in Library"}
</span>
</div>
);
}
if (item.type === "note") {
return (
<div className="py-1">
<p className="text-xs font-bold text-[var(--warm-grey)] mb-1">{item.label}</p>
<textarea
className="retro-window-inset w-full p-2 text-sm min-h-[60px] resize-y"
defaultValue={(item.value?.note as string) ?? ""}
disabled={disabled}
onBlur={(e) => onUpdate({ note: e.target.value })}
placeholder="Freeform notes for today..."
/>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,42 @@
"use client";
import { formatDisplayDate } from "@/lib/dates-client";
type Gap = { date: string; reason: string };
interface CatchUpCardProps {
gaps: Gap[];
onSelectDate: (date: string) => void;
}
const REASON_LABEL: Record<string, string> = {
empty: "No adventure logged",
incomplete: "Adventure started but empty",
no_reflection: "Reflection missing",
};
export function CatchUpCard({ gaps, onSelectDate }: CatchUpCardProps) {
if (gaps.length === 0) return null;
return (
<div className="retro-window mb-4">
<div className="retro-titlebar">Catch up gently</div>
<div className="p-3 space-y-2">
<p className="text-xs text-[var(--warm-grey)]">
A few recent days could use a note no pressure, just pick one if you like.
</p>
{gaps.map((g) => (
<button
key={g.date}
type="button"
className="retro-btn text-xs w-full text-left flex justify-between"
onClick={() => onSelectDate(g.date)}
>
<span>{formatDisplayDate(g.date)}</span>
<span className="opacity-70">{REASON_LABEL[g.reason] ?? g.reason}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { xpProgressInLevel } from "@adventureos/shared";
import { SkillBar } from "@/components/retro/skill-bar";
interface PortraitConfig {
skinTone: string;
hairColor: string;
clothingColor: string;
}
interface CharacterCardProps {
displayName: string;
currentTitle: string | null;
level: number;
totalXp: number;
portraitConfig: PortraitConfig;
scores: {
consistencyScore: number;
disciplineScore: number;
learningScore: number;
spiritualScore: number;
healthScore: number;
readingScore: number;
};
}
export function CharacterCard({
displayName,
currentTitle,
level,
totalXp,
portraitConfig,
scores,
}: CharacterCardProps) {
const xp = xpProgressInLevel(totalXp);
return (
<div className="retro-window h-full">
<div className="retro-titlebar">Character Overview</div>
<div className="p-4 flex gap-4">
<div className="flex-shrink-0">
<svg width="80" height="100" viewBox="0 0 80 100" className="drop-shadow">
<ellipse cx="40" cy="28" rx="22" ry="26" fill={portraitConfig.skinTone} />
<path
d="M18 30 Q40 8 62 30 Q58 50 40 48 Q22 50 18 30"
fill={portraitConfig.hairColor}
/>
<rect x="20" y="52" width="40" height="45" rx="4" fill={portraitConfig.clothingColor} />
<polygon
points="40,12 48,22 32,22"
fill="var(--gold-trim)"
stroke="#a08030"
strokeWidth="1"
/>
<text x="40" y="20" textAnchor="middle" fontSize="8" fill="#2c2416" fontWeight="bold">
{level}
</text>
</svg>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-bold text-base truncate">{displayName}</h2>
<p className="text-xs text-[var(--gold-trim)] mb-2">
{currentTitle ?? "New Adventurer"}
</p>
<div className="text-xs mb-2">
Level {level} · {xp.current}/{xp.needed} XP
</div>
<div className="skill-bar-track mb-3">
<div className="skill-bar-fill skill-bar-fill-gold" style={{ width: `${xp.percent}%` }} />
</div>
<SkillBar label="Consistency" value={scores.consistencyScore} />
<SkillBar label="Discipline" value={scores.disciplineScore} />
<SkillBar label="Learning" value={scores.learningScore} />
<SkillBar label="Spiritual" value={scores.spiritualScore} />
<SkillBar label="Health" value={scores.healthScore} />
<SkillBar label="Reading" value={scores.readingScore} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
export function ChecklistItem({
label,
checks,
disabled,
onToggle,
}: {
label: string;
checks: boolean[];
disabled: boolean;
onToggle: (checks: boolean[]) => void;
}) {
return (
<div>
<p className="text-xs mb-1">{label}</p>
<div className="flex gap-1">
{checks.map((c, i) => (
<button
key={i}
disabled={disabled}
onClick={() => {
const next = [...checks];
next[i] = !next[i];
onToggle(next);
}}
className={`w-7 h-7 retro-window-inset text-xs flex items-center justify-center ${
c ? "bg-[var(--bliss-green)] text-white" : ""
}`}
>
{c ? "✓" : ""}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useUiStore } from "@/stores/ui";
import { hasMeaningfulReflectionContent } from "@/lib/reflection-utils";
interface ReflectionProps {
date: string;
dateLabel?: string;
initial?: {
wentWell: string;
learned: string;
improveTomorrow: string;
} | null;
}
type ReflectionForm = {
wentWell: string;
learned: string;
improveTomorrow: string;
};
function formSnapshot(form: ReflectionForm) {
return JSON.stringify(form);
}
export function DailyReflection({ date, dateLabel, initial }: ReflectionProps) {
const [open, setOpen] = useState(true);
const [form, setForm] = useState<ReflectionForm>({
wentWell: initial?.wentWell ?? "",
learned: initial?.learned ?? "",
improveTomorrow: initial?.improveTomorrow ?? "",
});
const lastSaved = useRef(formSnapshot(form));
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const save = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reflections/${date}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error("Failed");
return res.json() as Promise<ReflectionForm & { xpAwarded?: number }>;
},
onSuccess: (data) => {
lastSaved.current = formSnapshot(form);
qc.invalidateQueries({ queryKey: ["dashboard"] });
if (data.xpAwarded && data.xpAwarded > 0) {
showXpToast(data.xpAwarded, "Reflection saved");
}
},
});
const handleBlur = () => {
if (formSnapshot(form) === lastSaved.current) return;
if (!hasMeaningfulReflectionContent(form)) return;
save.mutate();
};
return (
<div className="retro-window">
<button
className="retro-titlebar w-full text-left"
onClick={() => setOpen(!open)}
>
Daily Reflection {dateLabel ? `· ${dateLabel}` : ""} {open ? "▼" : "▶"}
<span className="text-xs font-normal opacity-80">~3 minutes</span>
</button>
{open && (
<div className="p-4 space-y-3">
<Field
label="What went well?"
value={form.wentWell}
onChange={(v) => setForm({ ...form, wentWell: v })}
onBlur={handleBlur}
/>
<Field
label="What did I learn?"
value={form.learned}
onChange={(v) => setForm({ ...form, learned: v })}
onBlur={handleBlur}
/>
<Field
label="What should I improve tomorrow?"
value={form.improveTomorrow}
onChange={(v) => setForm({ ...form, improveTomorrow: v })}
onBlur={handleBlur}
/>
</div>
)}
</div>
);
}
function Field({
label,
value,
onChange,
onBlur,
}: {
label: string;
value: string;
onChange: (v: string) => void;
onBlur: () => void;
}) {
return (
<div>
<label className="text-xs font-bold block mb-1">{label}</label>
<input
className="retro-window-inset w-full p-2 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { formatDisplayDate } from "@/lib/dates-client";
interface DaySwitcherProps {
activeDate: string;
logicalToday: string;
logicalYesterday: string;
isGraceWindow: boolean;
onSelectDate: (date: string) => void;
}
export function DaySwitcher({
activeDate,
logicalToday,
logicalYesterday,
isGraceWindow,
onSelectDate,
}: DaySwitcherProps) {
const isYesterday = activeDate === logicalYesterday;
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
<div className="flex gap-1 retro-window p-1">
<button
type="button"
className={`retro-btn text-xs ${activeDate === logicalToday ? "retro-btn-primary" : ""}`}
onClick={() => onSelectDate(logicalToday)}
>
Today
</button>
<button
type="button"
className={`retro-btn text-xs ${isYesterday ? "retro-btn-primary" : ""}`}
onClick={() => onSelectDate(logicalYesterday)}
>
Yesterday
</button>
</div>
{isGraceWindow && activeDate === logicalYesterday && (
<span className="text-xs italic text-[var(--xp-blue)]">
Still logging yesterday?
</span>
)}
{activeDate !== logicalToday && (
<span className="text-xs text-[var(--warm-grey)]">
Viewing {formatDisplayDate(activeDate)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
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?",
];
export function MentorPanel({ onClose }: { onClose?: () => void }) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
useEffect(() => {
if (!sessionId) {
fetch("/api/ai/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Quick chat" }),
})
.then((r) => r.json())
.then((d) => setSessionId(d.id));
}
}, [sessionId]);
const { data, refetch } = useQuery({
queryKey: ["chat-session", sessionId],
queryFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
return res.json();
},
enabled: !!sessionId,
});
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Send failed");
return res.json();
},
onSuccess: () => {
refetch();
setInput("");
},
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [data?.messages?.length]);
const messages = data?.messages ?? [];
return (
<div className="flex flex-col h-full">
<div className="retro-titlebar flex items-center justify-between">
<span>Mentor</span>
<div className="flex gap-2">
<Link href="/mentor" className="text-xs underline font-normal" onClick={onClose}>
Open full
</Link>
{onClose && (
<button type="button" className="text-xs font-normal" onClick={onClose}>
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]">
{messages.length === 0 && (
<p className="text-xs text-[var(--warm-grey)] italic">
Ask anything about your journey. I use your saved memories nothing hidden.
</p>
)}
{messages.map((m: { id: string; role: string; content: string }) => (
<div
key={m.id}
className={`p-2 rounded ${m.role === "user" ? "bg-white/50 ml-4" : "bg-[var(--xp-blue)]/10 mr-4"}`}
>
<span className="text-[10px] font-bold block mb-1">{m.role === "user" ? "You" : "Mentor"}</span>
{m.content}
</div>
))}
<div ref={bottomRef} />
</div>
<div className="p-2 border-t flex flex-wrap gap-1">
{SUGGESTED.slice(0, 3).map((q) => (
<button
key={q}
type="button"
className="retro-btn text-[10px]"
onClick={() => send.mutate(q)}
disabled={!sessionId || send.isPending}
>
{q}
</button>
))}
</div>
<div className="p-2 flex gap-2">
<input
className="retro-window-inset flex-1 p-1 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
placeholder="Ask the mentor..."
/>
<button
type="button"
className="retro-btn retro-btn-primary text-xs"
disabled={!input.trim() || send.isPending}
onClick={() => send.mutate(input.trim())}
>
Send
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface QuickLogPanelProps {
date: string;
prayerCount?: number;
}
export function QuickLogPanel({ date, prayerCount = 5 }: QuickLogPanelProps) {
const [open, setOpen] = useState(false);
const [workMinutes, setWorkMinutes] = useState(30);
const [readingPages, setReadingPages] = useState(0);
const [exercise, setExercise] = useState(false);
const [prayerChecks, setPrayerChecks] = useState(0);
const [reflection, setReflection] = useState("");
const qc = useQueryClient();
const save = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/adventures/${date}/quick-log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workMinutes: workMinutes > 0 ? workMinutes : undefined,
readingPages: readingPages > 0 ? readingPages : undefined,
exercise: exercise || undefined,
prayerChecks: prayerChecks > 0 ? prayerChecks : undefined,
reflection: reflection.trim() || undefined,
}),
});
if (!res.ok) throw new Error("Quick log failed");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["dashboard"] });
setReflection("");
setOpen(false);
},
});
if (!open) {
return (
<button type="button" className="retro-btn text-xs w-full" onClick={() => setOpen(true)}>
Quick log (when tired)
</button>
);
}
return (
<div className="retro-window border border-[var(--warm-grey)]/30">
<div className="retro-titlebar text-sm">Quick log</div>
<div className="p-3 space-y-2 text-sm">
<label className="flex items-center gap-2">
Work +{workMinutes}m
<input
type="range"
min={0}
max={480}
step={30}
value={workMinutes}
onChange={(e) => setWorkMinutes(Number(e.target.value))}
className="flex-1"
/>
</label>
<label className="flex items-center gap-2">
Reading pages
<input
type="number"
min={0}
value={readingPages}
onChange={(e) => setReadingPages(Number(e.target.value))}
className="retro-input w-16"
/>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={exercise} onChange={(e) => setExercise(e.target.checked)} />
Exercise done
</label>
<label className="flex items-center gap-2">
Prayers ({prayerChecks}/{prayerCount})
<input
type="range"
min={0}
max={prayerCount}
value={prayerChecks}
onChange={(e) => setPrayerChecks(Number(e.target.value))}
className="flex-1"
/>
</label>
<input
type="text"
placeholder="One-line reflection (optional)"
value={reflection}
onChange={(e) => setReflection(e.target.value)}
className="retro-input w-full text-xs"
/>
<div className="flex gap-2">
<button
type="button"
className="retro-btn retro-btn-primary text-xs flex-1"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save essentials"}
</button>
<button type="button" className="retro-btn text-xs" onClick={() => setOpen(false)}>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface QuestGiverProps {
suggestions: Array<{
id: string;
content: Record<string, unknown>;
}>;
ollamaAvailable?: boolean;
}
export function QuestGiverSidebar({ suggestions }: QuestGiverProps) {
const qc = useQueryClient();
const dismiss = useMutation({
mutationFn: async (id: string) => {
await fetch(`/api/ai/suggestions/${id}/dismiss`, { method: "POST" });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["dashboard"] }),
});
const latest = suggestions[0];
const quests =
(latest?.content?.quests as Array<{
title: string;
reason: string;
xp_hint: string;
}>) ?? [];
return (
<div className="retro-window">
<div className="retro-titlebar">The Guide</div>
<div className="p-3">
{quests.length === 0 ? (
<p className="text-xs text-[var(--warm-grey)] italic">
The Guide is resting. Check back tomorrow.
</p>
) : (
<ul className="space-y-2">
{quests.map((q, i) => (
<li key={i} className="text-sm border-b border-[var(--parchment-dark)] pb-2">
<p className="font-medium">{q.title}</p>
<p className="text-xs text-[var(--warm-grey)]">{q.reason}</p>
<span className="text-xs text-[var(--gold-trim)]">{q.xp_hint}</span>
</li>
))}
</ul>
)}
{latest && (
<button
className="retro-btn text-xs mt-2 w-full"
onClick={() => dismiss.mutate(latest.id)}
>
Dismiss
</button>
)}
</div>
</div>
);
}
interface ReadingWidgetProps {
activeBooks: Array<{ title: string; progressPercent: number }>;
streak: { current: number; best: number; isPaused: boolean };
weeklyPages: number;
weeklyGoal: number;
}
export function ReadingWidget({
activeBooks,
streak,
weeklyPages,
weeklyGoal,
}: ReadingWidgetProps) {
return (
<div className="retro-window">
<div className="retro-titlebar">Reading</div>
<div className="p-3 text-sm">
<p className="mb-2">
This week: <strong>{weeklyPages}</strong> / {weeklyGoal} pages
</p>
<div className="skill-bar-track mb-2">
<div
className="skill-bar-fill"
style={{ width: `${Math.min(100, (weeklyPages / weeklyGoal) * 100)}%` }}
/>
</div>
<p className="text-xs text-[var(--warm-grey)]">
Streak: {streak.current} days
{streak.isPaused && " (paused)"} · Best: {streak.best}
</p>
{activeBooks[0] && (
<p className="text-xs mt-2 truncate">📖 {activeBooks[0].title}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,313 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { TemplateData, AdventureItemType } from "@adventureos/shared";
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const ITEM_TYPES: AdventureItemType[] = [
"duration",
"checkbox",
"checklist",
"timeblock",
"reading",
"note",
];
export function TemplateEditor() {
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const { data: templates = [], isLoading } = useQuery<TemplateData[]>({
queryKey: ["templates"],
queryFn: async () => {
const res = await fetch("/api/templates");
return res.json();
},
});
const selected = templates.find((t) => t.id === selectedId) ?? templates[0] ?? null;
const invalidate = () => qc.invalidateQueries({ queryKey: ["templates"] });
const createTemplate = useMutation({
mutationFn: async (name: string) => {
const res = await fetch("/api/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, daysOfWeek: [] }),
});
return res.json();
},
onSuccess: (data) => {
invalidate();
setSelectedId(data.id);
setNewName("");
},
});
const updateTemplate = useMutation({
mutationFn: async (payload: { id: string; data: Record<string, unknown> }) => {
const res = await fetch(`/api/templates/${payload.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload.data),
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
onSuccess: invalidate,
});
const duplicateTemplate = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/templates/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "duplicate" }),
});
return res.json();
},
onSuccess: (data) => {
invalidate();
setSelectedId(data.id);
},
});
const deleteTemplate = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/templates/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed");
return res.json();
},
onSuccess: () => {
invalidate();
setSelectedId(null);
},
});
const saveItem = useMutation({
mutationFn: async (payload: {
templateId: string;
item: Record<string, unknown>;
}) => {
const res = await fetch(`/api/templates/${payload.templateId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload.item),
});
return res.json();
},
onSuccess: invalidate,
});
const deleteItem = useMutation({
mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => {
const res = await fetch(
`/api/templates/${templateId}/items?itemId=${itemId}`,
{ method: "DELETE" }
);
return res.json();
},
onSuccess: invalidate,
});
if (isLoading) return <p className="text-sm">Loading templates...</p>;
return (
<div>
<h2 className="font-bold mb-3">Adventure Templates</h2>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Templates auto-populate your daily quest log. Teaching Day takes priority on Tue/Thu.
</p>
<div className="flex flex-wrap gap-2 mb-4">
{templates.map((t) => (
<button
key={t.id}
className={`retro-btn text-xs ${selected?.id === t.id ? "retro-btn-primary" : ""}`}
onClick={() => setSelectedId(t.id)}
>
{t.name}
{t.isSystem && " ★"}
</button>
))}
</div>
<div className="flex gap-2 mb-4">
<input
className="retro-window-inset flex-1 p-2 text-sm"
placeholder="New template name..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button
className="retro-btn text-xs"
disabled={!newName.trim()}
onClick={() => createTemplate.mutate(newName.trim())}
>
Create
</button>
</div>
{selected && (
<div className="retro-window-inset p-4">
<div className="flex flex-wrap gap-2 items-center mb-4">
<input
className="retro-window-inset p-2 font-bold flex-1 min-w-[200px]"
value={selected.name}
onChange={(e) =>
updateTemplate.mutate({ id: selected.id, data: { name: e.target.value } })
}
/>
<button
className="retro-btn text-xs"
onClick={() => duplicateTemplate.mutate(selected.id)}
>
Duplicate
</button>
{!selected.isSystem && (
<button
className="retro-btn text-xs text-[var(--muted-rose)]"
onClick={() => deleteTemplate.mutate(selected.id)}
>
Delete
</button>
)}
</div>
<p className="text-xs mb-2">Active days:</p>
<div className="flex flex-wrap gap-1 mb-4">
{DAY_LABELS.map((label, i) => {
const active = selected.daysOfWeek.includes(i);
return (
<button
key={i}
className={`retro-btn text-xs px-2 ${active ? "retro-btn-primary" : ""}`}
onClick={() => {
const next = active
? selected.daysOfWeek.filter((d) => d !== i)
: [...selected.daysOfWeek, i].sort();
updateTemplate.mutate({ id: selected.id, data: { daysOfWeek: next } });
}}
>
{label}
</button>
);
})}
</div>
<p className="text-xs font-bold mb-2">Items</p>
<div className="space-y-2 mb-4">
{selected.items.map((item) => (
<div key={item.id} className="flex flex-wrap gap-2 items-center text-sm">
<select
className="retro-window-inset p-1 text-xs"
value={item.type}
onChange={(e) =>
saveItem.mutate({
templateId: selected.id,
item: { id: item.id, type: e.target.value, label: item.label, config: item.config, sortOrder: item.sortOrder },
})
}
>
{ITEM_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<input
className="retro-window-inset flex-1 p-1 min-w-[120px]"
value={item.label}
onChange={(e) =>
saveItem.mutate({
templateId: selected.id,
item: { id: item.id, type: item.type, label: e.target.value, config: item.config, sortOrder: item.sortOrder },
})
}
/>
{item.type === "duration" && (
<input
type="number"
step="0.5"
className="retro-window-inset w-16 p-1 text-xs"
placeholder="hrs"
value={(item.config?.targetHours as number) ?? 7.5}
onChange={(e) =>
saveItem.mutate({
templateId: selected.id,
item: {
id: item.id,
type: item.type,
label: item.label,
config: { ...item.config, targetHours: Number(e.target.value) },
sortOrder: item.sortOrder,
},
})
}
/>
)}
{item.type === "timeblock" && (
<input
className="retro-window-inset w-20 p-1 text-xs"
placeholder="18:00"
value={(item.config?.scheduledTime as string) ?? ""}
onChange={(e) =>
saveItem.mutate({
templateId: selected.id,
item: {
id: item.id,
type: item.type,
label: item.label,
config: { ...item.config, scheduledTime: e.target.value },
sortOrder: item.sortOrder,
},
})
}
/>
)}
<button
className="text-xs text-[var(--muted-rose)]"
onClick={() => deleteItem.mutate({ templateId: selected.id, itemId: item.id })}
>
</button>
</div>
))}
</div>
<button
className="retro-btn text-xs"
onClick={() =>
saveItem.mutate({
templateId: selected.id,
item: {
type: "checkbox",
label: "New item",
config: {},
sortOrder: selected.items.length,
},
})
}
>
Add item
</button>
<div className="mt-4 p-3 bg-[var(--color-surface-muted)] rounded text-xs">
<p className="font-bold mb-1">Preview</p>
<p className="text-[var(--color-text-muted)]">
A typical {selected.daysOfWeek.length > 0 ? DAY_LABELS[selected.daysOfWeek[0]] : "day"} would include:
</p>
<ul className="mt-1">
{selected.items.map((item) => (
<li key={item.id}>· {item.label}</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
import type { DailyAdventureItemData, DailyTodoData, DayMode } from "@adventureos/shared";
import { DAY_MODES } from "@adventureos/shared";
import { useState } from "react";
import Link from "next/link";
import { useAdventureMutations } from "@/hooks/useAdventureMutations";
import { AdventureItemRow } from "@/components/features/adventure-item-row";
import { ChecklistItem } from "@/components/features/checklist-item";
interface TodaysAdventureProps {
date: string;
chapter: string;
journeyDay: number;
isRestDay: boolean;
isCustomized: boolean;
workHoursTarget: number | null;
dayMode: DayMode;
isBackfilled: boolean;
items: DailyAdventureItemData[];
todos: DailyTodoData[];
}
export function TodaysAdventure({
date,
chapter,
journeyDay,
isRestDay,
isCustomized,
workHoursTarget,
dayMode,
isBackfilled,
items,
todos,
}: TodaysAdventureProps) {
const [customize, setCustomize] = useState(false);
const [newTodo, setNewTodo] = useState("");
const [newItemLabel, setNewItemLabel] = useState("");
const [workTarget, setWorkTarget] = useState(
workHoursTarget ?? items.find((i) => i.type === "duration")?.config?.targetHours ?? 7.5
);
const { updateItem, addItem, removeItem, todoMut, restDay, updateWorkHours, updateDayMode } =
useAdventureMutations(date, items);
const checklists = items.filter((i) => i.type === "checklist");
const main = items.filter((i) => i.type !== "checklist");
return (
<div className="retro-window">
<div className="retro-titlebar">
<span>Today&apos;s Adventure</span>
<span className="text-xs font-normal opacity-90">
{chapter} · Day {journeyDay}
{isBackfilled && " · Logged later"}
</span>
</div>
<div className="p-4">
<div className="flex flex-wrap gap-2 mb-3 items-center">
<select
className="retro-window-inset text-xs p-1"
value={dayMode}
onChange={(e) => updateDayMode.mutate(e.target.value)}
>
{Object.entries(DAY_MODES).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
{dayMode === "low_energy" && (
<span className="text-xs italic text-[var(--xp-blue)]">Low-energy day lighter expectations</span>
)}
<button
className={`retro-btn text-xs ${customize ? "retro-btn-primary" : ""}`}
onClick={() => setCustomize(!customize)}
>
{customize ? "Done customizing" : "Customize Today"}
</button>
{customize && (
<Link href="/settings?section=templates" className="text-xs underline text-[var(--warm-grey)]">
Edit weekday template
</Link>
)}
</div>
{customize && (
<p className="text-xs italic mb-3 text-[var(--xp-blue)]">
Editing today only templates unchanged
{isCustomized && " · This day has custom overrides"}
</p>
)}
{isRestDay && (
<p className="text-sm italic mb-3 text-[var(--xp-blue)]">
Rest day your adventure pauses gently today.
</p>
)}
<div className="space-y-2">
{main.map((item) => (
<AdventureItemRow
key={item.id}
item={item}
disabled={isRestDay}
customize={customize}
workTarget={workHoursTarget ?? (item.config?.targetHours as number) ?? 7.5}
onUpdate={(value, state) => updateItem.mutate({ id: item.id, value, state })}
onMetaUpdate={(updates) => updateItem.mutate({ id: item.id, ...updates })}
onRemove={() => removeItem.mutate(item.id)}
/>
))}
</div>
{customize && (
<div className="mt-3 flex gap-2 items-center">
<input
className="retro-window-inset flex-1 p-1 text-sm"
placeholder="Add custom item..."
value={newItemLabel}
onChange={(e) => setNewItemLabel(e.target.value)}
/>
<button
className="retro-btn text-xs"
disabled={!newItemLabel.trim()}
onClick={() => {
addItem.mutate(newItemLabel.trim(), {
onSuccess: () => setNewItemLabel(""),
});
}}
>
Add
</button>
</div>
)}
{customize && items.some((i) => i.type === "duration" && i.label === "Work") && (
<div className="mt-3 flex gap-2 items-center">
<span className="text-sm">Work target (hours):</span>
<input
type="number"
step="0.5"
className="retro-window-inset w-20 p-1 text-sm"
value={workTarget}
onChange={(e) => setWorkTarget(Number(e.target.value))}
/>
<button
className="retro-btn text-xs"
onClick={() => updateWorkHours.mutate(workTarget)}
>
Save
</button>
</div>
)}
{checklists.length > 0 && (
<>
<hr className="my-4 border-[var(--warm-grey)] opacity-30" />
<p className="text-xs font-bold mb-2 text-[var(--warm-grey)]">Checklists</p>
<div className="flex flex-wrap gap-4">
{checklists.map((item) => (
<ChecklistItem
key={item.id}
label={item.label}
checks={(item.value?.checks as boolean[]) ?? []}
disabled={isRestDay}
onToggle={(checks) => updateItem.mutate({ id: item.id, value: { checks } })}
/>
))}
</div>
</>
)}
<hr className="my-4 border-[var(--warm-grey)] opacity-30" />
<p className="text-xs font-bold mb-2 text-[var(--warm-grey)]">Today&apos;s Todos</p>
<div className="space-y-1">
{todos.map((todo) => (
<label key={todo.id} className="flex items-center gap-2 py-0.5 cursor-pointer">
<input
type="checkbox"
checked={todo.done}
disabled={isRestDay}
onChange={() =>
todoMut.mutate({ action: "update", id: todo.id, done: !todo.done })
}
className="w-4 h-4"
/>
<span className={todo.done ? "line-through opacity-70 text-sm" : "text-sm"}>
{todo.label}
</span>
{customize && (
<button
className="text-xs text-[var(--muted-rose)] ml-auto"
onClick={() => todoMut.mutate({ action: "delete", id: todo.id })}
>
</button>
)}
</label>
))}
</div>
{!isRestDay && (
<div className="mt-2 flex gap-2">
<input
className="retro-window-inset flex-1 p-1 text-sm"
placeholder="Add a todo for today..."
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTodo.trim()) {
todoMut.mutate(
{ action: "create", label: newTodo.trim() },
{ onSuccess: () => setNewTodo("") }
);
}
}}
/>
<button
className="retro-btn text-xs"
disabled={!newTodo.trim()}
onClick={() =>
todoMut.mutate(
{ action: "create", label: newTodo.trim() },
{ onSuccess: () => setNewTodo("") }
)
}
>
Add
</button>
</div>
)}
<div className="mt-4 flex gap-2">
<button
className="retro-btn text-xs"
onClick={() => restDay.mutate()}
disabled={isRestDay}
>
Mark Rest Day
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useUiStore } from "@/stores/ui";
import { MentorPanel } from "@/components/features/mentor-panel";
const NAV = [
{ href: "/", label: "Command Centre", icon: "⌂" },
{ href: "/mentor", label: "Mentor", icon: "🧭", aiRelated: true },
{ href: "/library", label: "Library", icon: "📚" },
{ href: "/cartographer", label: "Cartographer", icon: "🗺" },
{ href: "/statistics", label: "Statistics", icon: "📊" },
{ href: "/review", label: "Weekly Review", icon: "📖" },
{ href: "/achievements", label: "Achievements", icon: "🏆" },
{ href: "/teacher", label: "Teacher", icon: "🎓", aiRelated: true },
{ href: "/settings", label: "Settings", icon: "⚙" },
];
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const setAiOnline = useUiStore((s) => s.setAiOnline);
const aiOnline = useUiStore((s) => s.aiOnline);
const [mentorOpen, setMentorOpen] = useState(false);
const { data } = useQuery({
queryKey: ["ai-health-status"],
queryFn: async () => {
const res = await fetch("/api/ai/health");
return res.json();
},
staleTime: 60_000,
refetchInterval: 120_000,
});
useEffect(() => {
if (data?.health) {
setAiOnline(data.health.status === "online");
}
}, [data, setAiOnline]);
const today = new Date().toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
year: "numeric",
});
return (
<div className="min-h-screen flex flex-col md:flex-row">
<aside className="hidden md:flex flex-col w-[200px] retro-window m-2 mr-0 flex-shrink-0">
<div className="retro-titlebar flex items-center gap-2">
<span>AdventureOS</span>
{aiOnline !== null && (
<span
className="text-[10px] font-normal ml-auto flex items-center gap-1"
title={aiOnline ? "AI online" : "AI offline"}
>
<span
className={`inline-block w-2 h-2 rounded-full ${
aiOnline ? "bg-[var(--color-positive)]" : "bg-[var(--color-text-muted)]"
}`}
/>
AI
</span>
)}
</div>
<nav className="p-2 flex-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2 px-2 py-2 text-sm rounded hover:bg-white/40 ${
pathname === item.href ? "bg-white/60 font-bold" : ""
}`}
>
<span>{item.icon}</span>
<span className="flex-1">{item.label}</span>
{item.aiRelated && aiOnline === false && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-text-muted)]" />
)}
</Link>
))}
</nav>
</aside>
<div className="flex-1 flex flex-col min-h-screen m-2 md:ml-0">
<header className="retro-titlebar rounded-t flex items-center justify-between gap-2">
<span className="md:hidden font-bold">AdventureOS</span>
<span className="hidden md:inline">AdventureOS Personal Command Centre</span>
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
className="retro-btn text-xs hidden md:inline"
onClick={() => setMentorOpen(!mentorOpen)}
>
Mentor {mentorOpen ? "▾" : "▸"}
</button>
<span className="text-xs font-normal">{today}</span>
</div>
</header>
<main className="retro-window flex-1 rounded-t-none overflow-auto relative">
{children}
{mentorOpen && (
<div className="hidden md:block fixed right-4 top-24 w-80 retro-window z-50 shadow-lg max-h-[calc(100vh-6rem)]">
<MentorPanel onClose={() => setMentorOpen(false)} />
</div>
)}
</main>
</div>
<nav className="md:hidden fixed bottom-0 left-0 right-0 retro-window flex justify-around p-1 z-40">
{NAV.slice(0, 5).map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center p-2 text-xs min-w-[44px] ${
pathname === item.href ? "font-bold" : ""
}`}
>
<span className="text-lg">{item.icon}</span>
</Link>
))}
</nav>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { ThemeProvider } from "@/components/theme/theme-provider";
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: true },
},
})
);
return (
<QueryClientProvider client={client}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useUiStore } from "@/stores/ui";
import { useEffect } from "react";
import { useUndoMutation } from "@/hooks/useUndoMutation";
export function XpToast() {
const { xpToast, clearXpToast } = useUiStore();
useEffect(() => {
if (!xpToast) return;
const t = setTimeout(clearXpToast, 3200);
return () => clearTimeout(t);
}, [xpToast, clearXpToast]);
if (!xpToast) return null;
return (
<div className="fixed bottom-6 right-6 z-50 xp-toast retro-window px-4 py-2 flex items-center gap-2">
<span className="text-[var(--color-gold)] font-bold">+{xpToast.amount} XP</span>
<span className="text-sm">{xpToast.message}</span>
</div>
);
}
export function ActionToast() {
const { actionToast, clearActionToast } = useUiStore();
const undo = useUndoMutation({ onSuccess: () => clearActionToast() });
useEffect(() => {
if (!actionToast) return;
const t = setTimeout(clearActionToast, 10000);
return () => clearTimeout(t);
}, [actionToast, clearActionToast]);
if (!actionToast) return null;
function handleUndo() {
if (!actionToast) return;
undo.mutate(actionToast.actionEventId);
}
return (
<div className="fixed bottom-6 left-6 z-50 action-toast retro-window px-4 py-2 flex items-center gap-3 max-w-sm">
<span className="text-sm flex-1">{actionToast.summary}</span>
<button
className="retro-btn text-xs"
onClick={handleUndo}
disabled={undo.isPending}
>
{undo.isPending ? "..." : "Undo"}
</button>
<button className="text-xs opacity-60 hover:opacity-100" onClick={clearActionToast}>
</button>
</div>
);
}
export function BookCelebration() {
const { bookCelebration, clearBookCelebration } = useUiStore();
if (!bookCelebration) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="retro-window max-w-md p-6 text-center">
<div className="retro-titlebar mb-4 -mx-6 -mt-6 px-4">Chapter Closed</div>
<p className="serif text-lg mb-2">You finished</p>
<p className="font-bold text-xl mb-4">{bookCelebration.title}</p>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
Another volume joins your completed shelf. Well traveled.
</p>
<button className="retro-btn retro-btn-primary" onClick={clearBookCelebration}>
Continue the adventure
</button>
</div>
</div>
);
}
export function WelcomeBack() {
const { welcomeBack, setWelcomeBack } = useUiStore();
if (!welcomeBack) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="retro-window max-w-lg p-6 text-center">
<div className="retro-titlebar mb-4 -mx-6 -mt-6 px-4">Welcome Back, Traveler</div>
<p className="serif text-base leading-relaxed mb-4">
Your adventure continues. No missed days to review just today, fresh and open.
</p>
<button className="retro-btn retro-btn-primary" onClick={() => setWelcomeBack(false)}>
Enter Command Centre
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
interface SkillBarProps {
label: string;
value: number;
className?: string;
}
export function SkillBar({ label, value, className = "" }: SkillBarProps) {
return (
<div className={`mb-2 ${className}`}>
<div className="flex justify-between text-xs mb-0.5">
<span>{label}</span>
<span className="text-[var(--warm-grey)]">{value}</span>
</div>
<div className="skill-bar-track">
<div
className={`skill-bar-fill ${value >= 80 ? "skill-bar-fill-gold" : ""}`}
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api-client";
import { useUndoMutation } from "@/hooks/useUndoMutation";
export function ActionHistoryPanel() {
const { data, isLoading } = useQuery({
queryKey: ["action-history"],
queryFn: () =>
apiFetch<{
actions: {
id: string;
summary: string;
actionType: string;
createdAt: string;
undoable: boolean;
}[];
}>("/api/actions/recent"),
});
const undo = useUndoMutation({
onSuccess: () => {
/* query invalidation handled in hook */
},
});
const actions = data?.actions ?? [];
return (
<div className="space-y-4">
<h2 className="font-bold">Action History</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Recent actions you can undo. Actions older than 90 days are automatically pruned.
</p>
{isLoading ? (
<p className="text-sm">Loading...</p>
) : actions.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">No recent actions.</p>
) : (
<ul className="space-y-2">
{actions.map((a) => (
<li key={a.id} className="retro-window-inset p-2 flex items-center gap-2 text-sm">
<div className="flex-1">
<p>{a.summary}</p>
<p className="text-xs text-[var(--color-text-muted)]">
{new Date(a.createdAt).toLocaleString()} · {a.actionType}
</p>
</div>
{a.undoable && (
<button
className="retro-btn text-xs"
onClick={() => undo.mutate(a.id)}
disabled={undo.isPending}
>
Undo
</button>
)}
</li>
)
)}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
const PERSONALITIES = [
{ id: "supportive_mentor", label: "Supportive Mentor" },
{ id: "wise_teacher", label: "Wise Teacher" },
{ id: "quiet_observer", label: "Quiet Observer" },
{ id: "academic_tutor", label: "Academic Tutor" },
{ id: "friendly_coach", label: "Friendly Coach" },
];
export function AiConfigPanel() {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["ai-config"],
queryFn: async () => {
const res = await fetch("/api/ai/config");
return res.json();
},
});
const [behavior, setBehavior] = useState({
personality: "supportive_mentor",
verbosity: "balanced",
frequency: "weekly",
creativity: 0.7,
enabled: true,
strictMode: false,
});
const [provider, setProvider] = useState({
type: "ollama",
baseUrl: "http://localhost:11434",
model: "llama3.2:3b",
temperature: 0.7,
maxTokens: 2048,
timeoutMs: 60000,
enabled: true,
});
useEffect(() => {
if (data?.behavior) setBehavior(data.behavior);
if (data?.provider) setProvider(data.provider);
}, [data]);
const save = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/config", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ behavior, provider }),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-config"] }),
});
return (
<div className="space-y-4">
<h2 className="font-bold">AI Configuration</h2>
<section className="space-y-2">
<h3 className="text-sm font-bold">Personality & Behavior</h3>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={behavior.enabled}
onChange={(e) => setBehavior({ ...behavior, enabled: e.target.checked })}
/>
AI enabled
</label>
<Field label="Personality">
<select
className="retro-window-inset w-full p-2"
value={behavior.personality}
onChange={(e) => setBehavior({ ...behavior, personality: e.target.value })}
>
{PERSONALITIES.map((p) => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
</Field>
<Field label="Verbosity">
<select
className="retro-window-inset w-full p-2"
value={behavior.verbosity}
onChange={(e) => setBehavior({ ...behavior, verbosity: e.target.value })}
>
<option value="minimal">Minimal</option>
<option value="balanced">Balanced</option>
<option value="detailed">Detailed</option>
</select>
</Field>
<Field label="Frequency">
<select
className="retro-window-inset w-full p-2"
value={behavior.frequency}
onChange={(e) => setBehavior({ ...behavior, frequency: e.target.value })}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</Field>
<Field label={`Creativity: ${behavior.creativity.toFixed(1)}`}>
<input
type="range"
min="0"
max="1"
step="0.1"
value={behavior.creativity}
onChange={(e) => setBehavior({ ...behavior, creativity: parseFloat(e.target.value) })}
className="w-full"
/>
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={behavior.strictMode}
onChange={(e) => setBehavior({ ...behavior, strictMode: e.target.checked })}
/>
Strict mode (surface errors instead of silent fallback)
</label>
</section>
<section className="space-y-2">
<h3 className="text-sm font-bold">Provider Settings</h3>
<Field label="Provider Type">
<select
className="retro-window-inset w-full p-2"
value={provider.type}
onChange={(e) => setProvider({ ...provider, type: e.target.value })}
>
<option value="ollama">Ollama</option>
<option value="openai_compatible">OpenAI Compatible</option>
<option value="llamacpp">llama.cpp Server</option>
</select>
</Field>
<Field label="Base URL">
<input
className="retro-window-inset w-full p-2"
value={provider.baseUrl}
onChange={(e) => setProvider({ ...provider, baseUrl: e.target.value })}
/>
</Field>
<Field label="Model">
<input
className="retro-window-inset w-full p-2"
value={provider.model}
onChange={(e) => setProvider({ ...provider, model: e.target.value })}
/>
</Field>
<Field label="Max Tokens">
<input
type="number"
className="retro-window-inset w-full p-2"
value={provider.maxTokens}
onChange={(e) => setProvider({ ...provider, maxTokens: parseInt(e.target.value) })}
/>
</Field>
<Field label="Timeout (ms)">
<input
type="number"
className="retro-window-inset w-full p-2"
value={provider.timeoutMs}
onChange={(e) => setProvider({ ...provider, timeoutMs: parseInt(e.target.value) })}
/>
</Field>
<p className="text-xs text-[var(--color-text-muted)]">
API keys are configured via environment variables only (OPENAI_API_KEY, LLAMACPP_API_KEY).
Never stored in the database.
</p>
</section>
<button
className="retro-btn retro-btn-primary"
onClick={() => save.mutate()}
disabled={save.isPending}
>
{save.isPending ? "Saving..." : "Save AI Configuration"}
</button>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="text-xs font-bold block mb-1">{label}</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export function AiHealthPanel() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["ai-health"],
queryFn: async () => {
const res = await fetch("/api/ai/health");
return res.json();
},
});
const runCheck = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/health", { method: "POST" });
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-health"] }),
});
const health = data?.health;
const logs = data?.logs ?? [];
const statusColor =
health?.status === "online"
? "text-[var(--color-positive)]"
: health?.status === "degraded"
? "text-[var(--color-gold)]"
: "text-[var(--color-danger)]";
return (
<div className="space-y-4">
<h2 className="font-bold">AI Health</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Check whether your local AI provider is reachable and responding.
</p>
{isLoading ? (
<p className="text-sm">Loading...</p>
) : (
<div className="retro-window-inset p-4 space-y-3">
<div className="flex items-center gap-3">
<span className={`text-2xl font-bold uppercase ${statusColor}`}>
{health?.status ?? "unknown"}
</span>
<button
className="retro-btn retro-btn-primary text-sm"
onClick={() => runCheck.mutate()}
disabled={runCheck.isPending}
>
{runCheck.isPending ? "Checking..." : "Run Health Check"}
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<Stat label="Provider" value={health?.provider ?? "—"} />
<Stat label="Model" value={health?.model ?? "—"} />
<Stat label="Latency" value={health?.latencyMs != null ? `${health.latencyMs}ms` : "—"} />
<Stat label="Base URL" value={health?.baseUrlSafe ?? "—"} />
<Stat label="Context Length" value={health?.contextLength?.toString() ?? "—"} />
<Stat label="Memory" value={health?.memoryUsageMb != null ? `${health.memoryUsageMb} MB` : "—"} />
<Stat label="Last Success" value={formatTime(health?.lastSuccessAt)} />
<Stat label="Last Failure" value={formatTime(health?.lastFailureAt)} />
</div>
{health?.lastError && (
<div className="text-xs text-[var(--color-danger)] retro-window-inset p-2">
<strong>Last error:</strong> {health.lastError}
</div>
)}
{health?.modelsAvailable?.length > 0 && (
<div className="text-xs">
<strong>Models available:</strong> {health.modelsAvailable.slice(0, 5).join(", ")}
{health.modelsAvailable.length > 5 && ` (+${health.modelsAvailable.length - 5} more)`}
</div>
)}
</div>
)}
{logs.length > 0 && (
<div>
<h3 className="font-bold text-sm mb-2">Recent Checks</h3>
<ul className="text-xs space-y-1">
{logs.map((log: { id: string; status: string; checkedAt: string; latencyMs: number }) => (
<li key={log.id} className="flex gap-2">
<span>{formatTime(log.checkedAt)}</span>
<span>{log.status}</span>
<span>{log.latencyMs}ms</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-[var(--color-text-muted)] text-xs">{label}</span>
<p className="font-mono text-xs truncate">{value}</p>
</div>
);
}
function formatTime(iso: string | null | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleString();
}

View File

@@ -0,0 +1,365 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
type Memory = {
id: string;
category: string;
title: string;
content: string;
sourceType: string;
enabled: boolean;
userVerified: boolean;
};
type Suggestion = {
id: string;
category: string;
title: string;
content: string;
confidence: string;
sourceType: string;
};
export function AiMemoryPanel() {
const qc = useQueryClient();
const [tab, setTab] = useState<"memories" | "suggestions" | "summary" | "learning">("memories");
const [filter, setFilter] = useState("");
const [category, setCategory] = useState("");
const [newMem, setNewMem] = useState({ category: "likes" as MemoryCategory, title: "", content: "" });
const { data } = useQuery({
queryKey: ["ai-memory", filter, category],
queryFn: async () => {
const params = new URLSearchParams();
if (filter) params.set("q", filter);
if (category) params.set("category", category);
const res = await fetch(`/api/ai/memory?${params}`);
return res.json();
},
});
const { data: learningData } = useQuery({
queryKey: ["ai-memory-learning"],
queryFn: async () => {
const res = await fetch("/api/ai/memory/learning");
return res.json();
},
});
const { data: suggestionsData } = useQuery({
queryKey: ["ai-memory-suggestions"],
queryFn: async () => {
const res = await fetch("/api/ai/memory/suggestions");
return res.json();
},
});
const createMem = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/memory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newMem),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-memory"] });
setNewMem({ category: "likes", title: "", content: "" });
},
});
const saveSummary = useMutation({
mutationFn: async (summary: string) => {
const res = await fetch("/api/ai/memory/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ summary }),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory"] }),
});
const saveLearning = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
const res = await fetch("/api/ai/memory/learning", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory-learning"] }),
});
const memories: Memory[] = data?.memories ?? [];
const summary = data?.summary?.summary ?? "";
const suggestions: Suggestion[] = suggestionsData?.suggestions ?? [];
const learning = learningData ?? {};
return (
<div className="space-y-4">
<p className="text-sm text-[var(--warm-grey)]">
What the AI knows about you fully editable. Raw logs and reflections stay separate.
</p>
<div className="flex flex-wrap gap-1">
{(["memories", "suggestions", "summary", "learning"] as const).map((t) => (
<button
key={t}
type="button"
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t === "suggestions" ? `Suggestions (${suggestions.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "memories" && (
<>
<div className="flex gap-2 flex-wrap">
<input
className="retro-window-inset p-1 text-sm flex-1 min-w-[120px]"
placeholder="Search memories..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select
className="retro-window-inset text-sm p-1"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">All categories</option>
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div className="retro-window p-3 space-y-2">
<p className="text-xs font-bold">Add memory manually</p>
<select
className="retro-window-inset text-sm p-1 w-full"
value={newMem.category}
onChange={(e) => setNewMem({ ...newMem, category: e.target.value as MemoryCategory })}
>
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<input
className="retro-window-inset p-1 text-sm w-full"
placeholder="Title"
value={newMem.title}
onChange={(e) => setNewMem({ ...newMem, title: e.target.value })}
/>
<textarea
className="retro-window-inset p-1 text-sm w-full"
placeholder="What should the AI remember?"
rows={2}
value={newMem.content}
onChange={(e) => setNewMem({ ...newMem, content: e.target.value })}
/>
<button
type="button"
className="retro-btn retro-btn-primary text-xs"
disabled={!newMem.title.trim() || !newMem.content.trim()}
onClick={() => createMem.mutate()}
>
Add memory
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{memories.map((m) => (
<MemoryRow key={m.id} memory={m} onChange={() => qc.invalidateQueries({ queryKey: ["ai-memory"] })} />
))}
{memories.length === 0 && (
<p className="text-sm italic text-[var(--warm-grey)]">No memories yet. Add one above.</p>
)}
</div>
<div className="flex gap-2">
<a href="/api/ai/memory/summary" className="retro-btn text-xs" download>
Export memories
</a>
<button
type="button"
className="retro-btn text-xs text-[var(--muted-rose)]"
onClick={async () => {
if (!confirm("Archive all memories? This cannot be undone easily.")) return;
await fetch("/api/ai/memory/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
qc.invalidateQueries({ queryKey: ["ai-memory"] });
}}
>
Reset all memories
</button>
</div>
</>
)}
{tab === "suggestions" && (
<div className="space-y-2">
{suggestions.length === 0 && (
<p className="text-sm italic">No pending suggestions. Enable learning to receive them.</p>
)}
{suggestions.map((s) => (
<SuggestionRow key={s.id} suggestion={s} onChange={() => {
qc.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
qc.invalidateQueries({ queryKey: ["ai-memory"] });
}} />
))}
</div>
)}
{tab === "summary" && (
<div className="space-y-2">
<p className="text-xs text-[var(--warm-grey)]">
AI-generated profile summary (editable). Used as compact context for the mentor.
</p>
<textarea
className="retro-window-inset p-2 text-sm w-full"
rows={6}
value={summary}
onChange={(e) => saveSummary.mutate(e.target.value)}
/>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
const res = await fetch("/api/ai/memory/summary/rebuild", { method: "POST" });
if (res.ok) qc.invalidateQueries({ queryKey: ["ai-memory"] });
}}
>
Rebuild from memories
</button>
</div>
)}
{tab === "learning" && (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.learningEnabled ?? false}
onChange={(e) => saveLearning.mutate({ learningEnabled: e.target.checked })}
/>
Enable memory learning (opt-in)
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.requireApproval ?? true}
onChange={(e) => saveLearning.mutate({ requireApproval: e.target.checked })}
/>
Require approval before saving
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.suggestAfterReflection ?? true}
onChange={(e) => saveLearning.mutate({ suggestAfterReflection: e.target.checked })}
/>
Suggest after reflections
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.allowSensitiveCategories ?? false}
onChange={(e) => saveLearning.mutate({ allowSensitiveCategories: e.target.checked })}
/>
Allow learning sensitive categories
</label>
</div>
)}
</div>
);
}
function MemoryRow({ memory, onChange }: { memory: Memory; onChange: () => void }) {
const toggle = async () => {
await fetch(`/api/ai/memory/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !memory.enabled }),
});
onChange();
};
const archive = async () => {
await fetch(`/api/ai/memory/${memory.id}`, { method: "DELETE" });
onChange();
};
return (
<div className={`retro-window p-2 text-sm ${!memory.enabled ? "opacity-50" : ""}`}>
<div className="flex justify-between gap-2">
<span className="font-bold">{memory.title}</span>
<span className="text-xs text-[var(--warm-grey)]">
{MEMORY_CATEGORIES[memory.category as MemoryCategory] ?? memory.category}
</span>
</div>
<p className="text-xs mt-1">{memory.content}</p>
<p className="text-[10px] text-[var(--warm-grey)] mt-1">Source: {memory.sourceType}</p>
<div className="flex gap-2 mt-2">
<button type="button" className="retro-btn text-xs" onClick={toggle}>
{memory.enabled ? "Disable" : "Enable"}
</button>
<button type="button" className="retro-btn text-xs" onClick={archive}>Archive</button>
</div>
</div>
);
}
function SuggestionRow({ suggestion, onChange }: { suggestion: Suggestion; onChange: () => void }) {
return (
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
<p className="font-bold">{suggestion.title}</p>
<p className="text-xs mt-1">{suggestion.content}</p>
<p className="text-[10px] text-[var(--warm-grey)]">
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory]} · confidence {suggestion.confidence}
</p>
<div className="flex gap-2 mt-2">
<button
type="button"
className="retro-btn retro-btn-primary text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, { method: "POST" });
onChange();
}}
>
Accept
</button>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/reject`, { method: "POST" });
onChange();
}}
>
Reject
</button>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/ignore`, { method: "POST" });
onChange();
}}
>
Ignore
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { apiFetch, jsonBody } from "@/lib/api-client";
type AiTemplate = {
key: string;
name: string;
body: string;
description?: string;
category: string;
enabled?: boolean;
};
type AiPromptEditorProps = {
category: "template" | "system_prompt";
title: string;
description?: string;
showPreview?: boolean;
advancedWarning?: boolean;
};
export function AiPromptEditor({
category,
title,
description,
showPreview = false,
advancedWarning = false,
}: AiPromptEditorProps) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["ai-templates"],
queryFn: () => apiFetch<{ templates: AiTemplate[]; placeholders: { key: string; description: string }[] }>("/api/ai/templates"),
});
const templates = (data?.templates ?? []).filter((t) => t.category === category);
const placeholders = data?.placeholders ?? [];
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [body, setBody] = useState("");
const [preview, setPreview] = useState("");
const selected = templates.find((t) => t.key === selectedKey);
function selectTemplate(t: AiTemplate) {
setSelectedKey(t.key);
setBody(t.body);
setPreview("");
}
const save = useMutation({
mutationFn: () =>
apiFetch(`/api/ai/templates/${selectedKey}`, {
method: "PATCH",
...jsonBody({ body }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-templates"] }),
});
const reset = useMutation({
mutationFn: () =>
apiFetch<{ template?: AiTemplate }>(`/api/ai/templates/${selectedKey}?action=reset`, {
method: "POST",
}),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ["ai-templates"] });
if (data.template) setBody(data.template.body);
},
});
const previewMut = useMutation({
mutationFn: () =>
apiFetch<{ rendered: string }>(`/api/ai/templates/${selectedKey}?action=preview`, {
method: "POST",
...jsonBody({ body }),
}),
onSuccess: (data) => setPreview(data.rendered),
});
const resetConfirm =
category === "system_prompt"
? "Reset to default system prompt?"
: "Reset this template to default?";
return (
<div className="space-y-4">
{advancedWarning && (
<div className="retro-window-inset p-3 border-l-4 border-[var(--color-gold)]">
<p className="text-sm font-bold">Advanced Settings</p>
<p className="text-xs text-[var(--color-text-muted)]">
Modifying system prompts affects all AI generation. Proceed carefully.
</p>
</div>
)}
<h2 className="font-bold">{title}</h2>
{description && (
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
)}
<div className="flex flex-col md:flex-row gap-4">
<div className="w-full md:w-48 space-y-1">
{templates.map((t) => (
<button
key={t.key}
className={`block w-full text-left px-2 py-1.5 text-sm hover:bg-white/40 ${
selectedKey === t.key ? "bg-white/60 font-bold" : ""
}`}
onClick={() => selectTemplate(t)}
>
{t.name}
{t.enabled === false && " (disabled)"}
</button>
))}
</div>
{selected && (
<div className="flex-1 space-y-3">
{selected.description && (
<p className="text-xs text-[var(--color-text-muted)]">{selected.description}</p>
)}
<textarea
className="retro-window-inset w-full p-2 font-mono text-xs h-48"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex gap-2 flex-wrap">
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate()}>
Save
</button>
{showPreview && (
<button className="retro-btn" onClick={() => previewMut.mutate()}>
Preview
</button>
)}
<button
className="retro-btn"
onClick={() => {
if (confirm(resetConfirm)) reset.mutate();
}}
>
Reset to Default
</button>
</div>
{preview && (
<pre className="retro-window-inset p-2 text-xs whitespace-pre-wrap max-h-40 overflow-auto">
{preview}
</pre>
)}
{showPreview && placeholders.length > 0 && (
<div className="text-xs">
<strong>Placeholders:</strong>
<ul className="mt-1">
{placeholders.map((p) => (
<li key={p.key}>
<code>{`{{${p.key}}}`}</code> {p.description}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
);
}
export function PromptTemplateEditor() {
return (
<AiPromptEditor
category="template"
title="AI Templates"
description='Edit the prompts that control AI behavior. Use {"{{placeholders}}"} for dynamic content.'
showPreview
/>
);
}
export function SystemPromptsPanel() {
return (
<AiPromptEditor
category="system_prompt"
title="System Prompts"
advancedWarning
/>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { THEMES, type ThemeId } from "@/themes/registry";
import { useTheme } from "./theme-provider";
export function ThemeGallery() {
const { activeTheme, previewTheme, setPreviewTheme, applyTheme, theme } = useTheme();
const isPreviewing = previewTheme !== null;
return (
<div className="space-y-4">
<h2 className="font-bold">Appearance</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Choose a nostalgia theme. Click to preview, then apply.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{THEMES.map((t) => (
<button
key={t.id}
type="button"
className={`theme-preview-card retro-window text-left overflow-hidden ${
activeTheme === t.id ? "selected" : ""
}`}
onClick={() => setPreviewTheme(t.id)}
>
<div
className="theme-preview-bar"
style={{ background: t.swatches[1] }}
/>
<div className="theme-preview-body" style={{ background: t.swatches[0] }}>
{t.swatches.map((c, i) => (
<div key={i} className="theme-preview-swatch" style={{ background: c }} />
))}
</div>
<div className="px-2 pb-2">
<p className="text-xs font-bold">{t.name}</p>
<p className="text-[10px] text-[var(--color-text-muted)]">{t.description}</p>
</div>
</button>
))}
</div>
{isPreviewing && (
<div className="flex gap-2 items-center retro-window-inset p-3">
<span className="text-sm flex-1">Previewing: {THEMES.find((t) => t.id === previewTheme)?.name}</span>
<button
className="retro-btn"
onClick={() => setPreviewTheme(null)}
>
Cancel
</button>
<button
className="retro-btn retro-btn-primary"
onClick={() => applyTheme(previewTheme as ThemeId)}
>
Apply
</button>
</div>
)}
{!isPreviewing && theme && (
<p className="text-xs text-[var(--color-text-muted)]">
Current theme: {THEMES.find((t) => t.id === theme)?.name}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_THEME,
migrateThemeId,
type ThemeId,
} from "@/themes/registry";
interface ThemeContextValue {
theme: ThemeId;
previewTheme: ThemeId | null;
setPreviewTheme: (id: ThemeId | null) => void;
applyTheme: (id: ThemeId) => Promise<void>;
activeTheme: ThemeId;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function applyThemeToDom(id: ThemeId) {
if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-theme", id);
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<ThemeId>(DEFAULT_THEME);
const [previewTheme, setPreviewThemeState] = useState<ThemeId | null>(null);
const activeTheme = previewTheme ?? theme;
useEffect(() => {
applyThemeToDom(activeTheme);
}, [activeTheme]);
useEffect(() => {
const cached = localStorage.getItem("adventureos-theme");
if (cached) {
const migrated = migrateThemeId(cached);
setTheme(migrated);
}
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
const stored = data?.settings?.theme as string | undefined;
const migrated = migrateThemeId(stored);
setTheme(migrated);
localStorage.setItem("adventureos-theme", migrated);
})
.catch(() => {});
}, []);
const setPreviewTheme = useCallback((id: ThemeId | null) => {
setPreviewThemeState(id);
}, []);
const applyTheme = useCallback(async (id: ThemeId) => {
setPreviewThemeState(null);
setTheme(id);
localStorage.setItem("adventureos-theme", id);
applyThemeToDom(id);
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { theme: id } }),
});
}, []);
return (
<ThemeContext.Provider
value={{ theme, previewTheme, setPreviewTheme, applyTheme, activeTheme }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,7 @@
export function LoadingState({ message = "Loading..." }: { message?: string }) {
return <div className="p-8 text-center text-[var(--color-text-muted)]">{message}</div>;
}
export function ErrorState({ message }: { message: string }) {
return <div className="p-8 text-center text-red-600">{message}</div>;
}

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
type RetroWindowProps = {
title: string;
children: ReactNode;
className?: string;
};
export function RetroWindow({ title, children, className = "" }: RetroWindowProps) {
return (
<div className={`retro-window ${className}`}>
<div className="retro-titlebar">{title}</div>
<div className="p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
type TabToggleProps<T extends string> = {
tabs: { id: T; label: string }[];
active: T;
onChange: (id: T) => void;
};
export function TabToggle<T extends string>({ tabs, active, onChange }: TabToggleProps<T>) {
return (
<div className="flex gap-2 mb-4">
{tabs.map((tab) => (
<button
key={tab.id}
className={`retro-btn ${active === tab.id ? "retro-btn-primary" : ""}`}
onClick={() => onChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { apiFetch, jsonBody } from "@/lib/api-client";
export type ActionMutationResult = { actionEventId?: string };
export function updateAdventureItem(
date: string,
id: string,
body: Record<string, unknown>
) {
return apiFetch<ActionMutationResult & Record<string, unknown>>(
`/api/adventures/${date}/items/${id}`,
{ method: "PATCH", ...jsonBody(body) }
);
}
export function createAdventureItem(date: string, label: string) {
return apiFetch<ActionMutationResult & { item?: unknown }>(
`/api/adventures/${date}/items`,
{ method: "POST", ...jsonBody({ label }) }
);
}
export function deleteAdventureItem(date: string, id: string) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/items/${id}`, {
method: "DELETE",
});
}
export function createDailyTodo(date: string, label: string) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos`, {
method: "POST",
...jsonBody({ label }),
});
}
export function updateDailyTodo(
date: string,
id: string,
body: { label?: string; done?: boolean }
) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos/${id}`, {
method: "PATCH",
...jsonBody(body),
});
}
export function deleteDailyTodo(date: string, id: string) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos/${id}`, {
method: "DELETE",
});
}
export function setRestDay(date: string) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/rest-day`, {
method: "POST",
});
}
export function updateDaySettings(
date: string,
body: { workHoursTarget?: number; dayMode?: string }
) {
return apiFetch<ActionMutationResult>(`/api/adventures/${date}`, {
method: "PATCH",
...jsonBody(body),
});
}

View File

@@ -0,0 +1,8 @@
import { apiFetch } from "@/lib/api-client";
export function undoAction(actionEventId: string) {
return apiFetch<{ ok: boolean; error?: string; summary?: string }>(
`/api/actions/${actionEventId}/undo`,
{ method: "POST" }
);
}

Some files were not shown because too many files have changed in this diff Show More