This commit is contained in:
@@ -76,6 +76,7 @@ docker compose exec ollama ollama pull llama3.1:8b
|
||||
- **Themes:** Settings → Appearance — 11 nostalgia themes with live preview. Default for new installs: `minimal-dark`. Legacy `xp` maps to `windows-xp-light`.
|
||||
- **Undo:** After adventure/reading actions, use the toast Undo button or Settings → Action History.
|
||||
- **AI:** Settings → AI Configuration, AI Templates, System Prompts, AI Health. Provider keys stay in `.env` only (`OPENAI_API_KEY`, `LLAMACPP_API_KEY`).
|
||||
- **Memories:** See [How Memories Work](docs/MEMORIES.md) for the phrases and review flow the mentor understands.
|
||||
|
||||
Run tests: `npm run test`
|
||||
|
||||
|
||||
35
apps/web/src/app/api/resource-links/[id]/route.ts
Normal file
35
apps/web/src/app/api/resource-links/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { deleteResourceLink, updateResourceLink } from "@/lib/services/resource-links";
|
||||
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();
|
||||
const link = await updateResourceLink({
|
||||
userId: user.id,
|
||||
id,
|
||||
title: typeof body.title === "string" ? body.title : undefined,
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
category: typeof body.category === "string" ? body.category : undefined,
|
||||
notes: typeof body.notes === "string" || body.notes === null ? body.notes : undefined,
|
||||
status: typeof body.status === "string" ? body.status : undefined,
|
||||
});
|
||||
return { link };
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return deleteResourceLink(user.id, id);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/resource-links/export/route.ts
Normal file
15
apps/web/src/app/api/resource-links/export/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { exportResourceLinksToBacklog } from "@/lib/services/resource-links";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return exportResourceLinksToBacklog({
|
||||
userId: user.id,
|
||||
weekStart: String(body.weekStart ?? ""),
|
||||
ids: Array.isArray(body.ids) ? body.ids.map(String) : [],
|
||||
});
|
||||
});
|
||||
}
|
||||
40
apps/web/src/app/api/resource-links/route.ts
Normal file
40
apps/web/src/app/api/resource-links/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
createResourceLink,
|
||||
getResourceBacklogSections,
|
||||
listResourceLinks,
|
||||
type ResourceLinkStatus,
|
||||
} from "@/lib/services/resource-links";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const weekStart = searchParams.get("weekStart") ?? undefined;
|
||||
const status = (searchParams.get("status") ?? undefined) as ResourceLinkStatus | undefined;
|
||||
const includeSections = searchParams.get("includeSections") === "1";
|
||||
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const [links, sections] = await Promise.all([
|
||||
listResourceLinks(user.id, { weekStart, status }),
|
||||
includeSections ? getResourceBacklogSections() : Promise.resolve([]),
|
||||
]);
|
||||
return { links, sections };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const link = await createResourceLink({
|
||||
userId: user.id,
|
||||
date: String(body.date),
|
||||
title: String(body.title ?? ""),
|
||||
url: String(body.url ?? ""),
|
||||
category: typeof body.category === "string" ? body.category : undefined,
|
||||
notes: typeof body.notes === "string" ? body.notes : undefined,
|
||||
});
|
||||
return { link };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { CharacterCard } from "@/components/features/character-card";
|
||||
@@ -10,6 +10,7 @@ import { QuestGiverSidebar, ReadingWidget } from "@/components/features/sidebar-
|
||||
import { DaySwitcher } from "@/components/features/day-switcher";
|
||||
import { CatchUpCard } from "@/components/features/catch-up-card";
|
||||
import { QuickLogPanel } from "@/components/features/quick-log-panel";
|
||||
import { ResourceLinkCapture } from "@/components/features/resource-link-capture";
|
||||
import { formatDisplayDate, todayString } from "@/lib/dates-client";
|
||||
|
||||
async function fetchDashboard(date?: string) {
|
||||
@@ -25,6 +26,9 @@ export default function HomePage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["dashboard", activeDate],
|
||||
queryFn: () => fetchDashboard(activeDate),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -100,6 +104,7 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
<QuickLogPanel date={date} />
|
||||
<ResourceLinkCapture date={date} />
|
||||
<DailyReflection
|
||||
date={date}
|
||||
initial={data.reflection}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState } from "react";
|
||||
import { weekStartString } from "@/lib/dates-client";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
import { ResourceLinkReview } from "@/components/features/resource-link-review";
|
||||
|
||||
export default function ReviewPage() {
|
||||
const weekStart = weekStartString();
|
||||
@@ -60,15 +61,20 @@ export default function ReviewPage() {
|
||||
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's Review
|
||||
</button>
|
||||
<div className="p-4 pb-20 md:pb-4 min-h-full parchment-bg">
|
||||
<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's Review
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto mt-4">
|
||||
<ResourceLinkReview weekStart={weekStart} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
@@ -162,6 +168,9 @@ export default function ReviewPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto mt-4">
|
||||
<ResourceLinkReview weekStart={weekStart} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
132
apps/web/src/components/features/resource-link-capture.tsx
Normal file
132
apps/web/src/components/features/resource-link-capture.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
type ResourceSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type ResourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
interface ResourceLinkCaptureProps {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function ResourceLinkCapture({ date }: ResourceLinkCaptureProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [category, setCategory] = useState("Interesting things to read up:");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["resource-links", "capture"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/resource-links?status=inbox&includeSections=1");
|
||||
if (!res.ok) throw new Error("Failed to load resource links");
|
||||
return res.json() as Promise<{ links: ResourceLink[]; sections: ResourceSection[] }>;
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sections = data?.sections?.length
|
||||
? data.sections
|
||||
: [{ title: "Interesting things to read up:", level: 1 }];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/resource-links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date, title, url, category }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Failed to save link");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTitle("");
|
||||
setUrl("");
|
||||
qc.invalidateQueries({ queryKey: ["resource-links"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="retro-btn text-xs w-full" onClick={() => setOpen(true)}>
|
||||
Capture resource link
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-window border border-[var(--warm-grey)]/30">
|
||||
<div className="retro-titlebar text-sm">Resource Inbox</div>
|
||||
<div className="p-3 space-y-2 text-sm">
|
||||
<input
|
||||
className="retro-input w-full text-xs"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="retro-input w-full text-xs"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="retro-window-inset w-full p-1 text-xs"
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.title} value={section.title}>
|
||||
{section.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{save.error && (
|
||||
<p className="text-xs text-[var(--muted-rose)]">{save.error.message}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs flex-1"
|
||||
disabled={save.isPending || !title.trim() || !url.trim()}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving..." : "Save link"}
|
||||
</button>
|
||||
<button type="button" className="retro-btn text-xs" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{data?.links?.length ? (
|
||||
<div className="pt-2 border-t border-[var(--warm-grey)]/25">
|
||||
<p className="text-xs font-bold text-[var(--warm-grey)] mb-1">Inbox</p>
|
||||
<ul className="space-y-1">
|
||||
{data.links.slice(0, 4).map((link) => (
|
||||
<li key={link.id} className="text-xs truncate">
|
||||
{link.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
apps/web/src/components/features/resource-link-review.tsx
Normal file
179
apps/web/src/components/features/resource-link-review.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
type ResourceSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type ResourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string;
|
||||
status: "inbox" | "exported" | "dismissed";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
interface ResourceLinkReviewProps {
|
||||
weekStart: string;
|
||||
}
|
||||
|
||||
export function ResourceLinkReview({ weekStart }: ResourceLinkReviewProps) {
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["resource-links", weekStart],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/resource-links?weekStart=${weekStart}&status=inbox&includeSections=1`
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load resource links");
|
||||
return res.json() as Promise<{ links: ResourceLink[]; sections: ResourceSection[] }>;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const sections = data?.sections?.length
|
||||
? data.sections
|
||||
: [{ title: "Interesting things to read up:", level: 1 }];
|
||||
|
||||
const links = data?.links ?? [];
|
||||
|
||||
const updateCategory = useMutation({
|
||||
mutationFn: async ({ id, category }: { id: string; category: string }) => {
|
||||
const res = await fetch(`/api/resource-links/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ category }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update link");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["resource-links", weekStart] }),
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/resource-links/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "dismissed" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to dismiss link");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["resource-links", weekStart] }),
|
||||
});
|
||||
|
||||
const exportLinks = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/resource-links/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ weekStart, ids: Array.from(selected) }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error ?? "Failed to export links");
|
||||
return data as { exported: number; path: string; weekLabel: string };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
qc.invalidateQueries({ queryKey: ["resource-links", weekStart] });
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-window p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h2 className="font-bold">Resource Links</h2>
|
||||
<p className="text-xs text-[var(--warm-grey)]">Week of {weekStart}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs"
|
||||
disabled={selected.size === 0 || exportLinks.isPending}
|
||||
onClick={() => exportLinks.mutate()}
|
||||
>
|
||||
{exportLinks.isPending ? "Exporting..." : `Export ${selected.size || ""}`.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-[var(--warm-grey)]">Loading links...</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm text-[var(--warm-grey)]">No inbox links for this week.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.map((link) => (
|
||||
<div key={link.id} className="retro-window-inset p-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={selected.has(link.id)}
|
||||
onChange={() => toggle(link.id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-bold underline break-words"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
<p className="text-xs text-[var(--warm-grey)] truncate">{link.url}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<select
|
||||
className="retro-window-inset p-1 text-xs max-w-full"
|
||||
value={link.category}
|
||||
onChange={(event) =>
|
||||
updateCategory.mutate({ id: link.id, category: event.target.value })
|
||||
}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.title} value={section.title}>
|
||||
{section.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => dismiss.mutate(link.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportLinks.error && (
|
||||
<p className="mt-2 text-xs text-[var(--muted-rose)]">{exportLinks.error.message}</p>
|
||||
)}
|
||||
{exportLinks.data && (
|
||||
<p className="mt-2 text-xs text-[var(--warm-grey)]">
|
||||
Exported {exportLinks.data.exported} links to ## {exportLinks.data.weekLabel}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,23 +20,36 @@ export async function getDashboard(date?: string) {
|
||||
const boundaryHour = await getDayBoundaryHour(user.id);
|
||||
const logicalToday = getLogicalToday(boundaryHour);
|
||||
const activeDate = date ?? logicalToday;
|
||||
await materializeAndVisit(user.id, activeDate);
|
||||
|
||||
const { adventure, items, todos } = await getDailyAdventure(user.id, activeDate);
|
||||
const reflection = await getReflection(user.id, activeDate);
|
||||
const suggestions = await getSuggestions(user.id, "quest_giver");
|
||||
const bookList = await getBooks(user.id);
|
||||
const streak = await getReadingStreak(user.id);
|
||||
const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd");
|
||||
const weeklyPages = await getWeeklyPages(user.id, weekStart, activeDate);
|
||||
const catchUpGaps = await detectCatchUpGaps(user.id);
|
||||
|
||||
const [goalRow] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
);
|
||||
const [
|
||||
{ adventure, items, todos },
|
||||
reflection,
|
||||
suggestions,
|
||||
bookList,
|
||||
streak,
|
||||
weeklyPages,
|
||||
catchUpGaps,
|
||||
goalRows,
|
||||
] = await Promise.all([
|
||||
getDailyAdventure(user.id, activeDate),
|
||||
getReflection(user.id, activeDate),
|
||||
getSuggestions(user.id, "quest_giver"),
|
||||
getBooks(user.id),
|
||||
getReadingStreak(user.id),
|
||||
getWeeklyPages(user.id, weekStart, activeDate),
|
||||
detectCatchUpGaps(user.id),
|
||||
db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
),
|
||||
]);
|
||||
|
||||
void recordDashboardVisit(user.id, activeDate);
|
||||
|
||||
const [goalRow] = goalRows;
|
||||
|
||||
const activeBooks = bookList
|
||||
.filter((b) => b.status === "reading")
|
||||
@@ -133,14 +146,17 @@ export async function getDashboard(date?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeAndVisit(userId: string, date: string) {
|
||||
await getDailyAdventure(userId, date);
|
||||
await awardDailyVisit(userId, date);
|
||||
await refreshScores(userId);
|
||||
const existing = await getAchievements(userId);
|
||||
if (existing.length === 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_visit");
|
||||
async function recordDashboardVisit(userId: string, date: string) {
|
||||
try {
|
||||
await awardDailyVisit(userId, date);
|
||||
await refreshScores(userId);
|
||||
const existing = await getAchievements(userId);
|
||||
if (existing.length === 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_visit");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Dashboard visit bookkeeping failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
258
apps/web/src/lib/services/resource-links.ts
Normal file
258
apps/web/src/lib/services/resource-links.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db, resourceLinks } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
|
||||
const RESOURCE_BACKLOG_PATH =
|
||||
process.env.RESOURCE_BACKLOG_PATH ??
|
||||
"/home/zaine/master-folder/vault-master/Non Technical/z resources.md";
|
||||
|
||||
export const DEFAULT_RESOURCE_CATEGORY = "Interesting things to read up:";
|
||||
|
||||
export type ResourceLinkStatus = "inbox" | "exported" | "dismissed";
|
||||
|
||||
export type ResourceBacklogSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export async function createResourceLink(input: {
|
||||
userId: string;
|
||||
date: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const url = input.url.trim();
|
||||
if (!title) throw new Error("Title is required");
|
||||
if (!isValidUrl(url)) throw new Error("A valid URL is required");
|
||||
|
||||
const [row] = await db
|
||||
.insert(resourceLinks)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
date: input.date,
|
||||
weekStart: weekStartString(parseISO(input.date)),
|
||||
title,
|
||||
url,
|
||||
category: input.category?.trim() || DEFAULT_RESOURCE_CATEGORY,
|
||||
notes: input.notes?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listResourceLinks(userId: string, options?: {
|
||||
weekStart?: string;
|
||||
status?: ResourceLinkStatus;
|
||||
}) {
|
||||
const predicates = [eq(resourceLinks.userId, userId)];
|
||||
if (options?.weekStart) predicates.push(eq(resourceLinks.weekStart, options.weekStart));
|
||||
if (options?.status) predicates.push(eq(resourceLinks.status, options.status));
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(resourceLinks)
|
||||
.where(and(...predicates))
|
||||
.orderBy(desc(resourceLinks.createdAt));
|
||||
}
|
||||
|
||||
export async function updateResourceLink(input: {
|
||||
userId: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
category?: string;
|
||||
notes?: string | null;
|
||||
status?: ResourceLinkStatus;
|
||||
}) {
|
||||
const updates: Partial<typeof resourceLinks.$inferInsert> = {};
|
||||
if (input.title !== undefined) {
|
||||
const title = input.title.trim();
|
||||
if (!title) throw new Error("Title is required");
|
||||
updates.title = title;
|
||||
}
|
||||
if (input.url !== undefined) {
|
||||
const url = input.url.trim();
|
||||
if (!isValidUrl(url)) throw new Error("A valid URL is required");
|
||||
updates.url = url;
|
||||
}
|
||||
if (input.category !== undefined) {
|
||||
updates.category = input.category.trim() || DEFAULT_RESOURCE_CATEGORY;
|
||||
}
|
||||
if (input.notes !== undefined) updates.notes = input.notes?.trim() || null;
|
||||
if (input.status !== undefined) updates.status = input.status;
|
||||
|
||||
const [row] = await db
|
||||
.update(resourceLinks)
|
||||
.set(updates)
|
||||
.where(and(eq(resourceLinks.userId, input.userId), eq(resourceLinks.id, input.id)))
|
||||
.returning();
|
||||
if (!row) throw new Error("Resource link not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteResourceLink(userId: string, id: string) {
|
||||
await db
|
||||
.delete(resourceLinks)
|
||||
.where(and(eq(resourceLinks.userId, userId), eq(resourceLinks.id, id)));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function getResourceBacklogSections(): Promise<ResourceBacklogSection[]> {
|
||||
const markdown = await readBacklog();
|
||||
return parseTopLevelSections(markdown);
|
||||
}
|
||||
|
||||
export async function exportResourceLinksToBacklog(input: {
|
||||
userId: string;
|
||||
weekStart: string;
|
||||
ids: string[];
|
||||
}) {
|
||||
if (input.ids.length === 0) throw new Error("Select at least one link to export");
|
||||
|
||||
const links = await db
|
||||
.select()
|
||||
.from(resourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLinks.userId, input.userId),
|
||||
eq(resourceLinks.weekStart, input.weekStart),
|
||||
eq(resourceLinks.status, "inbox"),
|
||||
inArray(resourceLinks.id, input.ids)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(resourceLinks.createdAt));
|
||||
|
||||
if (links.length === 0) throw new Error("No inbox links found for export");
|
||||
|
||||
let markdown = await readBacklog();
|
||||
const weekLabel = format(parseISO(input.weekStart), "dd/MM/yy");
|
||||
|
||||
const byCategory = new Map<string, typeof links>();
|
||||
for (const link of links) {
|
||||
const category = link.category || DEFAULT_RESOURCE_CATEGORY;
|
||||
byCategory.set(category, [...(byCategory.get(category) ?? []), link]);
|
||||
}
|
||||
|
||||
for (const [category, categoryLinks] of byCategory) {
|
||||
const lines = categoryLinks.map(formatMarkdownLink);
|
||||
markdown = appendLinksToSection(markdown, category, weekLabel, lines);
|
||||
}
|
||||
|
||||
await fs.writeFile(RESOURCE_BACKLOG_PATH, markdown, "utf8");
|
||||
await db
|
||||
.update(resourceLinks)
|
||||
.set({ status: "exported", exportedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLinks.userId, input.userId),
|
||||
inArray(resourceLinks.id, links.map((link) => link.id))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
exported: links.length,
|
||||
path: RESOURCE_BACKLOG_PATH,
|
||||
weekLabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBacklog() {
|
||||
try {
|
||||
return await fs.readFile(RESOURCE_BACKLOG_PATH, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTopLevelSections(markdown: string): ResourceBacklogSection[] {
|
||||
const sections = markdown
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^(#{1})\s+(.+?)\s*$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => ({ level: 1, title: match[2] }));
|
||||
|
||||
if (sections.length === 0) {
|
||||
return [{ level: 1, title: DEFAULT_RESOURCE_CATEGORY }];
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendLinksToSection(
|
||||
markdown: string,
|
||||
category: string,
|
||||
weekLabel: string,
|
||||
links: string[]
|
||||
) {
|
||||
const normalizedCategory = normalizeHeading(category);
|
||||
const lines = markdown.replace(/\s*$/, "\n").split(/\r?\n/);
|
||||
const categoryIndex = lines.findIndex(
|
||||
(line) => line.startsWith("# ") && normalizeHeading(line.slice(2)) === normalizedCategory
|
||||
);
|
||||
|
||||
if (categoryIndex === -1) {
|
||||
lines.push("", `# ${category}`, "", `## ${weekLabel}`, ...links);
|
||||
return lines.join("\n").replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
const nextCategoryIndex = findNextTopLevelHeading(lines, categoryIndex + 1);
|
||||
const categoryEnd = nextCategoryIndex === -1 ? lines.length : nextCategoryIndex;
|
||||
const weekIndex = lines.findIndex(
|
||||
(line, index) =>
|
||||
index > categoryIndex &&
|
||||
index < categoryEnd &&
|
||||
line.startsWith("## ") &&
|
||||
normalizeHeading(line.slice(3)) === normalizeHeading(weekLabel)
|
||||
);
|
||||
|
||||
if (weekIndex !== -1) {
|
||||
const insertAt = findSectionEnd(lines, weekIndex + 1, categoryEnd);
|
||||
lines.splice(insertAt, 0, ...links);
|
||||
} else {
|
||||
lines.splice(categoryEnd, 0, "", `## ${weekLabel}`, ...links);
|
||||
}
|
||||
|
||||
return lines.join("\n").replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
function findNextTopLevelHeading(lines: string[], start: number) {
|
||||
for (let i = start; i < lines.length; i += 1) {
|
||||
if (lines[i].startsWith("# ")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSectionEnd(lines: string[], start: number, max: number) {
|
||||
for (let i = start; i < max; i += 1) {
|
||||
if (lines[i].startsWith("## ")) return i;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function formatMarkdownLink(link: typeof resourceLinks.$inferSelect) {
|
||||
const title = escapeMarkdownLinkText(link.title);
|
||||
const url = link.url.replace(/>/g, "%3E");
|
||||
return `- [ ] [${title}](<${url}>)`;
|
||||
}
|
||||
|
||||
function escapeMarkdownLinkText(text: string) {
|
||||
return text.replace(/[[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeHeading(text: string) {
|
||||
return text.trim().replace(/:$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isValidUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
186
docs/MEMORIES.md
Normal file
186
docs/MEMORIES.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# How Memories Work
|
||||
|
||||
AdventureOS memories are durable facts the mentor can use as context in future replies. They are separate from raw chat logs, reflections, and adventure history. A saved memory has a category, title, content, source, and enabled/disabled state.
|
||||
|
||||
The fastest way to make the mentor register a memory request is to use direct save language at the start of your message.
|
||||
|
||||
## Best Phrases To Use
|
||||
|
||||
Use one of these phrases, then state the fact clearly:
|
||||
|
||||
- `Remember that ...`
|
||||
- `Remember this as ...`
|
||||
- `Save this ...`
|
||||
- `Save this as ...`
|
||||
- `Add this to memory ...`
|
||||
- `Add this as ...`
|
||||
- `Add a memory ...`
|
||||
- `I want you to add a memory ...`
|
||||
- `The memory will be ...`
|
||||
|
||||
Good examples:
|
||||
|
||||
- `Remember that I prefer short explanations.`
|
||||
- `Save this: I get discouraged when my task list is too large.`
|
||||
- `Add this to memory: I like learning with worked examples.`
|
||||
- `I want you to add a memory for me, and the memory will be a goal type: One of my goals for the next few weeks is to wake up at 7am every morning.`
|
||||
|
||||
## Tone
|
||||
|
||||
You do not need a special tone. Be explicit, plain, and declarative.
|
||||
|
||||
Strong memory requests usually have three parts:
|
||||
|
||||
- A save phrase: `remember`, `save`, or `add this to memory`
|
||||
- A complete fact about you
|
||||
- Enough detail that it will still make sense later
|
||||
|
||||
Less reliable:
|
||||
|
||||
- `That's useful for later.`
|
||||
- `You should know this.`
|
||||
- `Keep that in mind.`
|
||||
- `Maybe remember this somehow.`
|
||||
|
||||
Those may make sense to a human, but the app is looking for direct memory intent.
|
||||
|
||||
## What Happens After A Request
|
||||
|
||||
When you send an explicit memory request in mentor chat, AdventureOS first tries to extract a memory candidate from your message.
|
||||
|
||||
Explicit save requests do not require memory learning to be enabled. They use the memory action path directly.
|
||||
|
||||
If approval is not required, the memory is saved immediately and the mentor should say something like:
|
||||
|
||||
`I've saved that as a goal memory: "Wake Up at 7am Every Morning".`
|
||||
|
||||
If approval is required, or if the category is sensitive, the app creates a pending suggestion instead. The mentor should say something like:
|
||||
|
||||
`I've created a memory suggestion for review: "Wake Up at 7am Every Morning".`
|
||||
|
||||
A suggestion is not confirmed memory yet. You can accept, edit, reject, or ignore suggestions in `Settings -> AI Memory -> Suggestions`.
|
||||
|
||||
## Approving Or Rejecting In Chat
|
||||
|
||||
If the mentor has just created a pending memory suggestion in the same chat, you can approve it with:
|
||||
|
||||
- `approved`
|
||||
- `yes approved`
|
||||
- `save it`
|
||||
- `confirm it`
|
||||
- `accept it`
|
||||
- `that is correct`
|
||||
- `that's correct`
|
||||
- `yes that's right`
|
||||
- `yes that is right`
|
||||
- `yes, as a goal, approved`
|
||||
|
||||
You can reject it with:
|
||||
|
||||
- `reject it`
|
||||
- `don't save that`
|
||||
- `do not save that`
|
||||
- `forget that`
|
||||
- `ignore that suggestion`
|
||||
- `no, don't remember that`
|
||||
- `no, do not remember that`
|
||||
|
||||
Editing a pending memory directly in chat is currently limited. If you say `change it to`, `edit that`, `update that suggestion`, or `make it`, the mentor should tell you to edit the pending suggestion card before approving it.
|
||||
|
||||
## Passive Memory Suggestions
|
||||
|
||||
AdventureOS can also suggest memories from ordinary messages when memory learning is enabled. This is different from explicit save requests.
|
||||
|
||||
Examples that can become suggestions:
|
||||
|
||||
- `One of my goals for the next few weeks is to wake up at 7am every morning.`
|
||||
- `I like learning with examples.`
|
||||
- `I dislike too many tasks at once.`
|
||||
- `I get worried when I fall behind.`
|
||||
- `I prefer concise explanations.`
|
||||
- `I want the AI to be direct with me.`
|
||||
- `I struggle with planning my week.`
|
||||
|
||||
Passive learning depends on `Settings -> AI Memory -> Learning`:
|
||||
|
||||
- `Enable memory learning` must be on.
|
||||
- `Suggest after reflections` must be on for reflection-based suggestions.
|
||||
- `Allow learning sensitive categories` controls sensitive categories.
|
||||
- `Require approval before saving` decides whether suggestions must be reviewed before becoming confirmed memories.
|
||||
|
||||
The underlying settings also include chat-based suggestions. By default, chat suggestions are enabled when memory learning is enabled, but explicit save requests are still the most reliable route when you definitely want a memory created.
|
||||
|
||||
## Categories The App Understands
|
||||
|
||||
Memories are grouped into categories:
|
||||
|
||||
- Identity & background
|
||||
- Long-term goals
|
||||
- Current goals
|
||||
- Likes
|
||||
- Dislikes
|
||||
- Motivators
|
||||
- Things that discourage
|
||||
- Daily routines
|
||||
- Weekly routines
|
||||
- Spiritual practices
|
||||
- Reading preferences
|
||||
- Learning interests
|
||||
- Exercise preferences
|
||||
- Work/study commitments
|
||||
- Worries & concerns
|
||||
- Preferred AI tone
|
||||
- Boundaries & avoid
|
||||
- Important personal context
|
||||
- Current life season
|
||||
- Open questions about user
|
||||
|
||||
If your wording mentions goals, current goals, trying to do something, or wanting to do something, the app will usually classify it as `Current goals`. If your wording says you prefer a kind of explanation, it will usually classify it as `Preferred AI tone`. If your wording says you like learning a certain way, it may classify it as `Learning interests`.
|
||||
|
||||
## Manual Control
|
||||
|
||||
You can bypass chat phrasing entirely in `Settings -> AI Memory -> Memories`.
|
||||
|
||||
From there you can:
|
||||
|
||||
- Add a memory manually
|
||||
- Search memories
|
||||
- Filter by category
|
||||
- Disable or re-enable a memory
|
||||
- Archive a memory
|
||||
- Export memories
|
||||
- Reset all memories
|
||||
- Edit the profile summary used as compact mentor context
|
||||
- Rebuild the profile summary from saved memories
|
||||
|
||||
## Practical Recipes
|
||||
|
||||
For a goal:
|
||||
|
||||
`Remember that one of my current goals is to read for 20 minutes every evening.`
|
||||
|
||||
For tone:
|
||||
|
||||
`Remember that I prefer direct explanations with one example.`
|
||||
|
||||
For a worry:
|
||||
|
||||
`Save this: I get worried when I fall behind and need help choosing the next small step.`
|
||||
|
||||
For a boundary:
|
||||
|
||||
`Add this to memory: I do not want shame-based motivation.`
|
||||
|
||||
For a learning preference:
|
||||
|
||||
`Remember that I like learning with examples before theory.`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the mentor does not register the memory request, rewrite it with `Remember that ...` or `Save this: ...`.
|
||||
|
||||
If the mentor says it created a suggestion, go to `Settings -> AI Memory -> Suggestions` and accept it.
|
||||
|
||||
If the mentor says there is no pending suggestion to approve, the suggestion was not created in the current chat, was already handled, or the approval phrase was too detached from the original request.
|
||||
|
||||
If a memory already exists, AdventureOS avoids creating a duplicate and should tell you that the memory is already saved or pending review.
|
||||
@@ -14,6 +14,10 @@ See the full product specification in your Cursor plan file. Key modules:
|
||||
- **Achievement Gallery** — 20+ milestones
|
||||
- **Control Panel** — Settings, templates, export, themes
|
||||
|
||||
Related docs:
|
||||
|
||||
- [How Memories Work](./MEMORIES.md)
|
||||
|
||||
## Anti-Burnout Rules
|
||||
|
||||
- No XP loss, no streak destruction, no failure screens
|
||||
|
||||
19
packages/db/drizzle/0004_resource_links.sql
Normal file
19
packages/db/drizzle/0004_resource_links.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS "resource_links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"week_start" date NOT NULL,
|
||||
"title" varchar(300) NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"category" varchar(120) DEFAULT 'Interesting things to read up:' NOT NULL,
|
||||
"notes" text,
|
||||
"status" varchar(20) DEFAULT 'inbox' NOT NULL,
|
||||
"exported_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "resource_links" ADD CONSTRAINT "resource_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "resource_links_user_week_idx" ON "resource_links" ("user_id", "week_start");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "resource_links_user_status_idx" ON "resource_links" ("user_id", "status");
|
||||
@@ -238,6 +238,29 @@ export const weeklyReviews = pgTable("weekly_reviews", {
|
||||
userIntention: text("user_intention"),
|
||||
});
|
||||
|
||||
export const resourceLinks = pgTable(
|
||||
"resource_links",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
date: date("date").notNull(),
|
||||
weekStart: date("week_start").notNull(),
|
||||
title: varchar("title", { length: 300 }).notNull(),
|
||||
url: text("url").notNull(),
|
||||
category: varchar("category", { length: 120 }).notNull().default("Interesting things to read up:"),
|
||||
notes: text("notes"),
|
||||
status: varchar("status", { length: 20 }).notNull().default("inbox"),
|
||||
exportedAt: timestamp("exported_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("resource_links_user_week_idx").on(t.userId, t.weekStart),
|
||||
index("resource_links_user_status_idx").on(t.userId, t.status),
|
||||
]
|
||||
);
|
||||
|
||||
export const xpEvents = pgTable("xp_events", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
@@ -439,6 +462,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
aiMemories: many(aiMemories),
|
||||
aiMemorySuggestions: many(aiMemorySuggestions),
|
||||
aiChatSessions: many(aiChatSessions),
|
||||
resourceLinks: many(resourceLinks),
|
||||
}));
|
||||
|
||||
export const adventureTemplatesRelations = relations(
|
||||
|
||||
Reference in New Issue
Block a user