Add offline monthly insights with Ollama

This commit is contained in:
2026-03-23 14:12:35 -04:00
parent 696d393fca
commit a745c0ca1e
13 changed files with 415 additions and 60 deletions

View File

@@ -9,6 +9,11 @@ import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
type DashboardSnapshot = {
month: string;
insight: {
summary: string;
recommendations: string;
generatedAt: string;
} | null;
totals: {
expensesCents: number;
paychecksCents: number;
@@ -29,22 +34,27 @@ export function HomeDashboard() {
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [insightBusy, setInsightBusy] = useState(false);
useEffect(() => {
async function loadDashboard() {
const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
async function loadDashboard(month: string) {
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
if (!response.ok) {
setError(payload.error ?? "Could not load the dashboard.");
return;
}
setError(null);
setSnapshot(payload);
if (!response.ok) {
setError(payload.error ?? "Could not load the dashboard.");
return;
}
void loadDashboard();
setError(null);
setSnapshot(payload);
}
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadDashboard(selectedMonth);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [selectedMonth]);
const topCategoryLabel = useMemo(() => {
@@ -60,6 +70,22 @@ export function HomeDashboard() {
? "No spend yet"
: formatPercent(snapshot.totals.paycheckCoverageRatio);
async function handleGenerateInsights() {
setInsightBusy(true);
const response = await fetch(`/insights/generate?month=${selectedMonth}`, { method: "POST" });
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setInsightBusy(false);
if (!response.ok) {
setError(payload?.error ?? "Could not generate insights.");
return;
}
await loadDashboard(selectedMonth);
}
return (
<div className="space-y-10">
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
@@ -119,6 +145,43 @@ export function HomeDashboard() {
</div>
</section>
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Offline guidance for this month</h2>
</div>
<button
type="button"
onClick={() => void handleGenerateInsights()}
disabled={insightBusy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{insightBusy ? "Generating..." : "Generate insights"}
</button>
</div>
{snapshot?.insight ? (
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Summary</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.summary}</p>
</article>
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.recommendations}</p>
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
</p>
</article>
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
No saved insight for this month yet. Generate one to get a private offline summary.
</div>
)}
</section>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="flex items-center justify-between gap-4">