docs for memories
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-29 15:00:34 +01:00
parent 701d39982f
commit 421c96c814
14 changed files with 956 additions and 33 deletions

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

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

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

View File

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

View File

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

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

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

View File

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

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