Add offline monthly insights with Ollama
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user