diff --git a/dashboard/app/api/auth/delete-account/route.ts b/dashboard/app/api/auth/delete-account/route.ts new file mode 100644 index 0000000..855c102 --- /dev/null +++ b/dashboard/app/api/auth/delete-account/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/server/current-user"; +import { prisma } from "@/server/db"; +import { SESSION_COOKIE, clearedCookieOptions } from "@/server/cookies"; + +export async function DELETE() { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Cascade in schema handles Sessions → Events/Tracks, and UserSetting + await prisma.user.delete({ where: { id: user.id } }); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", clearedCookieOptions()); + return res; +} diff --git a/dashboard/app/dashboard/account-menu.tsx b/dashboard/app/dashboard/account-menu.tsx new file mode 100644 index 0000000..183a24a --- /dev/null +++ b/dashboard/app/dashboard/account-menu.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; + +type Menu = null | "account" | "delete-confirm"; + +const backdrop = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.2 }, +}; + +const panel = { + initial: { opacity: 0, scale: 0.92, y: 16 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.95, y: 8 }, + transition: { type: "spring" as const, stiffness: 400, damping: 28, mass: 0.8 }, +}; + +// 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 [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"); + router.refresh(); + } + } + + async function handleDelete() { + setPending(true); + setDeleteErr(null); + try { + const res = await fetch("/api/auth/delete-account", { method: "DELETE" }); + 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 (err) { + setDeleteErr(err instanceof Error ? err.message : "Failed to delete account"); + setPending(false); + } + } + + return ( + <> + + + {typeof document !== "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. +

+ + {deleteErr ? ( +

{deleteErr}

+ ) : null} + +
+ + +
+
+
+ + )} + +
, + document.body, + )} + + ); +} diff --git a/dashboard/app/dashboard/app-header.tsx b/dashboard/app/dashboard/app-header.tsx index c2af845..0623675 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} - + 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 ( - - ); -} 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 945f269..7641167 100644 --- a/dashboard/app/dashboard/reports/report-form.tsx +++ b/dashboard/app/dashboard/reports/report-form.tsx @@ -26,7 +26,35 @@ 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(email: string, range: RangePreset, from: string, to: string): string { + const u = email.toLowerCase().trim(); + return range === "custom" + ? `wt:report:${u}:custom:${from}:${to}` + : `wt:report:${u}:${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(); @@ -48,12 +76,19 @@ 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(""); 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(userEmail, "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 +100,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(userEmail, 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(userEmail, "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(userEmail, "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 +145,9 @@ export function ReportForm() { } try { const result = await generateReport(input); - setStatus({ kind: "success", result }); + const createdAt = new Date().toISOString(); + 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"; setStatus({ kind: "error", message }); @@ -116,7 +182,7 @@ export function ReportForm() { - - + ) : null}