From cbbdc8a1dc8bd10496b16f04b92165598e8542d7 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 21:53:18 +0200 Subject: [PATCH] Enhance dashboard layout and sidebar functionality; improve session metrics calculations and API error handling --- .github/dependabot.yml | 12 ++ app/dashboard/layout.tsx | 67 +++++-- app/dashboard/overview/page.tsx | 12 +- components/Sidebar.tsx | 319 ++++++++++++++++++-------------- components/WordCloud.tsx | 20 +- lib/metrics.ts | 25 ++- lib/types.ts | 4 + pages/api/forgot-password.ts | 25 +-- pages/api/reset-password.ts | 17 +- 9 files changed, 296 insertions(+), 205 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f6a6830..fac1eed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,18 +9,30 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "docker" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "docker-compose" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index fe86322..ce5f813 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,16 +1,44 @@ "use client"; -import { ReactNode } from "react"; -import { useSession, signOut } from "next-auth/react"; +import { ReactNode, useState, useEffect, useCallback } from "react"; +import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import Sidebar from "../../components/Sidebar"; export default function DashboardLayout({ children }: { children: ReactNode }) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - const { data: session, status } = useSession(); + const { status } = useSession(); const router = useRouter(); - // Redirect if not authenticated + const [isSidebarExpanded, setIsSidebarExpanded] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const updateStatesBasedOnScreen = () => { + const screenIsMobile = window.innerWidth < 640; // sm breakpoint for mobile + const screenIsSmallDesktop = window.innerWidth < 768 && !screenIsMobile; // between sm and md + + setIsMobile(screenIsMobile); + setIsSidebarExpanded(!screenIsSmallDesktop && !screenIsMobile); + }; + + updateStatesBasedOnScreen(); + window.addEventListener("resize", updateStatesBasedOnScreen); + return () => + window.removeEventListener("resize", updateStatesBasedOnScreen); + }, []); + + // Toggle sidebar handler - used for clicking the toggle button + const toggleSidebarHandler = useCallback(() => { + setIsSidebarExpanded((prev) => !prev); + }, []); + + // Collapse sidebar handler - used when clicking navigation links on mobile + const collapseSidebar = useCallback(() => { + if (isMobile) { + setIsSidebarExpanded(false); + } + }, [isMobile]); + if (status === "unauthenticated") { router.push("/login"); return ( @@ -20,7 +48,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { ); } - // Show loading state while session status is being determined if (status === "loading") { return (
@@ -29,20 +56,26 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { ); } - // Defined for potential future use, like adding a logout button in the layout - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - const handleLogout = () => { - signOut({ callbackUrl: "/login" }); - }; - return (
- {/* Sidebar with logout handler passed as prop */} - + - {/* Main content */} -
-
{children}
+
+ {/*
{children}
*/} +
{children}
); diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index a3ae1cd..a40c463 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -41,6 +41,12 @@ function DashboardContent() { setLoading(true); const res = await fetch("/api/dashboard/metrics"); const data = await res.json(); + console.log("Metrics from API:", { + avgSessionLength: data.metrics.avgSessionLength, + avgSessionTimeTrend: data.metrics.avgSessionTimeTrend, + totalSessionDuration: data.metrics.totalSessionDuration, + validSessionsForDuration: data.metrics.validSessionsForDuration, + }); setMetrics(data.metrics); setCompany(data.company); setLoading(false); @@ -284,7 +290,7 @@ function DashboardContent() { />
-
+

Token Usage & Costs

-
+
Total Tokens: {metrics.totalTokens?.toLocaleString() || 0} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 6f4232d..07ee60f 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import React from "react"; // No hooks needed since state is now managed by parent import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; @@ -110,29 +110,40 @@ const LogoutIcon = () => ( ); -const ToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => ( +const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => ( - + {isExpanded ? ( + + ) : ( + + )} ); +export interface SidebarProps { + isExpanded: boolean; + onToggle: () => void; + isMobile?: boolean; // Add this property to indicate mobile viewport + onNavigate?: () => void; // Function to call when navigating to a new page +} + interface NavItemProps { href: string; label: string; icon: React.ReactNode; isExpanded: boolean; isActive: boolean; + onNavigate?: () => void; // Function to call when navigating to a new page } const NavItem: React.FC = ({ @@ -141,6 +152,7 @@ const NavItem: React.FC = ({ icon, isExpanded, isActive, + onNavigate, }) => ( = ({ ? "bg-sky-100 text-sky-800 font-medium" : "hover:bg-gray-100 text-gray-700 hover:text-gray-900" }`} + onClick={() => { + if (onNavigate) { + onNavigate(); + } + }} > {icon} @@ -168,145 +185,173 @@ const NavItem: React.FC = ({ ); -export default function Sidebar() { - const [isExpanded, setIsExpanded] = useState(true); +export default function Sidebar({ + isExpanded, + onToggle, + isMobile = false, + onNavigate, +}: SidebarProps) { const pathname = usePathname() || ""; - const toggleSidebar = () => { - setIsExpanded(!isExpanded); - }; - const handleLogout = () => { signOut({ callbackUrl: "/login" }); }; return ( -
- {/* Logo section - now above toggle button */} -
+ <> + {/* Backdrop overlay when sidebar is expanded on mobile */} + {isExpanded && isMobile && (
- LiveDash Logo + className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300" + onClick={onToggle} + /> + )} + +
+
+ {/* Toggle button when sidebar is collapsed - above logo */} + {!isExpanded && ( +
+ +
+ )} + + {/* Logo section with link to homepage */} + +
+ LiveDash Logo +
+ {isExpanded && ( + + LiveDash + + )} +
{isExpanded && ( - LiveDash +
+ +
)} -
- {/* Toggle button */} -
- + } + isExpanded={isExpanded} + isActive={pathname === "/dashboard"} + onNavigate={onNavigate} + /> + + + + } + isExpanded={isExpanded} + isActive={pathname === "/dashboard/overview"} + onNavigate={onNavigate} + /> + } + isExpanded={isExpanded} + isActive={pathname.startsWith("/dashboard/sessions")} + onNavigate={onNavigate} + /> + } + isExpanded={isExpanded} + isActive={pathname === "/dashboard/company"} + onNavigate={onNavigate} + /> + } + isExpanded={isExpanded} + isActive={pathname === "/dashboard/users"} + onNavigate={onNavigate} + /> + +
+ +
- {/* Navigation items */} - - {/* Logout at the bottom */} -
- -
-
+ ); } diff --git a/components/WordCloud.tsx b/components/WordCloud.tsx index c4efe8c..473fed9 100644 --- a/components/WordCloud.tsx +++ b/components/WordCloud.tsx @@ -2,15 +2,7 @@ import { useRef, useEffect, useState } from "react"; import { select } from "d3-selection"; -import cloud from "d3-cloud"; - -interface CloudWord { - text: string; - size: number; - x?: number; - y?: number; - rotate?: number; -} +import cloud, { Word } from "d3-cloud"; interface WordCloudProps { words: { @@ -53,12 +45,12 @@ export default function WordCloud({ ) .padding(5) .rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees - .fontSize((d: CloudWord) => d.size) + .fontSize((d: Word) => d.size || 10) .on("end", draw); layout.start(); - function draw(words: CloudWord[]) { + function draw(words: Word[]) { svg .append("g") .attr("transform", `translate(${width / 2},${height / 2})`) @@ -66,7 +58,7 @@ export default function WordCloud({ .data(words) .enter() .append("text") - .style("font-size", (d: CloudWord) => `${d.size}px`) + .style("font-size", (d: Word) => `${d.size || 10}px`) .style("font-family", "Inter, Arial, sans-serif") .style("fill", () => { // Create a nice gradient of colors @@ -85,10 +77,10 @@ export default function WordCloud({ .attr("text-anchor", "middle") .attr( "transform", - (d: CloudWord) => + (d: Word) => `translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})` ) - .text((d: CloudWord) => d.text); + .text((d: Word) => d.text || ""); } // Cleanup function diff --git a/lib/metrics.ts b/lib/metrics.ts index 15d8334..e06fca9 100644 --- a/lib/metrics.ts +++ b/lib/metrics.ts @@ -373,19 +373,22 @@ export function sessionMetrics( // If times are identical, duration will be 0. // If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference. const duration = Math.abs(timeDifference); + // console.log( + // `[metrics] duration is ${duration} for session ${session.id || session.sessionId}` + // ); totalSessionDuration += duration; // Add this duration if (timeDifference < 0) { // Log a specific warning if the original endTime was before startTime console.warn( - `[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / (1000 * 60)).toFixed(2)} mins).` + `[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / 1000).toFixed(2)} seconds).` ); } else if (timeDifference === 0) { - // Optionally, log if times are identical, though this might be verbose if common - console.log( - `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.` - ); + // // Optionally, log if times are identical, though this might be verbose if common + // console.log( + // `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.` + // ); } // If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case. @@ -399,7 +402,9 @@ export function sessionMetrics( } if (!session.endTime) { // This is a common case for ongoing sessions, might not always be an error - // console.log(`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`); + console.log( + `[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.` + ); } } @@ -493,7 +498,7 @@ export function sessionMetrics( const uniqueUsers = uniqueUserIds.size; const avgSessionLength = validSessionsForDuration > 0 - ? totalSessionDuration / validSessionsForDuration / 1000 / 60 // Convert ms to minutes + ? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes : 0; const avgResponseTime = validSessionsForResponseTime > 0 @@ -510,6 +515,12 @@ export function sessionMetrics( const avgSessionsPerDay = numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0; + // console.log("Debug metrics calculation:", { + // totalSessionDuration, + // validSessionsForDuration, + // calculatedAvgSessionLength: avgSessionLength, + // }); + return { totalSessions, uniqueUsers, diff --git a/lib/types.ts b/lib/types.ts index de4ca14..383b8ec 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -138,6 +138,10 @@ export interface MetricsResult { usersTrend?: number; // e.g., percentage change in uniqueUsers avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime + + // Debug properties + totalSessionDuration?: number; + validSessionsForDuration?: number; } export interface ApiResponse { diff --git a/pages/api/forgot-password.ts b/pages/api/forgot-password.ts index 3f36fca..fab8aca 100644 --- a/pages/api/forgot-password.ts +++ b/pages/api/forgot-password.ts @@ -1,27 +1,20 @@ import { prisma } from "../../lib/prisma"; import { sendEmail } from "../../lib/sendEmail"; import crypto from "crypto"; -import type { IncomingMessage, ServerResponse } from "http"; - -type NextApiRequest = IncomingMessage & { - body: { - email: string; - [key: string]: unknown; - }; -}; - -type NextApiResponse = ServerResponse & { - status: (code: number) => NextApiResponse; - json: (data: Record) => void; - end: () => void; -}; +import type { NextApiRequest, NextApiResponse } from "next"; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { - if (req.method !== "POST") return res.status(405).end(); - const { email } = req.body; + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + // Type the body with a type assertion + const { email } = req.body as { email: string }; + const user = await prisma.user.findUnique({ where: { email } }); if (!user) return res.status(200).end(); // always 200 for privacy diff --git a/pages/api/reset-password.ts b/pages/api/reset-password.ts index cf77dd6..86bd4dd 100644 --- a/pages/api/reset-password.ts +++ b/pages/api/reset-password.ts @@ -34,12 +34,9 @@ export default async function handler( }); if (!user) { - return res - .status(400) - .json({ - error: - "Invalid or expired token. Please request a new password reset.", - }); + return res.status(400).json({ + error: "Invalid or expired token. Please request a new password reset.", + }); } const hash = await bcrypt.hash(password, 10); @@ -59,10 +56,8 @@ export default async function handler( } catch (error) { console.error("Reset password error:", error); // Log the error for server-side debugging // Provide a generic error message to the client - return res - .status(500) - .json({ - error: "An internal server error occurred. Please try again later.", - }); + return res.status(500).json({ + error: "An internal server error occurred. Please try again later.", + }); } }