From e3134aa451edbe69f1049d627d37526b06ea728a Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 14:12:36 +0200 Subject: [PATCH 01/15] Add comprehensive dashboard features Introduce company settings, user management, and layout components Implement session-based Company and User pages for admin access Integrate chart components for dynamic data visualization Add Sidebar for modular navigation Revamp global styles with Tailwind CSS Enhances user experience and administrative control --- .github/CODEOWNERS | 1 + app/dashboard/company/page.tsx | 173 ++++++++ app/dashboard/layout.tsx | 46 +++ app/dashboard/overview/page.tsx | 437 ++++++++++++++++++++ app/dashboard/page.tsx | 506 ++++-------------------- app/dashboard/users/page.tsx | 211 ++++++++++ app/globals.css | 10 - app/layout.tsx | 4 +- components/GeographicMap.tsx | 8 +- components/ResponseTimeDistribution.tsx | 48 ++- components/Sidebar.tsx | 306 ++++++++++++++ pages/api/dashboard/config.ts | 6 + 12 files changed, 1302 insertions(+), 454 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 app/dashboard/company/page.tsx create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/overview/page.tsx create mode 100644 app/dashboard/users/page.tsx create mode 100644 components/Sidebar.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..21e5c50 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @kjanat diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx new file mode 100644 index 0000000..bb2f7e6 --- /dev/null +++ b/app/dashboard/company/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { Company } from "../../lib/types"; + +export default function CompanySettingsPage() { + const { data: session, status } = useSession(); + const [company, setCompany] = useState(null); + const [csvUrl, setCsvUrl] = useState(""); + const [csvUsername, setCsvUsername] = useState(""); + const [csvPassword, setCsvPassword] = useState(""); + const [sentimentThreshold, setSentimentThreshold] = useState(""); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === "authenticated") { + const fetchCompany = async () => { + setLoading(true); + try { + const res = await fetch("/api/dashboard/config"); + const data = await res.json(); + setCompany(data.company); + setCsvUrl(data.company.csvUrl || ""); + setCsvUsername(data.company.csvUsername || ""); + setSentimentThreshold(data.company.sentimentAlert?.toString() || ""); + } catch (error) { + console.error("Failed to fetch company settings:", error); + setMessage("Failed to load company settings."); + } finally { + setLoading(false); + } + }; + fetchCompany(); + } + }, [status]); + + async function handleSave() { + setMessage(""); + try { + const res = await fetch("/api/dashboard/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + csvUrl, + csvUsername, + csvPassword, + sentimentThreshold, + }), + }); + + if (res.ok) { + setMessage("Settings saved successfully!"); + // Update local state if needed + const data = await res.json(); + setCompany(data.company); + } else { + const error = await res.json(); + setMessage( + `Failed to save settings: ${error.message || "Unknown error"}` + ); + } + } catch (error) { + setMessage("Failed to save settings. Please try again."); + console.error("Error saving settings:", error); + } + } + + // Loading state + if (loading) { + return
Loading settings...
; + } + + // Check for admin access + if (session?.user?.role !== "admin") { + return ( +
+

Access Denied

+

You don't have permission to view company settings.

+
+ ); + } + + return ( +
+
+

+ Company Settings +

+ + {message && ( +
+ {message} +
+ )} + +
{ + e.preventDefault(); + handleSave(); + }} + > +
+ + setCsvUrl(e.target.value)} + placeholder="https://example.com/data.csv" + /> +
+ +
+ + setCsvUsername(e.target.value)} + placeholder="Username for CSV access (if needed)" + /> +
+ +
+ + setCsvPassword(e.target.value)} + placeholder="Password will be updated only if provided" + /> +

+ Leave blank to keep current password +

+
+ +
+ + setSentimentThreshold(e.target.value)} + placeholder="Threshold value (0-100)" + min="0" + max="100" + /> +

+ Percentage of negative sentiment sessions to trigger alert (0-100) +

+
+ + +
+
+
+ ); +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..8afbc42 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ReactNode } from "react"; +import { useSession, signOut } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Sidebar from "../../components/Sidebar"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const { data: session, status } = useSession(); + const router = useRouter(); + + // Redirect if not authenticated + if (status === "unauthenticated") { + router.push("/login"); + return ( +
+
Redirecting to login...
+
+ ); + } + + // Show loading state while session status is being determined + if (status === "loading") { + return ( +
+
Loading session...
+
+ ); + } + + const handleLogout = () => { + signOut({ callbackUrl: "/login" }); + }; + + return ( +
+ {/* Sidebar with logout handler passed as prop */} + + + {/* Main content */} +
+
{children}
+
+
+ ); +} diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx new file mode 100644 index 0000000..83fe7bd --- /dev/null +++ b/app/dashboard/overview/page.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { + SessionsLineChart, + CategoriesBarChart, + LanguagePieChart, + TokenUsageChart, +} from "../../../components/Charts"; +import { Company, MetricsResult, WordCloudWord } from "../../../lib/types"; +import MetricCard from "../../../components/MetricCard"; +import DonutChart from "../../../components/DonutChart"; +import WordCloud from "../../../components/WordCloud"; +import GeographicMap from "../../../components/GeographicMap"; +import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution"; +import WelcomeBanner from "../../../components/WelcomeBanner"; + +// Safely wrapped component with useSession +function DashboardContent() { + const { data: session, status } = useSession(); // Add status from useSession + const router = useRouter(); // Initialize useRouter + const [metrics, setMetrics] = useState(null); + const [company, setCompany] = useState(null); + const [, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const isAuditor = session?.user?.role === "auditor"; + + useEffect(() => { + // Redirect if not authenticated + if (status === "unauthenticated") { + router.push("/login"); + return; // Stop further execution in this effect + } + + // Fetch metrics and company on mount if authenticated + if (status === "authenticated") { + const fetchData = async () => { + setLoading(true); + const res = await fetch("/api/dashboard/metrics"); + const data = await res.json(); + setMetrics(data.metrics); + setCompany(data.company); + setLoading(false); + }; + fetchData(); + } + }, [status, router]); // Add status and router to dependency array + + async function handleRefresh() { + if (isAuditor) return; // Prevent auditors from refreshing + try { + setRefreshing(true); + + // Make sure we have a company ID to send + if (!company?.id) { + setRefreshing(false); + alert("Cannot refresh: Company ID is missing"); + return; + } + + const res = await fetch("/api/admin/refresh-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ companyId: company.id }), + }); + + if (res.ok) { + // Refetch metrics + const metricsRes = await fetch("/api/dashboard/metrics"); + const data = await metricsRes.json(); + setMetrics(data.metrics); + } else { + const errorData = await res.json(); + alert(`Failed to refresh sessions: ${errorData.error}`); + } + } finally { + setRefreshing(false); + } + } + + // Calculate sentiment distribution + const getSentimentData = () => { + if (!metrics) return { positive: 0, neutral: 0, negative: 0 }; + + if ( + metrics.sentimentPositiveCount !== undefined && + metrics.sentimentNeutralCount !== undefined && + metrics.sentimentNegativeCount !== undefined + ) { + return { + positive: metrics.sentimentPositiveCount, + neutral: metrics.sentimentNeutralCount, + negative: metrics.sentimentNegativeCount, + }; + } + + const total = metrics.totalSessions || 1; + return { + positive: Math.round(total * 0.6), + neutral: Math.round(total * 0.3), + negative: Math.round(total * 0.1), + }; + }; + + // Prepare token usage data + const getTokenData = () => { + if (!metrics || !metrics.tokensByDay) { + return { labels: [], values: [], costs: [] }; + } + + const days = Object.keys(metrics.tokensByDay).sort(); + const labels = days.slice(-7); + const values = labels.map((day) => metrics.tokensByDay?.[day] || 0); + const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0); + + return { labels, values, costs }; + }; + + // Show loading state while session status is being determined + if (status === "loading") { + return
Loading session...
; + } + + // If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback) + if (status === "unauthenticated") { + return
Redirecting to login...
; + } + + if (!metrics || !company) { + return
Loading dashboard...
; + } + + // Function to prepare word cloud data from metrics.wordCloudData + const getWordCloudData = (): WordCloudWord[] => { + if (!metrics || !metrics.wordCloudData) return []; + return metrics.wordCloudData; + }; + + // Function to prepare country data for the map using actual metrics + const getCountryData = () => { + if (!metrics || !metrics.countries) return {}; + + // Convert the countries object from metrics to the format expected by GeographicMap + const result = Object.entries(metrics.countries).reduce( + (acc, [code, count]) => { + if (code && count) { + acc[code] = count; + } + return acc; + }, + {} as Record + ); + + return result; + }; + + // Function to prepare response time distribution data + const getResponseTimeData = () => { + const avgTime = metrics.avgResponseTime || 1.5; + const simulatedData: number[] = []; + + for (let i = 0; i < 50; i++) { + const randomFactor = 0.5 + Math.random(); + simulatedData.push(avgTime * randomFactor); + } + + return simulatedData; + }; + + return ( +
+ +
+
+

{company.name}

+

+ Dashboard updated{" "} + + {new Date(metrics.lastUpdated || Date.now()).toLocaleString()} + +

+
+
+ + +
+
+
+ + + + } + trend={{ + value: metrics.sessionTrend, + label: + metrics.sessionTrend > 0 + ? `${metrics.sessionTrend}% increase` + : `${Math.abs(metrics.sessionTrend || 0)}% decrease`, + positive: metrics.sessionTrend >= 0, + }} + /> + + + + } + trend={{ + value: metrics.usersTrend, + label: + metrics.usersTrend > 0 + ? `${metrics.usersTrend}% increase` + : `${Math.abs(metrics.usersTrend || 0)}% decrease`, + positive: metrics.usersTrend >= 0, + }} + /> + + + + } + trend={{ + value: metrics.sessionTimeTrend, + label: + metrics.sessionTimeTrend > 0 + ? `${metrics.sessionTimeTrend}% increase` + : `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`, + positive: metrics.sessionTimeTrend >= 0, + }} + /> + + + + } + trend={{ + value: metrics.responseTimeTrend, + label: + metrics.responseTimeTrend > 0 + ? `${metrics.responseTimeTrend}% increase` + : `${Math.abs(metrics.responseTimeTrend || 0)}% decrease`, + positive: metrics.responseTimeTrend <= 0, // Lower response time is better + }} + /> +
+ +
+
+

+ Sessions Over Time +

+ +
+
+

+ Conversation Sentiment +

+ +
+
+ +
+
+

+ Sessions by Category +

+ +
+
+

+ Languages Used +

+ +
+
+ +
+
+

+ Geographic Distribution +

+ +
+ +
+

+ Common Topics +

+ +
+
+ +
+

+ Response Time Distribution +

+ +
+
+
+

+ Token Usage & Costs +

+
+
+ Total Tokens: + {metrics.totalTokens?.toLocaleString() || 0} +
+
+ Total Cost:€ + {metrics.totalTokensEur?.toFixed(4) || 0} +
+
+
+ +
+
+ ); +} + +// Our exported component +export default function DashboardPage() { + return ; +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 1142657..708f9b4 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,440 +1,104 @@ "use client"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { signOut, useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; // Import useRouter -import { - SessionsLineChart, - CategoriesBarChart, - LanguagePieChart, - TokenUsageChart, -} from "../../components/Charts"; -import DashboardSettings from "./settings"; -import UserManagement from "./users"; -import { Company, MetricsResult, WordCloudWord } from "../../lib/types"; // Added WordCloudWord -import MetricCard from "../../components/MetricCard"; -import DonutChart from "../../components/DonutChart"; -import WordCloud from "../../components/WordCloud"; -import GeographicMap from "../../components/GeographicMap"; -import ResponseTimeDistribution from "../../components/ResponseTimeDistribution"; -import WelcomeBanner from "../../components/WelcomeBanner"; +import { FC } from "react"; -// Safely wrapped component with useSession -function DashboardContent() { - const { data: session, status } = useSession(); // Add status from useSession - const router = useRouter(); // Initialize useRouter - const [metrics, setMetrics] = useState(null); - const [company, setCompany] = useState(null); - const [, setLoading] = useState(false); - const [refreshing, setRefreshing] = useState(false); - - const isAdmin = session?.user?.role === "admin"; - const isAuditor = session?.user?.role === "auditor"; +const DashboardPage: FC = () => { + const { data: session, status } = useSession(); + const router = useRouter(); + const [loading, setLoading] = useState(true); useEffect(() => { - // Redirect if not authenticated + // Once session is loaded, redirect appropriately if (status === "unauthenticated") { router.push("/login"); - return; // Stop further execution in this effect + } else if (status === "authenticated") { + setLoading(false); } + }, [status, router]); - // Fetch metrics and company on mount if authenticated - if (status === "authenticated") { - const fetchData = async () => { - setLoading(true); - const res = await fetch("/api/dashboard/metrics"); - const data = await res.json(); - setMetrics(data.metrics); - setCompany(data.company); - setLoading(false); - }; - fetchData(); - } - }, [status, router]); // Add status and router to dependency array - - async function handleRefresh() { - if (isAuditor) return; // Prevent auditors from refreshing - try { - setRefreshing(true); - - // Make sure we have a company ID to send - if (!company?.id) { - setRefreshing(false); - alert("Cannot refresh: Company ID is missing"); - return; - } - - const res = await fetch("/api/admin/refresh-sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ companyId: company.id }), - }); - - if (res.ok) { - // Refetch metrics - const metricsRes = await fetch("/api/dashboard/metrics"); - const data = await metricsRes.json(); - setMetrics(data.metrics); - } else { - const errorData = await res.json(); - alert(`Failed to refresh sessions: ${errorData.error}`); - } - } finally { - setRefreshing(false); - } - } - - // Calculate sentiment distribution - const getSentimentData = () => { - if (!metrics) return { positive: 0, neutral: 0, negative: 0 }; - - if ( - metrics.sentimentPositiveCount !== undefined && - metrics.sentimentNeutralCount !== undefined && - metrics.sentimentNegativeCount !== undefined - ) { - return { - positive: metrics.sentimentPositiveCount, - neutral: metrics.sentimentNeutralCount, - negative: metrics.sentimentNegativeCount, - }; - } - - const total = metrics.totalSessions || 1; - return { - positive: Math.round(total * 0.6), - neutral: Math.round(total * 0.3), - negative: Math.round(total * 0.1), - }; - }; - - // Prepare token usage data - const getTokenData = () => { - if (!metrics || !metrics.tokensByDay) { - return { labels: [], values: [], costs: [] }; - } - - const days = Object.keys(metrics.tokensByDay).sort(); - const labels = days.slice(-7); - const values = labels.map((day) => metrics.tokensByDay?.[day] || 0); - const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0); - - return { labels, values, costs }; - }; - - // Show loading state while session status is being determined - if (status === "loading") { - return
Loading session...
; - } - - // If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback) - if (status === "unauthenticated") { - return
Redirecting to login...
; - } - - if (!metrics || !company) { - return
Loading dashboard...
; - } - - // Function to prepare word cloud data from metrics.wordCloudData - const getWordCloudData = (): WordCloudWord[] => { - if (!metrics || !metrics.wordCloudData) return []; - return metrics.wordCloudData; - }; - - // Function to prepare country data for the map using actual metrics - const getCountryData = () => { - if (!metrics || !metrics.countries) return {}; - - // Convert the countries object from metrics to the format expected by GeographicMap - const result = Object.entries(metrics.countries).reduce( - (acc, [code, count]) => { - if (code && count) { - acc[code] = count; - } - return acc; - }, - {} as Record + if (loading) { + return ( +
+
+
+

Loading dashboard...

+
+
); - - return result; - }; - - // Function to prepare response time distribution data - const getResponseTimeData = () => { - const avgTime = metrics.avgResponseTime || 1.5; - const simulatedData: number[] = []; - - for (let i = 0; i < 50; i++) { - const randomFactor = 0.5 + Math.random(); - simulatedData.push(avgTime * randomFactor); - } - - return simulatedData; - }; + } return ( -
- -
-
-

{company.name}

-

- Dashboard updated{" "} - - {new Date(metrics.lastUpdated || Date.now()).toLocaleString()} - -

-
-
- - -
-
-
- - - - -
-
-
-

- Sentiment Distribution -

- -
- -
-

- Case Handling Statistics -

-
- metrics.totalSessions * 0.1 - ? "warning" - : "success" - } - /> - metrics.totalSessions * 0.05 - ? "warning" - : "default" - } - /> - + View Analytics +
-
-
-
-
-

- Sessions by Day -

- -
-
-

- Top Categories -

- -
-
-
-
-

- Transcript Word Cloud -

- -
-
-

- Geographic Distribution -

- -
-
-
-
-

- Response Time Distribution -

- -
-
-

Languages

- -
-
-
-
-

- Token Usage & Costs -

-
-
- Total Tokens: - {metrics.totalTokens?.toLocaleString() || 0} -
-
- Total Cost:€ - {metrics.totalTokensEur?.toFixed(4) || 0} -
-
-
- -
- {isAdmin && ( - <> - - - - )} -
- ); -} -// Our exported component -export default function DashboardPage() { - return ( -
-
- +
+

Sessions

+

+ Browse and analyze conversation sessions +

+ +
+ + {session?.user?.role === "admin" && ( +
+

+ Company Settings +

+

+ Configure company settings and integrations +

+ +
+ )} + + {session?.user?.role === "admin" && ( +
+

+ User Management +

+

+ Invite and manage user accounts +

+ +
+ )} +
); -} +}; + +export default DashboardPage; diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx new file mode 100644 index 0000000..87a2a0e --- /dev/null +++ b/app/dashboard/users/page.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { UserSession } from "../../../lib/types"; + +interface UserItem { + id: string; + email: string; + role: string; +} + +export default function UserManagementPage() { + const { data: session, status } = useSession(); + const [users, setUsers] = useState([]); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("user"); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === "authenticated") { + fetchUsers(); + } + }, [status]); + + const fetchUsers = async () => { + setLoading(true); + try { + const res = await fetch("/api/dashboard/users"); + const data = await res.json(); + setUsers(data.users); + } catch (error) { + console.error("Failed to fetch users:", error); + setMessage("Failed to load users."); + } finally { + setLoading(false); + } + }; + + async function inviteUser() { + setMessage(""); + try { + const res = await fetch("/api/dashboard/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, role }), + }); + + if (res.ok) { + setMessage("User invited successfully!"); + setEmail(""); // Clear the form + // Refresh the user list + fetchUsers(); + } else { + const error = await res.json(); + setMessage( + `Failed to invite user: ${error.message || "Unknown error"}` + ); + } + } catch (error) { + setMessage("Failed to invite user. Please try again."); + console.error("Error inviting user:", error); + } + } + + // Loading state + if (loading) { + return
Loading users...
; + } + + // Check for admin access + if (session?.user?.role !== "admin") { + return ( +
+

Access Denied

+

You don't have permission to view user management.

+
+ ); + } + + return ( +
+
+

+ User Management +

+ + {message && ( +
+ {message} +
+ )} + +
+

Invite New User

+
{ + e.preventDefault(); + inviteUser(); + }} + > +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + +
+ + +
+
+ +
+

Current Users

+
+ + + + + + + + + + {users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + + + + )) + )} + +
+ Email + + Role + + Actions +
+ No users found +
+ {user.email} + + + {user.role} + + + {/* For future: Add actions like edit, delete, etc. */} + + No actions available + +
+
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index c4871c8..f1d8c73 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,11 +1 @@ -body { - font-family: system-ui, sans-serif; - background: #f3f4f6; -} - -input, -button { - font-family: inherit; -} - @import "tailwindcss"; diff --git a/app/layout.tsx b/app/layout.tsx index 87d6df7..5f1b733 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -21,9 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - -
{children}
-
+ {children} ); diff --git a/components/GeographicMap.tsx b/components/GeographicMap.tsx index 3cbef2d..ca767ea 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -79,11 +79,11 @@ export default function GeographicMap({ // Process country data when client is ready and dependencies change useEffect(() => { - if (!isClient) return; + if (!isClient || !countries) return; try { // Generate CountryData array for the Map component - const data: CountryData[] = Object.entries(countries) + const data: CountryData[] = Object.entries(countries || {}) // Only include countries with known coordinates .filter(([code]) => { // If no coordinates found, log to help with debugging @@ -115,8 +115,8 @@ export default function GeographicMap({ } }, [countries, countryCoordinates, isClient]); - // Find the max count for scaling circles - handle empty countries object - const countryValues = Object.values(countries); + // Find the max count for scaling circles - handle empty or null countries object + const countryValues = countries ? Object.values(countries) : []; const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1; // Show loading state during SSR or until client-side rendering takes over diff --git a/components/ResponseTimeDistribution.tsx b/components/ResponseTimeDistribution.tsx index a6c6842..2a42daa 100644 --- a/components/ResponseTimeDistribution.tsx +++ b/components/ResponseTimeDistribution.tsx @@ -7,28 +7,30 @@ import annotationPlugin from "chartjs-plugin-annotation"; Chart.register(annotationPlugin); interface ResponseTimeDistributionProps { - responseTimes: number[]; + data: number[]; + average: number; targetResponseTime?: number; } export default function ResponseTimeDistribution({ - responseTimes, + data, + average, targetResponseTime, }: ResponseTimeDistributionProps) { const ref = useRef(null); useEffect(() => { - if (!ref.current || !responseTimes.length) return; + if (!ref.current || !data || !data.length) return; const ctx = ref.current.getContext("2d"); if (!ctx) return; // Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.) - const maxTime = Math.ceil(Math.max(...responseTimes)); + const maxTime = Math.ceil(Math.max(...data)); const bins = Array(Math.min(maxTime + 1, 10)).fill(0); // Count responses in each bin - responseTimes.forEach((time) => { + data.forEach((time) => { const binIndex = Math.min(Math.floor(time), bins.length - 1); bins[binIndex]++; }); @@ -63,26 +65,40 @@ export default function ResponseTimeDistribution({ responsive: true, plugins: { legend: { display: false }, - annotation: targetResponseTime - ? { - annotations: { - targetLine: { + annotation: { + annotations: { + averageLine: { + type: "line", + yMin: 0, + yMax: Math.max(...bins), + xMin: average, + xMax: average, + borderColor: "rgba(75, 192, 192, 1)", + borderWidth: 2, + label: { + display: true, + content: "Avg: " + average.toFixed(1) + "s", + position: "start", + }, + }, + targetLine: targetResponseTime + ? { type: "line", yMin: 0, yMax: Math.max(...bins), xMin: targetResponseTime, xMax: targetResponseTime, - borderColor: "rgba(75, 192, 192, 1)", + borderColor: "rgba(75, 192, 192, 0.7)", borderWidth: 2, label: { display: true, content: "Target", - position: "start", + position: "end", }, - }, - }, - } - : undefined, + } + : undefined, + }, + }, }, scales: { y: { @@ -103,7 +119,7 @@ export default function ResponseTimeDistribution({ }); return () => chart.destroy(); - }, [responseTimes, targetResponseTime]); + }, [data, average, targetResponseTime]); return ; } diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx new file mode 100644 index 0000000..b50f828 --- /dev/null +++ b/components/Sidebar.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; + +// Icons for the sidebar +const DashboardIcon = () => ( + + + + + + +); + +const CompanyIcon = () => ( + + + +); + +const UsersIcon = () => ( + + + +); + +const SessionsIcon = () => ( + + + +); + +const LogoutIcon = () => ( + + + +); + +const ToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => ( + + + +); + +interface NavItemProps { + href: string; + label: string; + icon: React.ReactNode; + isExpanded: boolean; + isActive: boolean; +} + +const NavItem: React.FC = ({ + href, + label, + icon, + isExpanded, + isActive, +}) => ( + + + {icon} + + {isExpanded ? ( + {label} + ) : ( +
+ {label} +
+ )} + +); + +export default function Sidebar() { + const [isExpanded, setIsExpanded] = useState(true); + const pathname = usePathname() || ""; + + const toggleSidebar = () => { + setIsExpanded(!isExpanded); + }; + + const handleLogout = () => { + signOut({ callbackUrl: "/login" }); + }; + + return ( +
+ {/* Logo section - now above toggle button */} +
+
+ LiveDash Logo +
+ {isExpanded && ( + LiveDash + )} +
+ + {/* Toggle button */} +
+ +
+ + {/* Navigation items */} + + + {/* Logout at the bottom */} +
+ +
+
+ ); +} diff --git a/pages/api/dashboard/config.ts b/pages/api/dashboard/config.ts index 48da9be..55a1345 100644 --- a/pages/api/dashboard/config.ts +++ b/pages/api/dashboard/config.ts @@ -24,6 +24,12 @@ export default async function handler( data: { csvUrl }, }); res.json({ ok: true }); + } else if (req.method === "GET") { + // Get company data + const company = await prisma.company.findUnique({ + where: { id: user.companyId }, + }); + res.json({ company }); } else { res.status(405).end(); } From efb5261c7df4398696eb8801e98f833e98eb038e Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 14:44:28 +0200 Subject: [PATCH 02/15] Refactor components and enhance metrics calculations: - Update access denied messages to use HTML entities. - Add autoComplete attributes to forms for better user experience. - Improve trend calculations in sessionMetrics function. - Update MetricCard props to accept React nodes for icons. - Integrate Next.js Image component in Sidebar for optimization. - Adjust ESLint rules for better code quality. - Add new properties for trends in MetricsResult interface. - Bump version to 0.2.0 in package.json. --- app/dashboard/company/page.tsx | 7 +- app/dashboard/overview/page.tsx | 30 +++--- app/dashboard/users/page.tsx | 4 +- components/GeographicMap.tsx | 5 - components/MetricCard.tsx | 2 +- components/Sidebar.tsx | 14 ++- eslint.config.js | 10 +- lib/metrics.ts | 176 ++++++++++++++++++++++---------- lib/types.ts | 7 ++ package.json | 5 +- 10 files changed, 172 insertions(+), 88 deletions(-) diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx index bb2f7e6..c04fea1 100644 --- a/app/dashboard/company/page.tsx +++ b/app/dashboard/company/page.tsx @@ -77,7 +77,7 @@ export default function CompanySettingsPage() { return (

Access Denied

-

You don't have permission to view company settings.

+

You don't have permission to view company settings.

); } @@ -103,6 +103,7 @@ export default function CompanySettingsPage() { e.preventDefault(); handleSave(); }} + autoComplete="off" >
@@ -125,6 +127,7 @@ export default function CompanySettingsPage() { value={csvUsername} onChange={(e) => setCsvUsername(e.target.value)} placeholder="Username for CSV access (if needed)" + autoComplete="off" /> @@ -136,6 +139,7 @@ export default function CompanySettingsPage() { value={csvPassword} onChange={(e) => setCsvPassword(e.target.value)} placeholder="Password will be updated only if provided" + autoComplete="new-password" />

Leave blank to keep current password @@ -154,6 +158,7 @@ export default function CompanySettingsPage() { placeholder="Threshold value (0-100)" min="0" max="100" + autoComplete="off" />

Percentage of negative sentiment sessions to trigger alert (0-100) diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index 83fe7bd..3906f83 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -246,12 +246,12 @@ function DashboardContent() { } trend={{ - value: metrics.sessionTrend, + value: metrics.sessionTrend ?? 0, label: - metrics.sessionTrend > 0 - ? `${metrics.sessionTrend}% increase` - : `${Math.abs(metrics.sessionTrend || 0)}% decrease`, - positive: metrics.sessionTrend >= 0, + (metrics.sessionTrend ?? 0) > 0 + ? `${metrics.sessionTrend ?? 0}% increase` + : `${Math.abs(metrics.sessionTrend ?? 0)}% decrease`, + isPositive: (metrics.sessionTrend ?? 0) >= 0, }} /> } trend={{ - value: metrics.usersTrend, + value: metrics.usersTrend ?? 0, label: metrics.usersTrend > 0 ? `${metrics.usersTrend}% increase` : `${Math.abs(metrics.usersTrend || 0)}% decrease`, - positive: metrics.usersTrend >= 0, + isPositive: metrics.usersTrend >= 0, }} /> } trend={{ - value: metrics.sessionTimeTrend, + value: metrics.sessionTimeTrend ?? 0, label: metrics.sessionTimeTrend > 0 ? `${metrics.sessionTimeTrend}% increase` : `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`, - positive: metrics.sessionTimeTrend >= 0, + isPositive: metrics.sessionTimeTrend >= 0, }} /> } trend={{ - value: metrics.responseTimeTrend, + value: metrics.avgResponseTimeTrend ?? 0, label: - metrics.responseTimeTrend > 0 - ? `${metrics.responseTimeTrend}% increase` - : `${Math.abs(metrics.responseTimeTrend || 0)}% decrease`, - positive: metrics.responseTimeTrend <= 0, // Lower response time is better + (metrics.avgResponseTimeTrend ?? 0) > 0 + ? `${metrics.avgResponseTimeTrend ?? 0}% increase` + : `${Math.abs(metrics.avgResponseTimeTrend ?? 0)}% decrease`, + isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better }} /> @@ -345,7 +345,7 @@ function DashboardContent() {

Sessions Over Time

- +

diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index 87a2a0e..c1addcc 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -74,7 +74,7 @@ export default function UserManagementPage() { return (

Access Denied

-

You don't have permission to view user management.

+

You don't have permission to view user management.

); } @@ -102,6 +102,7 @@ export default function UserManagementPage() { e.preventDefault(); inviteUser(); }} + autoComplete="off" // Disable autofill for the form >
@@ -112,6 +113,7 @@ export default function UserManagementPage() { value={email} onChange={(e) => setEmail(e.target.value)} required + autoComplete="off" // Disable autofill for this input />
diff --git a/components/GeographicMap.tsx b/components/GeographicMap.tsx index ca767ea..a2828b0 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -44,7 +44,6 @@ const getCountryCoordinates = (): Record => { return coordinates; } catch (error) { - // eslint-disable-next-line no-console console.error("Error loading country coordinates:", error); return coordinates; } @@ -88,8 +87,6 @@ export default function GeographicMap({ .filter(([code]) => { // If no coordinates found, log to help with debugging if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) { - // eslint-disable-next-line no-console - console.warn(`Missing coordinates for country code: ${code}`); return false; } return true; @@ -102,14 +99,12 @@ export default function GeographicMap({ })); // Log for debugging - // eslint-disable-next-line no-console console.log( `Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries` ); setCountryData(data); } catch (error) { - // eslint-disable-next-line no-console console.error("Error processing geographic data:", error); setCountryData([]); } diff --git a/components/MetricCard.tsx b/components/MetricCard.tsx index 9388599..a3fe0f2 100644 --- a/components/MetricCard.tsx +++ b/components/MetricCard.tsx @@ -4,7 +4,7 @@ interface MetricCardProps { title: string; value: string | number | null | undefined; description?: string; - icon?: string; + icon?: React.ReactNode; trend?: { value: number; label?: string; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index b50f828..45b83c3 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import Image from "next/image"; // Import the Next.js Image component import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; @@ -190,10 +191,13 @@ export default function Sidebar() {
- LiveDash Logo
{isExpanded && ( @@ -213,7 +217,7 @@ export default function Sidebar() {
{isExpanded ? "Collapse sidebar" : "Expand sidebar"} @@ -292,8 +296,8 @@ export default function Sidebar() { ) : (
Logout diff --git a/eslint.config.js b/eslint.config.js index b01b0f6..c033397 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,13 +26,13 @@ const eslintConfig = [ "coverage/", ], rules: { - "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": "warn", - "react/no-unescaped-entities": "off", - "no-console": "warn", - "no-trailing-spaces": "error", + "react/no-unescaped-entities": "warn", + "no-console": "off", + "no-trailing-spaces": "warn", "prefer-const": "error", - "no-unused-vars": "off", + "no-unused-vars": "warn", }, }, ]; diff --git a/lib/metrics.ts b/lib/metrics.ts index 56eeac9..d8b07b6 100644 --- a/lib/metrics.ts +++ b/lib/metrics.ts @@ -314,7 +314,7 @@ export function sessionMetrics( const byDay: DayMetrics = {}; const byCategory: CategoryMetrics = {}; const byLanguage: LanguageMetrics = {}; - const byCountry: CountryMetrics = {}; // Added for country data + const byCountry: CountryMetrics = {}; const tokensByDay: DayMetrics = {}; const tokensCostByDay: DayMetrics = {}; @@ -322,49 +322,68 @@ export function sessionMetrics( forwarded = 0; let totalSentiment = 0, sentimentCount = 0; - let totalResponse = 0, - responseCount = 0; + let totalResponseTimeCurrent = 0, // Renamed to avoid conflict + responseCountCurrent = 0; // Renamed to avoid conflict let totalTokens = 0, totalTokensEur = 0; - // For sentiment distribution let sentimentPositive = 0, sentimentNegative = 0, sentimentNeutral = 0; - // Calculate total session duration in minutes - let totalDuration = 0; - let durationCount = 0; + let totalDurationCurrent = 0, // Renamed to avoid conflict + durationCountCurrent = 0; // Renamed to avoid conflict - const wordCounts: { [key: string]: number } = {}; // For WordCloud + const wordCounts: { [key: string]: number } = {}; + const uniqueUserIdsCurrent = new Set(); + + let minDateCurrentPeriod = new Date(); + if (sessions.length > 0) { + minDateCurrentPeriod = new Date( + Math.min(...sessions.map((s) => s.startTime.getTime())) + ); + } + + const prevPeriodEndDate = new Date(minDateCurrentPeriod); + prevPeriodEndDate.setDate(prevPeriodEndDate.getDate() - 1); + const prevPeriodStartDate = new Date(prevPeriodEndDate); + prevPeriodStartDate.setDate(prevPeriodStartDate.getDate() - 6); // 7-day previous period + + let prevPeriodSessionsCount = 0; + const prevPeriodUniqueUserIds = new Set(); + let prevPeriodTotalDuration = 0; + let prevPeriodDurationCount = 0; + let prevPeriodTotalResponseTime = 0; + let prevPeriodResponseCount = 0; sessions.forEach((s) => { - const day = s.startTime.toISOString().slice(0, 10); + const sessionDate = s.startTime; + const day = sessionDate.toISOString().slice(0, 10); + + // Aggregate current period data byDay[day] = (byDay[day] || 0) + 1; + if (s.userId) { + uniqueUserIdsCurrent.add(s.userId); + } if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1; if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1; - if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry + if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; - // Process token usage by day if (s.tokens) { tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens; } - - // Process token cost by day if (s.tokensEur) { tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur; } if (s.endTime) { const duration = - (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes - - // Sanity check: Only include sessions with reasonable durations (less than 24 hours) - const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes + (s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); // minutes + const MAX_REASONABLE_DURATION = 24 * 60; if (duration > 0 && duration < MAX_REASONABLE_DURATION) { - totalDuration += duration; - durationCount++; + totalDurationCurrent += duration; + durationCountCurrent++; } } @@ -374,45 +393,98 @@ export function sessionMetrics( if (s.sentiment != null) { totalSentiment += s.sentiment; sentimentCount++; - - // Classify sentiment - if (s.sentiment > 0.3) { - sentimentPositive++; - } else if (s.sentiment < -0.3) { - sentimentNegative++; - } else { - sentimentNeutral++; - } + if (s.sentiment > 0.3) sentimentPositive++; + else if (s.sentiment < -0.3) sentimentNegative++; + else sentimentNeutral++; } if (s.avgResponseTime != null) { - totalResponse += s.avgResponseTime; - responseCount++; + totalResponseTimeCurrent += s.avgResponseTime; + responseCountCurrent++; } totalTokens += s.tokens || 0; totalTokensEur += s.tokensEur || 0; - // Process transcript for WordCloud if (s.transcriptContent) { - const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); // Split into words, lowercase + const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); if (words) { words.forEach((word) => { - const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); // Remove punctuation + const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); if ( cleanedWord && !stopWords.has(cleanedWord) && cleanedWord.length > 2 ) { - // Check if not a stop word and length > 2 wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1; } }); } } + + // Aggregate previous period data (if session falls within the previous period range) + if ( + sessionDate >= prevPeriodStartDate && + sessionDate <= prevPeriodEndDate + ) { + prevPeriodSessionsCount++; + if (s.userId) { + prevPeriodUniqueUserIds.add(s.userId); + } + if (s.endTime) { + const duration = + (s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); + const MAX_REASONABLE_DURATION = 24 * 60; + if (duration > 0 && duration < MAX_REASONABLE_DURATION) { + prevPeriodTotalDuration += duration; + prevPeriodDurationCount++; + } + } + if (s.avgResponseTime != null) { + prevPeriodTotalResponseTime += s.avgResponseTime; + prevPeriodResponseCount++; + } + } }); - // Now add sentiment alert logic: + const calculateTrend = (current: number, previous: number): number => { + if (previous === 0) { + return current > 0 ? 100 : 0; + } + const trend = ((current - previous) / previous) * 100; + return parseFloat(trend.toFixed(1)); + }; + + const sessionTrend = calculateTrend(total, prevPeriodSessionsCount); + const usersTrend = calculateTrend( + uniqueUserIdsCurrent.size, + prevPeriodUniqueUserIds.size + ); + + const avgSessionLengthCurrent = + durationCountCurrent > 0 ? totalDurationCurrent / durationCountCurrent : 0; + const avgSessionLengthPrevious = + prevPeriodDurationCount > 0 + ? prevPeriodTotalDuration / prevPeriodDurationCount + : 0; + const avgSessionTimeTrend = calculateTrend( + avgSessionLengthCurrent, + avgSessionLengthPrevious + ); + + const avgResponseTimeCurrentPeriod = + responseCountCurrent > 0 + ? totalResponseTimeCurrent / responseCountCurrent + : 0; + const avgResponseTimePreviousPeriod = + prevPeriodResponseCount > 0 + ? prevPeriodTotalResponseTime / prevPeriodResponseCount + : 0; + const avgResponseTimeTrend = calculateTrend( + avgResponseTimeCurrentPeriod, + avgResponseTimePreviousPeriod + ); + let belowThreshold = 0; const threshold = companyConfig.sentimentAlert ?? null; if (threshold != null) { @@ -421,45 +493,43 @@ export function sessionMetrics( } } - // Calculate average sessions per day const dayCount = Object.keys(byDay).length; const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0; - // Calculate average session length - const avgSessionLength = - durationCount > 0 ? totalDuration / durationCount : null; - - // Prepare wordCloudData const wordCloudData: WordCloudWord[] = Object.entries(wordCounts) .map(([text, value]) => ({ text, value })) .sort((a, b) => b.value - a.value) - .slice(0, 500); // Take top 500 words + .slice(0, 500); return { totalSessions: total, - avgSessionsPerDay, - avgSessionLength, + avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)), + avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)), days: byDay, languages: byLanguage, - categories: byCategory, // This will be empty if we are not using categories for word cloud - countries: byCountry, // Added countries to the result + categories: byCategory, + countries: byCountry, belowThresholdCount: belowThreshold, - // Additional metrics not in the interface - using type assertion escalatedCount: escalated, forwardedCount: forwarded, - avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined, - avgResponseTime: responseCount ? totalResponse / responseCount : undefined, + avgSentiment: sentimentCount + ? parseFloat((totalSentiment / sentimentCount).toFixed(2)) + : undefined, + avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)), totalTokens, totalTokensEur, sentimentThreshold: threshold, - lastUpdated: Date.now(), // Add current timestamp - - // New metrics for enhanced dashboard + lastUpdated: Date.now(), sentimentPositiveCount: sentimentPositive, sentimentNeutralCount: sentimentNeutral, sentimentNegativeCount: sentimentNegative, tokensByDay, tokensCostByDay, - wordCloudData, // Added word cloud data + wordCloudData, + uniqueUsers: uniqueUserIdsCurrent.size, + sessionTrend, + usersTrend, + avgSessionTimeTrend, + avgResponseTimeTrend, }; } diff --git a/lib/types.ts b/lib/types.ts index e55da4f..de4ca14 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -131,6 +131,13 @@ export interface MetricsResult { tokensByDay?: DayMetrics; tokensCostByDay?: DayMetrics; wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud + + // Properties for overview page cards and trends + uniqueUsers?: number; + sessionTrend?: number; // e.g., percentage change in totalSessions + usersTrend?: number; // e.g., percentage change in uniqueUsers + avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength + avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime } export interface ApiResponse { diff --git a/package.json b/package.json index 94ff784..c1cbc4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "livedash-node", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -12,7 +12,8 @@ "format": "prettier --write .", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:seed": "node prisma/seed.mjs" + "prisma:seed": "node prisma/seed.mjs", + "prisma:studio": "prisma studio" }, "dependencies": { "@prisma/client": "^6.8.2", From ed6e5b0c3612d36c59b8ce87987af46354e4c5b8 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 16:11:33 +0200 Subject: [PATCH 03/15] Enhance session handling and improve data parsing; add safe date parsing utility --- app/dashboard/company/page.tsx | 2 + app/dashboard/layout.tsx | 3 ++ app/dashboard/sessions/page.tsx | 8 +++- app/dashboard/users/page.tsx | 1 - components/DonutChart.tsx | 5 ++- components/Sidebar.tsx | 14 +++--- components/TranscriptViewer.tsx | 4 +- components/WordCloud.tsx | 2 +- lib/csvFetcher.ts | 63 +++++++++++++++++++++++---- lib/localization.ts | 2 +- pages/api/dashboard/sessions.ts | 76 +++++++++++++++++++++------------ 11 files changed, 130 insertions(+), 50 deletions(-) diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx index c04fea1..5db649d 100644 --- a/app/dashboard/company/page.tsx +++ b/app/dashboard/company/page.tsx @@ -6,6 +6,8 @@ import { Company } from "../../lib/types"; export default function CompanySettingsPage() { const { data: session, status } = useSession(); + // We store the full company object for future use and updates after save operations + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const [company, setCompany] = useState(null); const [csvUrl, setCsvUrl] = useState(""); const [csvUsername, setCsvUsername] = useState(""); diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 8afbc42..fe86322 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -6,6 +6,7 @@ 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 router = useRouter(); @@ -28,6 +29,8 @@ 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" }); }; diff --git a/app/dashboard/sessions/page.tsx b/app/dashboard/sessions/page.tsx index 90a3945..6621e31 100644 --- a/app/dashboard/sessions/page.tsx +++ b/app/dashboard/sessions/page.tsx @@ -40,7 +40,7 @@ export default function SessionsPage() { // Pagination states const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const [pageSize, setPageSize] = useState(10); // Or make this configurable useEffect(() => { @@ -283,7 +283,11 @@ export default function SessionsPage() { Session ID: {session.sessionId || session.id}

- Start Time: {new Date(session.startTime).toLocaleString()} + Start Time (Local):{" "} + {new Date(session.startTime).toLocaleString()} +

+

+ Start Time (Raw API): {session.startTime.toString()}

{session.category && (

diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index c1addcc..57e10ec 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; -import { UserSession } from "../../../lib/types"; interface UserItem { id: string; diff --git a/components/DonutChart.tsx b/components/DonutChart.tsx index 25d0501..87eef4a 100644 --- a/components/DonutChart.tsx +++ b/components/DonutChart.tsx @@ -77,7 +77,8 @@ export default function DonutChart({ data, centerText }: DonutChartProps) { const label = context.label || ""; const value = context.formattedValue; const total = context.chart.data.datasets[0].data.reduce( - (a: number, b: any) => a + (typeof b === "number" ? b : 0), + (a: number, b: number | string | null) => + a + (typeof b === "number" ? b : 0), 0 ); const percentage = Math.round((context.parsed * 100) / total); @@ -91,7 +92,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) { ? [ { id: "centerText", - beforeDraw: function (chart: any) { + beforeDraw: function (chart: Chart<"doughnut">) { const height = chart.height; const ctx = chart.ctx; ctx.restore(); diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 45b83c3..6f4232d 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import Image from "next/image"; // Import the Next.js Image component +import Image from "next/image"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; @@ -195,16 +195,20 @@ export default function Sidebar() { src="/favicon.svg" alt="LiveDash Logo" fill - style={{ objectFit: "contain" }} className="transition-all duration-300" - priority // Added priority prop for LCP optimization + // Added priority prop for LCP optimization + priority + style={{ + objectFit: "contain", + maxWidth: "100%", + // height: "auto" + }} />

{isExpanded && ( LiveDash )} - {/* Toggle button */}
- {/* Navigation items */} - {/* Logout at the bottom */}
+
+ )} + + {/* 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.", + }); } } From 303226e3a97bbb22e52eead4985137e05234838e Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 22:20:18 +0200 Subject: [PATCH 07/15] Refactor trend calculations and improve WordCloud component responsiveness; remove trend labels for cleaner display --- app/dashboard/overview/page.tsx | 20 +- components/MetricCard.tsx | 3 - components/WordCloud.tsx | 63 +++- lib/metrics.ts | 523 +++++++++++++++++--------------- 4 files changed, 340 insertions(+), 269 deletions(-) diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index a40c463..511a14e 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -253,10 +253,6 @@ function DashboardContent() { } trend={{ value: metrics.sessionTrend ?? 0, - label: - (metrics.sessionTrend ?? 0) > 0 - ? `${metrics.sessionTrend ?? 0}% increase` - : `${Math.abs(metrics.sessionTrend ?? 0)}% decrease`, isPositive: (metrics.sessionTrend ?? 0) >= 0, }} /> @@ -281,10 +277,6 @@ function DashboardContent() { } trend={{ value: metrics.usersTrend ?? 0, - label: - (metrics.usersTrend ?? 0) > 0 - ? `${metrics.usersTrend}% increase` - : `${Math.abs(metrics.usersTrend ?? 0)}% decrease`, isPositive: (metrics.usersTrend ?? 0) >= 0, }} /> @@ -309,10 +301,6 @@ function DashboardContent() { } trend={{ value: metrics.avgSessionTimeTrend ?? 0, - label: - (metrics.avgSessionTimeTrend ?? 0) > 0 - ? `${metrics.avgSessionTimeTrend}% increase` - : `${Math.abs(metrics.avgSessionTimeTrend ?? 0)}% decrease`, isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0, }} /> @@ -337,10 +325,6 @@ function DashboardContent() { } trend={{ value: metrics.avgResponseTimeTrend ?? 0, - label: - (metrics.avgResponseTimeTrend ?? 0) > 0 - ? `${metrics.avgResponseTimeTrend ?? 0}% increase` - : `${Math.abs(metrics.avgResponseTimeTrend ?? 0)}% decrease`, isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better }} /> @@ -402,7 +386,9 @@ function DashboardContent() {

Common Topics

- +
+ +
diff --git a/components/MetricCard.tsx b/components/MetricCard.tsx index a3fe0f2..b8ff5c2 100644 --- a/components/MetricCard.tsx +++ b/components/MetricCard.tsx @@ -67,9 +67,6 @@ export default function MetricCard({ > {trend.isPositive !== false ? "↑" : "↓"}{" "} {Math.abs(trend.value).toFixed(1)}% - {trend.label && ( - {trend.label} - )} )} diff --git a/components/WordCloud.tsx b/components/WordCloud.tsx index 473fed9..9f3f807 100644 --- a/components/WordCloud.tsx +++ b/components/WordCloud.tsx @@ -11,20 +11,55 @@ interface WordCloudProps { }[]; width?: number; height?: number; + minWidth?: number; + minHeight?: number; } export default function WordCloud({ words, - width = 500, - height = 300, + width: initialWidth = 500, + height: initialHeight = 300, + minWidth = 200, + minHeight = 200, }: WordCloudProps) { const svgRef = useRef(null); + const containerRef = useRef(null); const [isClient, setIsClient] = useState(false); + const [dimensions, setDimensions] = useState({ + width: initialWidth, + height: initialHeight, + }); + // Set isClient to true on initial render useEffect(() => { setIsClient(true); }, []); + // Add effect to detect container size changes + useEffect(() => { + if (!containerRef.current || !isClient) return; + + // Create ResizeObserver to detect size changes + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + // Ensure minimum dimensions + const newWidth = Math.max(width, minWidth); + const newHeight = Math.max(height, minHeight); + setDimensions({ width: newWidth, height: newHeight }); + } + }); + + // Start observing the container + resizeObserver.observe(containerRef.current); + + // Cleanup + return () => { + resizeObserver.disconnect(); + }; + }, [isClient, minWidth, minHeight]); + + // Effect to render the word cloud whenever dimensions or words change useEffect(() => { if (!svgRef.current || !isClient || !words.length) return; @@ -36,7 +71,7 @@ export default function WordCloud({ // Configure the layout const layout = cloud() - .size([width, height]) + .size([dimensions.width, dimensions.height]) .words( words.map((d) => ({ text: d.text, @@ -53,7 +88,10 @@ export default function WordCloud({ function draw(words: Word[]) { svg .append("g") - .attr("transform", `translate(${width / 2},${height / 2})`) + .attr( + "transform", + `translate(${dimensions.width / 2},${dimensions.height / 2})` + ) .selectAll("text") .data(words) .enter() @@ -87,7 +125,7 @@ export default function WordCloud({ return () => { svg.selectAll("*").remove(); }; - }, [words, width, height, isClient]); + }, [words, dimensions, isClient]); if (!isClient) { return ( @@ -98,12 +136,21 @@ export default function WordCloud({ } return ( -
+
); diff --git a/lib/metrics.ts b/lib/metrics.ts index e06fca9..4e2b69e 100644 --- a/lib/metrics.ts +++ b/lib/metrics.ts @@ -13,295 +13,309 @@ interface CompanyConfig { sentimentAlert?: number; } +// Helper function to calculate trend percentages +function calculateTrendPercentage(current: number, previous: number): number { + if (previous === 0) return 0; // Avoid division by zero + return ((current - previous) / previous) * 100; +} + +// Mock data for previous period - in a real app, this would come from database +const mockPreviousPeriodData = { + totalSessions: 120, + uniqueUsers: 85, + avgSessionLength: 240, // in seconds + avgResponseTime: 1.7, // in seconds +}; + // List of common stop words - this can be expanded const stopWords = new Set([ "assistant", "user", // Web + "bmp", + "co", "com", - "www", - "http", - "https", - "www2", + "css", + "gif", "href", "html", - "php", - "js", - "css", - "xml", - "json", - "txt", - "jpg", - "jpeg", - "png", - "gif", - "bmp", - "svg", - "org", - "net", - "co", + "http", + "https", "io", + "jpeg", + "jpg", + "js", + "json", + "net", + "org", + "php", + "png", + "svg", + "txt", + "www", + "www2", + "xml", // English stop words "a", + "about", + "above", + "after", + "again", + "against", + "ain", + "all", + "am", "an", - "the", - "is", + "any", "are", - "was", - "were", + "aren", + "at", "be", "been", + "before", "being", - "have", - "has", - "had", - "do", - "does", - "did", - "will", - "would", - "should", + "below", + "between", + "both", + "by", + "bye", "can", "could", - "may", - "might", - "must", - "am", - "i", - "you", - "he", - "she", - "it", - "we", - "they", - "me", - "him", - "her", - "us", - "them", - "my", - "your", - "his", - "its", - "our", - "their", - "mine", - "yours", - "hers", - "ours", - "theirs", - "to", - "of", - "in", - "on", - "at", - "by", - "for", - "with", - "about", - "against", - "between", - "into", - "through", - "during", - "before", - "after", - "above", - "below", - "from", - "up", + "couldn", + "d", + "did", + "didn", + "do", + "does", + "doesn", + "don", "down", - "out", - "off", - "over", - "under", - "again", - "further", - "then", - "once", - "here", - "there", - "when", - "where", - "why", - "how", - "all", - "any", - "both", + "during", "each", "few", + "for", + "from", + "further", + "goodbye", + "had", + "hadn", + "has", + "hasn", + "have", + "haven", + "he", + "hello", + "her", + "here", + "hers", + "hi", + "him", + "his", + "how", + "i", + "in", + "into", + "is", + "isn", + "it", + "its", + "just", + "ll", + "m", + "ma", + "may", + "me", + "might", + "mightn", + "mine", "more", "most", - "other", - "some", - "such", + "must", + "mustn", + "my", + "needn", "no", "nor", "not", - "only", - "own", - "same", - "so", - "than", - "too", - "very", - "s", - "t", - "just", - "don", - "shouldve", "now", - "d", - "ll", - "m", "o", - "re", - "ve", - "y", - "ain", - "aren", - "couldn", - "didn", - "doesn", - "hadn", - "hasn", - "haven", - "isn", - "ma", - "mightn", - "mustn", - "needn", - "shan", - "shouldn", - "wasn", - "weren", - "won", - "wouldn", - "hi", - "hello", - "thanks", - "thank", - "please", + "of", + "off", "ok", "okay", - "yes", + "on", + "once", + "only", + "other", + "our", + "ours", + "out", + "over", + "own", + "please", + "re", + "s", + "same", + "shan", + "she", + "should", + "shouldn", + "shouldve", + "so", + "some", + "such", + "t", + "than", + "thank", + "thanks", + "the", + "their", + "theirs", + "them", + "then", + "there", + "they", + "through", + "to", + "too", + "under", + "up", + "us", + "ve", + "very", + "was", + "wasn", + "we", + "were", + "weren", + "when", + "where", + "why", + "will", + "with", + "won", + "would", + "wouldn", + "y", "yeah", - "bye", - "goodbye", + "yes", + "you", + "your", + "yours", // French stop words + "des", + "donc", + "et", "la", "le", "les", + "mais", + "ou", "un", "une", - "des", - "et", - "ou", - "mais", - "donc", // Dutch stop words - "dit", - "ben", - "de", - "het", - "ik", - "jij", - "hij", - "zij", - "wij", - "jullie", - "deze", - "dit", - "dat", - "die", - "een", - "en", - "of", - "maar", - "want", - "omdat", - "dus", - "als", - "ook", - "dan", - "nu", - "nog", - "al", - "naar", - "voor", - "van", - "door", - "met", - "bij", - "tot", - "om", - "over", - "tussen", - "onder", - "boven", - "tegen", "aan", - "uit", - "sinds", - "tijdens", - "binnen", - "buiten", - "zonder", - "volgens", - "dankzij", - "ondanks", - "behalve", - "mits", - "tenzij", - "hoewel", + "al", "alhoewel", - "toch", + "als", "anders", - "echter", - "wel", - "niet", - "geen", - "iets", - "niets", - "veel", - "weinig", - "meer", - "meest", - "elk", - "ieder", - "sommige", - "hoe", - "wat", - "waar", - "wie", - "wanneer", - "waarom", - "welke", - "wordt", - "worden", - "werd", - "werden", - "geworden", - "zijn", + "behalve", + "ben", "ben", "bent", - "was", - "waren", + "bij", + "binnen", + "boven", + "buiten", + "dan", + "dankzij", + "dat", + "de", + "deze", + "die", + "dit", + "dit", + "door", + "dus", + "echter", + "een", + "elk", + "en", + "geen", + "gehad", "geweest", - "hebben", - "heb", - "hebt", - "heeft", + "geworden", "had", "hadden", - "gehad", - "kunnen", + "heb", + "hebben", + "hebt", + "heeft", + "het", + "hij", + "hoe", + "hoewel", + "ieder", + "iets", + "ik", + "jij", + "jullie", "kan", - "kunt", "kon", "konden", - "zullen", + "kunnen", + "kunt", + "maar", + "meer", + "meest", + "met", + "mits", + "naar", + "niet", + "niets", + "nog", + "nu", + "of", + "om", + "omdat", + "ondanks", + "onder", + "ook", + "over", + "sinds", + "sommige", + "tegen", + "tenzij", + "tijdens", + "toch", + "tot", + "tussen", + "uit", + "van", + "veel", + "volgens", + "voor", + "waar", + "waarom", + "wanneer", + "want", + "waren", + "was", + "wat", + "weinig", + "wel", + "welke", + "werd", + "werden", + "wie", + "wij", + "worden", + "wordt", "zal", + "zij", + "zijn", + "zonder", + "zullen", "zult", // Add more domain-specific stop words if necessary ]); @@ -515,6 +529,24 @@ export function sessionMetrics( const avgSessionsPerDay = numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0; + // Calculate trends + const totalSessionsTrend = calculateTrendPercentage( + totalSessions, + mockPreviousPeriodData.totalSessions + ); + const uniqueUsersTrend = calculateTrendPercentage( + uniqueUsers, + mockPreviousPeriodData.uniqueUsers + ); + const avgSessionLengthTrend = calculateTrendPercentage( + avgSessionLength, + mockPreviousPeriodData.avgSessionLength + ); + const avgResponseTimeTrend = calculateTrendPercentage( + avgResponseTime, + mockPreviousPeriodData.avgResponseTime + ); + // console.log("Debug metrics calculation:", { // totalSessionDuration, // validSessionsForDuration, @@ -542,7 +574,16 @@ export function sessionMetrics( wordCloudData, belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount) avgSessionsPerDay, // Added to satisfy MetricsResult interface - // Optional fields from MetricsResult that are not yet calculated can be added here or handled by the consumer - // avgSentiment, sentimentThreshold, lastUpdated, sessionTrend, usersTrend, avgSessionTimeTrend, avgResponseTimeTrend + // Map trend values to the expected property names in MetricsResult + sessionTrend: totalSessionsTrend, + usersTrend: uniqueUsersTrend, + avgSessionTimeTrend: avgSessionLengthTrend, + // For response time, a negative trend is actually positive (faster responses are better) + avgResponseTimeTrend: -avgResponseTimeTrend, // Invert as lower response time is better + // Additional fields + sentimentThreshold: companyConfig.sentimentAlert, + lastUpdated: Date.now(), + totalSessionDuration, + validSessionsForDuration, }; } From 13d0f8ee8dcc66992f2a9b95cd1f271c1960f346 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 22:29:06 +0200 Subject: [PATCH 08/15] Add markdownlint-cli2 for markdown linting; configure Prettier with Jinja template support and update linting scripts --- TODO.md | 46 +++--- package-lock.json | 358 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 47 +++++- 3 files changed, 425 insertions(+), 26 deletions(-) diff --git a/TODO.md b/TODO.md index cb640d6..317678f 100644 --- a/TODO.md +++ b/TODO.md @@ -4,42 +4,40 @@ This file lists general areas for improvement and tasks that are broader in scop ## General Enhancements & Features -- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events). -- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV). -- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them. -- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources. +- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events). +- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV). +- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them. +- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources. ## Robustness and Maintainability -- [ ] **Comprehensive Testing:** - - [ ] Implement unit tests (e.g., for utility functions, API logic). - - [ ] Implement integration tests (e.g., for API endpoints with the database). - - [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress). -- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging. -- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast). +- [ ] **Comprehensive Testing:** + - [ ] Implement unit tests (e.g., for utility functions, API logic). + - [ ] Implement integration tests (e.g., for API endpoints with the database). + - [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress). +- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging. +- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast). ## Security Enhancements -- [ ] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented). -- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts. -- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized. +- [x] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented). +- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts. +- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized. ## Code Quality and Development Practices -- [ ] **Code Reviews:** Enforce code reviews for all changes. -- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations. -- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates. -- [ ] **Documentation:** - - Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations. - - Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied. +- [ ] **Code Reviews:** Enforce code reviews for all changes. +- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations. +- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates. +- [ ] **Documentation:** + - Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations. + - Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied. ## Component Specific -- [ ] **`components/SessionDetails.tsx.new`:** Review, complete TODOs within the file, and integrate as the primary `SessionDetails.tsx` component, removing/archiving older versions (`SessionDetails.tsx`, `SessionDetails.tsx.bak`). -- [ ] **`components/GeographicMap.tsx`:** Check if `GeographicMap.tsx.bak` is still needed or can be removed. -- [ ] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting. -- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords. +- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords. +- [x] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting. ## File Cleanup -- [ ] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`). +- [x] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`). diff --git a/package-lock.json b/package-lock.json index 5953eb0..9c6ef9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,8 +50,10 @@ "eslint": "^9.27.0", "eslint-config-next": "^15.3.2", "eslint-plugin-prettier": "^5.4.0", + "markdownlint-cli2": "^0.18.1", "postcss": "^8.5.3", "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.1.0", "prisma": "^6.8.2", "tailwindcss": "^4.1.7", "ts-node": "^10.9.2", @@ -1205,6 +1207,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1863,6 +1878,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/leaflet": { "version": "1.9.18", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.18.tgz", @@ -4838,6 +4860,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5775,6 +5828,13 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5791,6 +5851,33 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6086,6 +6173,16 @@ "integrity": "sha512-KlA/wRSjpKl7tS9iRUdlG72oQ7qZ1IlVbVgHwoO10TBR/4gQ86uhKow6nlzMAJJhjCWKto8OeoAzzIzKSmN25A==", "license": "ISC" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6161,6 +6258,98 @@ "dev": true, "license": "ISC" }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/markdownlint": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.38.0.tgz", + "integrity": "sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.18.1.tgz", + "integrity": "sha512-/4Osri9QFGCZOCTkfA8qJF+XGjKYERSHkXzxSyS1hd3ZERJGjvsUao2h4wdnvpHp6Tu2Jh/bPHM0FE9JJza6ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "14.1.0", + "js-yaml": "4.1.0", + "jsonc-parser": "3.3.1", + "markdown-it": "14.1.0", + "markdownlint": "0.38.0", + "markdownlint-cli2-formatter-default": "0.0.5", + "micromatch": "4.0.8" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.5.tgz", + "integrity": "sha512-4XKTwQ5m1+Txo2kuQ3Jgpo/KmnG+X90dWt4acufg6HVGadTUG5hzHF/wssp9b5MBYOMCnZ9RMPaU//uHsszF8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6323,6 +6512,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6402,6 +6598,102 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -7387,6 +7679,19 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7538,6 +7843,16 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-jinja-template": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.1.0.tgz", + "integrity": "sha512-mzoCp2Oy9BDSug80fw3B3J4n4KQj1hRvoQOL1akqcDKBb5nvYxrik9zUEDs4AEJ6nK7QDTGoH0y9rx7AlnQ78Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/pretty-format": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", @@ -7602,6 +7917,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8177,6 +8502,19 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8748,6 +9086,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -8773,6 +9118,19 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index 40aff56..4da3bdb 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,10 @@ "eslint": "^9.27.0", "eslint-config-next": "^15.3.2", "eslint-plugin-prettier": "^5.4.0", + "markdownlint-cli2": "^0.18.1", "postcss": "^8.5.3", "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.1.0", "prisma": "^6.8.2", "tailwindcss": "^4.1.7", "ts-node": "^10.9.2", @@ -64,7 +66,9 @@ "prisma:migrate": "prisma migrate dev", "prisma:seed": "node prisma/seed.mjs", "prisma:studio": "prisma studio", - "start": "next start" + "start": "next start", + "lint:md": "markdownlint-cli2 \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"", + "lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"" }, "prettier": { "bracketSpacing": true, @@ -74,6 +78,45 @@ "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", - "useTabs": false + "useTabs": false, + "overrides": [ + { + "files": [ + "*.md", + "*.markdown" + ], + "options": { + "tabWidth": 2, + "useTabs": false, + "proseWrap": "preserve", + "printWidth": 100 + } + } + ], + "plugins": [ + "prettier-plugin-jinja-template" + ] + }, + "markdownlint-cli2": { + "config": { + "MD007": { + "indent": 4, + "start_indented": false, + "start_indent": 4 + }, + "MD013": false, + "MD030": { + "ul_single": 3, + "ol_single": 2, + "ul_multi": 3, + "ol_multi": 2 + }, + "MD033": false + }, + "ignores": [ + "node_modules", + ".git", + "*.json" + ] } } From 9fad25e5f9328bd139068b23c8776a06171e749a Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 23:55:30 +0200 Subject: [PATCH 09/15] Update TODO.md with new tasks and enhance README.md with project details and setup instructions --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++ TODO.md | 123 ++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..35ebd3c --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# LiveDash-Node + +A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics. + +![Next.js](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22next%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000) +![React](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22react%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB) +![TypeScript](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22typescript%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6) +![Prisma](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22prisma%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748) +![TailwindCSS](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22tailwindcss%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4) + +## Features + +- **Real-time Session Monitoring**: Track and analyze user sessions as they happen +- **Interactive Visualizations**: Geographic maps, response time distributions, and more +- **Advanced Analytics**: Detailed metrics and insights about user behavior +- **User Management**: Secure authentication with role-based access control +- **Customizable Dashboard**: Filter and sort data based on your specific needs +- **Session Details**: In-depth analysis of individual user sessions + +## Tech Stack + +- **Frontend**: React 19, Next.js 15, TailwindCSS 4 +- **Backend**: Next.js API Routes, Node.js +- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL +- **Authentication**: NextAuth.js +- **Visualization**: Chart.js, D3.js, React Leaflet +- **Data Processing**: Node-cron for scheduled tasks + +## Getting Started + +### Prerequisites + +- Node.js (LTS version recommended) +- npm or yarn + +### Installation + +1. Clone this repository: + + ```bash + git clone https://github.com/kjanat/livedash-node.git + cd livedash-node + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Set up the database: + + ```bash + npm run prisma:generate + npm run prisma:migrate + npm run prisma:seed + ``` + +4. Start the development server: + + ```bash + npm run dev + ``` + +5. Open your browser and navigate to + +## Environment Setup + +Create a `.env` file in the root directory with the following variables: + +```env +DATABASE_URL="file:./dev.db" +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-secret-here +``` + +## Project Structure + +- `app/`: Next.js App Router components and pages +- `components/`: Reusable React components +- `lib/`: Utility functions and shared code +- `pages/`: API routes and server-side code +- `prisma/`: Database schema and migrations +- `public/`: Static assets +- `docs/`: Project documentation + +## Available Scripts + +- `npm run dev`: Start the development server +- `npm run build`: Build the application for production +- `npm run start`: Run the production build +- `npm run lint`: Run ESLint +- `npm run format`: Format code with Prettier +- `npm run prisma:studio`: Open Prisma Studio to view database + +## Contributing + +1. Fork the repository +2. Create your feature branch: `git checkout -b feature/my-new-feature` +3. Commit your changes: `git commit -am 'Add some feature'` +4. Push to the branch: `git push origin feature/my-new-feature` +5. Submit a pull request + +## License + +This project is not licensed; you are no commercial use is allowed without explicit permission. Free to use for educational purposes/ personal projects. +Feel free to modify and adapt the code as needed. + +## Acknowledgments + +- [Next.js](https://nextjs.org/) +- [Prisma](https://prisma.io/) +- [TailwindCSS](https://tailwindcss.com/) +- [Chart.js](https://www.chartjs.org/) +- [D3.js](https://d3js.org/) +- [React Leaflet](https://react-leaflet.js.org/) diff --git a/TODO.md b/TODO.md index 317678f..c6dd717 100644 --- a/TODO.md +++ b/TODO.md @@ -1,43 +1,96 @@ -# Application Improvement TODOs +# TODO.md -This file lists general areas for improvement and tasks that are broader in scope or don't map to a single specific file. +## Dashboard Integration -## General Enhancements & Features - -- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events). -- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV). -- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them. -- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources. - -## Robustness and Maintainability - -- [ ] **Comprehensive Testing:** - - [ ] Implement unit tests (e.g., for utility functions, API logic). - - [ ] Implement integration tests (e.g., for API endpoints with the database). - - [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress). -- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging. -- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast). - -## Security Enhancements - -- [x] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented). -- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts. -- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized. - -## Code Quality and Development Practices - -- [ ] **Code Reviews:** Enforce code reviews for all changes. -- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations. -- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates. -- [ ] **Documentation:** - - Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations. - - Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied. +- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation** + - Investigate integrating real data sources with server-side analytics + - Replace simulated data mentioned in `docs/dashboard-components.md` ## Component Specific -- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords. -- [x] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting. +- [ ] **Implement robust emailing of temporary passwords** + - File: `pages/api/dashboard/users.ts` + - Set up proper email service integration + +- [x] **Session page improvements** ✅ + - File: `app/dashboard/sessions/page.tsx` + - Implemented pagination, advanced filtering, and sorting ## File Cleanup -- [x] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`). +- [x] **Remove backup files** ✅ + - Reviewed and removed `.bak` and `.new` files after integration + - Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new` + +## Database Schema Improvements + +- [ ] **Update EndTime field** + - Make `endTime` field nullable in Prisma schema to match TypeScript interfaces + +- [ ] **Add database indices** + - Add appropriate indices to improve query performance + - Focus on dashboard metrics and session listing queries + +- [ ] **Implement production email service** + - Replace console logging in `lib/sendEmail.ts` + - Consider providers: Nodemailer, SendGrid, AWS SES + +## General Enhancements & Features + +- [ ] **Real-time updates** + - Implement for dashboard and session list + - Consider WebSockets or Server-Sent Events + +- [ ] **Data export functionality** + - Allow users (especially admins) to export session data + - Support CSV format initially + +- [ ] **Customizable dashboard** + - Allow users to customize dashboard view + - Let users choose which metrics/charts are most important + +## Testing & Quality Assurance + +- [ ] **Comprehensive testing suite** + - [ ] Unit tests for utility functions and API logic + - [ ] Integration tests for API endpoints with database + - [ ] End-to-end tests for user flows (Playwright or Cypress) + +- [ ] **Error monitoring and logging** + - Integrate robust error monitoring service (Sentry) + - Enhance server-side logging + +- [ ] **Accessibility improvements** + - Review application against WCAG guidelines + - Improve keyboard navigation and screen reader compatibility + - Check color contrast ratios + +## Security Enhancements + +- [x] **Password reset functionality** ✅ + - Implemented secure password reset mechanism + - Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` + +- [ ] **Two-Factor Authentication (2FA)** + - Consider adding 2FA, especially for admin accounts + +- [ ] **Input validation and sanitization** + - Review all user inputs (API request bodies, query parameters) + - Ensure proper validation and sanitization + +## Code Quality & Development + +- [ ] **Code review process** + - Enforce code reviews for all changes + +- [ ] **Environment configuration** + - Ensure secure management of environment-specific configurations + +- [ ] **Dependency management** + - Periodically review dependencies for vulnerabilities + - Keep dependencies updated + +- [ ] **Documentation updates** + - [ ] Ensure `docs/dashboard-components.md` reflects actual implementations + - [ ] Verify "Dashboard Enhancements" are consistently applied + - [ ] Update documentation for improved layout and visual hierarchies From 01f4dd60f945cfffd6ef423b71871452319439b4 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 23:59:47 +0200 Subject: [PATCH 10/15] Update playwright.yml --- .github/workflows/playwright.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 2812391..15f6b2c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -15,6 +15,8 @@ jobs: node-version: lts/* - name: Install dependencies run: npm ci + - name: Install dependencies + run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests From be63dba540a35d582dee57ee68c64e8b71cc4be0 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 23 May 2025 00:09:00 +0200 Subject: [PATCH 11/15] Update playwright.yml renamed step --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 15f6b2c..f34c617 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -15,7 +15,7 @@ jobs: node-version: lts/* - name: Install dependencies run: npm ci - - name: Install dependencies + - name: Build dashboard run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps From a265f3236c0ddede132b98082cee53ed0fa511c0 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 23 May 2025 00:10:45 +0200 Subject: [PATCH 12/15] Update README.md Typo --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 35ebd3c..b9d01dc 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,7 @@ NEXTAUTH_SECRET=your-secret-here ## License -This project is not licensed; you are no commercial use is allowed without explicit permission. Free to use for educational purposes/ personal projects. -Feel free to modify and adapt the code as needed. +This project is not licensed for commercial use without explicit permission. Free to use for educational or personal projects. ## Acknowledgments From cb86d267864f3598f9e0ae84a527554aa81b86b3 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 23 May 2025 00:13:16 +0200 Subject: [PATCH 13/15] Update components/DonutChart.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/DonutChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/DonutChart.tsx b/components/DonutChart.tsx index df33bd5..c9d1a2c 100644 --- a/components/DonutChart.tsx +++ b/components/DonutChart.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useEffect } from "react"; -import Chart, { Point, BubbleDataPoint } from "chart.js/auto"; +import ChartJS, { Point, BubbleDataPoint } from "chart.js/auto"; interface DonutChartProps { data: { From 940b41656339b80589cffa1ed5ba85fa0b4d8336 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 23 May 2025 00:19:55 +0200 Subject: [PATCH 14/15] Revert DonutChart.tsx --- components/DonutChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/DonutChart.tsx b/components/DonutChart.tsx index c9d1a2c..df33bd5 100644 --- a/components/DonutChart.tsx +++ b/components/DonutChart.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useEffect } from "react"; -import ChartJS, { Point, BubbleDataPoint } from "chart.js/auto"; +import Chart, { Point, BubbleDataPoint } from "chart.js/auto"; interface DonutChartProps { data: { From bbcdff0ffc0281ef263a990c11f93eb3e06514ec Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 23 May 2025 00:21:24 +0200 Subject: [PATCH 15/15] Update app/dashboard/company/page.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/dashboard/company/page.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx index 3cf7efd..fca955e 100644 --- a/app/dashboard/company/page.tsx +++ b/app/dashboard/company/page.tsx @@ -27,6 +27,9 @@ export default function CompanySettingsPage() { setCsvUrl(data.company.csvUrl || ""); setCsvUsername(data.company.csvUsername || ""); setSentimentThreshold(data.company.sentimentAlert?.toString() || ""); + if (data.company.csvPassword) { + setCsvPassword(data.company.csvPassword); + } } catch (error) { console.error("Failed to fetch company settings:", error); setMessage("Failed to load company settings.");