Files
adventure/apps/web/src/app/cartographer/page.tsx
Zaine 3b37368e7d
Some checks failed
CI / test (push) Has been cancelled
initial 2
2026-06-26 09:26:50 +01:00

312 lines
10 KiB
TypeScript
Executable File

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