From c7eb982cf7c6d163d246cfb4d4aaa34094960518 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 20:23:45 +0300 Subject: [PATCH 1/5] feat(reports): localStorage cache per range, auto-load on switch, createdAt badge --- .../app/dashboard/reports/report-form.tsx | 107 ++++++++++++++---- 1 file changed, 83 insertions(+), 24 deletions(-) diff --git a/dashboard/app/dashboard/reports/report-form.tsx b/dashboard/app/dashboard/reports/report-form.tsx index 945f269..349ee88 100644 --- a/dashboard/app/dashboard/reports/report-form.tsx +++ b/dashboard/app/dashboard/reports/report-form.tsx @@ -26,7 +26,34 @@ type Status = | { kind: "idle" } | { kind: "loading" } | { kind: "error"; message: string } - | { kind: "success"; result: GenerateReportResponse }; + | { kind: "success"; result: GenerateReportResponse; createdAt: string }; + +// ─── localStorage cache ─────────────────────────────────────────────────────── + +type CachedReport = { result: GenerateReportResponse; createdAt: string }; + +function reportCacheKey(range: RangePreset, from: string, to: string): string { + return range === "custom" + ? `wt:report:custom:${from}:${to}` + : `wt:report:${range}`; +} + +function loadCache(key: string): CachedReport | null { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as CachedReport) : null; + } catch { return null; } +} + +function saveCache(key: string, data: CachedReport): void { + try { localStorage.setItem(key, JSON.stringify(data)); } catch { /* quota */ } +} + +function fmtCreatedAt(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + }); +} function todayISO(): string { const d = new Date(); @@ -53,7 +80,14 @@ export function ReportForm() { const [range, setRange] = useState("last_7d"); const [from, setFrom] = useState(""); const [to, setTo] = useState(todayISO()); - const [status, setStatus] = useState({ kind: "idle" }); + // Lazy init: read localStorage for the default range before first render + const [status, setStatus] = useState(() => { + try { + const cached = loadCache(reportCacheKey("last_7d", "", "")); + if (cached) return { kind: "success", result: cached.result, createdAt: cached.createdAt }; + } catch { /* localStorage unavailable (SSR guard) */ } + return { kind: "idle" }; + }); const [apiKey, setApiKey] = useState(null); const [modalOpen, setModalOpen] = useState(false); @@ -65,6 +99,35 @@ export function ReportForm() { return () => { cancelled = true; }; }, []); + // Range button click — load cache in the event handler (not in an effect) + function handleRangeChange(newRange: RangePreset) { + setRange(newRange); + if (newRange === "custom") return; + const cached = loadCache(reportCacheKey(newRange, "", "")); + setStatus(cached + ? { kind: "success", result: cached.result, createdAt: cached.createdAt } + : { kind: "idle" }); + } + + // Custom date changes — check cache when both dates are present + function handleFromChange(newFrom: string) { + setFrom(newFrom); + if (!newFrom || !to) { setStatus({ kind: "idle" }); return; } + const cached = loadCache(reportCacheKey("custom", newFrom, to)); + setStatus(cached + ? { kind: "success", result: cached.result, createdAt: cached.createdAt } + : { kind: "idle" }); + } + + function handleToChange(newTo: string) { + setTo(newTo); + if (!from || !newTo) { setStatus({ kind: "idle" }); return; } + const cached = loadCache(reportCacheKey("custom", from, newTo)); + setStatus(cached + ? { kind: "success", result: cached.result, createdAt: cached.createdAt } + : { kind: "idle" }); + } + const isCustom = range === "custom"; const isBusy = status.kind === "loading"; @@ -81,7 +144,9 @@ export function ReportForm() { } try { const result = await generateReport(input); - setStatus({ kind: "success", result }); + const createdAt = new Date().toISOString(); + saveCache(reportCacheKey(range, from, to), { result, createdAt }); + setStatus({ kind: "success", result, createdAt }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to generate report"; setStatus({ kind: "error", message }); @@ -116,7 +181,7 @@ export function ReportForm() { - - + ) : null} + + {typeof window !== "undefined" && createPortal( + + + {/* ── Modal 1: account options ── */} + {menu === "account" && ( + <> + setMenu(null)} + aria-hidden + /> + e.stopPropagation()} + > +
+
+

+ ACCOUNT +

+ +
+ +
+ + +
+
+
+ + )} + + {/* ── Modal 2: delete confirmation ── */} + {menu === "delete-confirm" && ( + <> + setMenu("account")} + aria-hidden + /> + e.stopPropagation()} + > +
+
+

+ DELETE ACCOUNT +

+ +
+ +

+ This will permanently delete all your events, + sessions, music data, and settings. This action cannot be undone. +

+ +
+ + +
+
+
+ + )} + +
, + document.body, + )} + + ); +} diff --git a/dashboard/app/dashboard/app-header.tsx b/dashboard/app/dashboard/app-header.tsx index c2af845..d37f122 100644 --- a/dashboard/app/dashboard/app-header.tsx +++ b/dashboard/app/dashboard/app-header.tsx @@ -1,4 +1,4 @@ -import { LogoutButton } from "./logout-button"; +import { AccountMenu } from "./account-menu"; import { HeaderNav } from "./header-nav"; import { MobileNav } from "./mobile-nav"; @@ -52,7 +52,7 @@ export function AppHeader({ email, name, picture }: Props) { > {displayName} - + From c1b42f07078b7b779a27ef5bb3761dbf00d14a3d Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 20:26:10 +0300 Subject: [PATCH 3/5] fix(reports): scope cache key by normalized user email to prevent cross-account bleed --- dashboard/app/dashboard/reports/page.tsx | 2 +- .../app/dashboard/reports/report-form.tsx | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/dashboard/app/dashboard/reports/page.tsx b/dashboard/app/dashboard/reports/page.tsx index 1f55bf5..f813ba7 100644 --- a/dashboard/app/dashboard/reports/page.tsx +++ b/dashboard/app/dashboard/reports/page.tsx @@ -18,7 +18,7 @@ export default async function ReportsPage() {

AI SESSION REPORT

- + ); } diff --git a/dashboard/app/dashboard/reports/report-form.tsx b/dashboard/app/dashboard/reports/report-form.tsx index 349ee88..7641167 100644 --- a/dashboard/app/dashboard/reports/report-form.tsx +++ b/dashboard/app/dashboard/reports/report-form.tsx @@ -32,10 +32,11 @@ type Status = type CachedReport = { result: GenerateReportResponse; createdAt: string }; -function reportCacheKey(range: RangePreset, from: string, to: string): string { +function reportCacheKey(email: string, range: RangePreset, from: string, to: string): string { + const u = email.toLowerCase().trim(); return range === "custom" - ? `wt:report:custom:${from}:${to}` - : `wt:report:${range}`; + ? `wt:report:${u}:custom:${from}:${to}` + : `wt:report:${u}:${range}`; } function loadCache(key: string): CachedReport | null { @@ -75,7 +76,7 @@ function downloadMarkdown(markdown: string, rangeLabel: string): void { URL.revokeObjectURL(url); } -export function ReportForm() { +export function ReportForm({ userEmail }: { userEmail: string }) { const toast = useToast(); const [range, setRange] = useState("last_7d"); const [from, setFrom] = useState(""); @@ -83,7 +84,7 @@ export function ReportForm() { // Lazy init: read localStorage for the default range before first render const [status, setStatus] = useState(() => { try { - const cached = loadCache(reportCacheKey("last_7d", "", "")); + const cached = loadCache(reportCacheKey(userEmail, "last_7d", "", "")); if (cached) return { kind: "success", result: cached.result, createdAt: cached.createdAt }; } catch { /* localStorage unavailable (SSR guard) */ } return { kind: "idle" }; @@ -103,7 +104,7 @@ export function ReportForm() { function handleRangeChange(newRange: RangePreset) { setRange(newRange); if (newRange === "custom") return; - const cached = loadCache(reportCacheKey(newRange, "", "")); + const cached = loadCache(reportCacheKey(userEmail, newRange, "", "")); setStatus(cached ? { kind: "success", result: cached.result, createdAt: cached.createdAt } : { kind: "idle" }); @@ -113,7 +114,7 @@ export function ReportForm() { function handleFromChange(newFrom: string) { setFrom(newFrom); if (!newFrom || !to) { setStatus({ kind: "idle" }); return; } - const cached = loadCache(reportCacheKey("custom", newFrom, to)); + const cached = loadCache(reportCacheKey(userEmail, "custom", newFrom, to)); setStatus(cached ? { kind: "success", result: cached.result, createdAt: cached.createdAt } : { kind: "idle" }); @@ -122,7 +123,7 @@ export function ReportForm() { function handleToChange(newTo: string) { setTo(newTo); if (!from || !newTo) { setStatus({ kind: "idle" }); return; } - const cached = loadCache(reportCacheKey("custom", from, newTo)); + const cached = loadCache(reportCacheKey(userEmail, "custom", from, newTo)); setStatus(cached ? { kind: "success", result: cached.result, createdAt: cached.createdAt } : { kind: "idle" }); @@ -145,7 +146,7 @@ export function ReportForm() { try { const result = await generateReport(input); const createdAt = new Date().toISOString(); - saveCache(reportCacheKey(range, from, to), { result, createdAt }); + saveCache(reportCacheKey(userEmail, range, from, to), { result, createdAt }); setStatus({ kind: "success", result, createdAt }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to generate report"; From 04445fffd062d7cf47bcc8214f844f96b3dd4bef Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 20:30:09 +0300 Subject: [PATCH 4/5] fix(account): escape key, error display, localStorage cleanup on logout/delete, hydration fix --- dashboard/app/dashboard/account-menu.tsx | 51 +++++++++++++++++++----- dashboard/app/dashboard/app-header.tsx | 2 +- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/dashboard/app/dashboard/account-menu.tsx b/dashboard/app/dashboard/account-menu.tsx index 0bee4f6..5dc607d 100644 --- a/dashboard/app/dashboard/account-menu.tsx +++ b/dashboard/app/dashboard/account-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; import { useRouter } from "next/navigation"; @@ -22,16 +22,39 @@ const panel = { transition: { type: "spring" as const, stiffness: 400, damping: 28, mass: 0.8 }, }; -export function AccountMenu() { +// Remove all cached report keys for a given email (or all wt:report:* keys) +function clearReportCache(email: string) { + try { + const prefix = `wt:report:${email.toLowerCase().trim()}:`; + Object.keys(localStorage) + .filter((k) => k.startsWith(prefix)) + .forEach((k) => localStorage.removeItem(k)); + } catch { /* localStorage unavailable */ } +} + +export function AccountMenu({ userEmail }: { userEmail: string }) { const router = useRouter(); const queryClient = useQueryClient(); - const [menu, setMenu] = useState(null); - const [pending, setPending] = useState(false); + const [menu, setMenu] = useState(null); + const [pending, setPending] = useState(false); + const [deleteErr, setDeleteErr] = useState(null); + + // Escape key closes the active modal + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (menu === "delete-confirm") setMenu("account"); + else if (menu === "account") setMenu(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [menu]); async function handleLogout() { setPending(true); try { await fetch("/api/auth/logout", { method: "POST" }); + clearReportCache(userEmail); } finally { queryClient.clear(); router.replace("/login"); @@ -41,21 +64,25 @@ export function AccountMenu() { async function handleDelete() { setPending(true); + setDeleteErr(null); try { const res = await fetch("/api/auth/delete-account", { method: "DELETE" }); - if (!res.ok) throw new Error("Failed to delete account"); + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + clearReportCache(userEmail); queryClient.clear(); router.replace("/login"); router.refresh(); - } catch { + } catch (err) { + setDeleteErr(err instanceof Error ? err.message : "Failed to delete account"); setPending(false); - setMenu("account"); } } return ( <> - {/* Trigger — same style as the old LogoutButton */} - {typeof window !== "undefined" && createPortal( + {createPortal( {/* ── Modal 1: account options ── */} @@ -104,7 +131,7 @@ export function AccountMenu() { - {createPortal( + {typeof document !== "undefined" && createPortal( {/* ── Modal 1: account options ── */} diff --git a/dashboard/app/dashboard/logout-button.tsx b/dashboard/app/dashboard/logout-button.tsx deleted file mode 100644 index 0f3854e..0000000 --- a/dashboard/app/dashboard/logout-button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; - -export function LogoutButton() { - const router = useRouter(); - const queryClient = useQueryClient(); - const [pending, setPending] = useState(false); - - return ( - - ); -}