Enhance session handling and improve data parsing; add safe date parsing utility

This commit is contained in:
2025-05-22 16:11:33 +02:00
parent efb5261c7d
commit ed6e5b0c36
11 changed files with 130 additions and 50 deletions

View File

@ -6,6 +6,8 @@ import { Company } from "../../lib/types";
export default function CompanySettingsPage() { export default function CompanySettingsPage() {
const { data: session, status } = useSession(); 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<Company | null>(null); const [company, setCompany] = useState<Company | null>(null);
const [csvUrl, setCsvUrl] = useState<string>(""); const [csvUrl, setCsvUrl] = useState<string>("");
const [csvUsername, setCsvUsername] = useState<string>(""); const [csvUsername, setCsvUsername] = useState<string>("");

View File

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import Sidebar from "../../components/Sidebar"; import Sidebar from "../../components/Sidebar";
export default function DashboardLayout({ children }: { children: ReactNode }) { 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 { data: session, status } = useSession();
const router = useRouter(); 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 = () => { const handleLogout = () => {
signOut({ callbackUrl: "/login" }); signOut({ callbackUrl: "/login" });
}; };

View File

@ -40,7 +40,7 @@ export default function SessionsPage() {
// Pagination states // Pagination states
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); 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 const [pageSize, setPageSize] = useState(10); // Or make this configurable
useEffect(() => { useEffect(() => {
@ -283,7 +283,11 @@ export default function SessionsPage() {
Session ID: {session.sessionId || session.id} Session ID: {session.sessionId || session.id}
</h2> </h2>
<p className="text-sm text-gray-500 mb-1"> <p className="text-sm text-gray-500 mb-1">
Start Time: {new Date(session.startTime).toLocaleString()} Start Time (Local):{" "}
{new Date(session.startTime).toLocaleString()}
</p>
<p className="text-xs text-gray-400 mb-1">
Start Time (Raw API): {session.startTime.toString()}
</p> </p>
{session.category && ( {session.category && (
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">

View File

@ -2,7 +2,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { UserSession } from "../../../lib/types";
interface UserItem { interface UserItem {
id: string; id: string;

View File

@ -77,7 +77,8 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
const label = context.label || ""; const label = context.label || "";
const value = context.formattedValue; const value = context.formattedValue;
const total = context.chart.data.datasets[0].data.reduce( 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 0
); );
const percentage = Math.round((context.parsed * 100) / total); const percentage = Math.round((context.parsed * 100) / total);
@ -91,7 +92,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
? [ ? [
{ {
id: "centerText", id: "centerText",
beforeDraw: function (chart: any) { beforeDraw: function (chart: Chart<"doughnut">) {
const height = chart.height; const height = chart.height;
const ctx = chart.ctx; const ctx = chart.ctx;
ctx.restore(); ctx.restore();

View File

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; 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 { usePathname } from "next/navigation";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
@ -195,16 +195,20 @@ export default function Sidebar() {
src="/favicon.svg" src="/favicon.svg"
alt="LiveDash Logo" alt="LiveDash Logo"
fill fill
style={{ objectFit: "contain" }}
className="transition-all duration-300" 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"
}}
/> />
</div> </div>
{isExpanded && ( {isExpanded && (
<span className="text-lg font-bold text-sky-700 mt-1">LiveDash</span> <span className="text-lg font-bold text-sky-700 mt-1">LiveDash</span>
)} )}
</div> </div>
{/* Toggle button */} {/* Toggle button */}
<div className="flex justify-center border-b border-t py-2"> <div className="flex justify-center border-b border-t py-2">
<button <button
@ -225,7 +229,6 @@ export default function Sidebar() {
)} )}
</button> </button>
</div> </div>
{/* Navigation items */} {/* Navigation items */}
<nav className="flex-1 py-4 px-2 overflow-y-auto overflow-x-visible"> <nav className="flex-1 py-4 px-2 overflow-y-auto overflow-x-visible">
<NavItem <NavItem
@ -279,7 +282,6 @@ export default function Sidebar() {
isActive={pathname === "/dashboard/users"} isActive={pathname === "/dashboard/users"}
/> />
</nav> </nav>
{/* Logout at the bottom */} {/* Logout at the bottom */}
<div className="p-4 border-t mt-auto"> <div className="p-4 border-t mt-auto">
<button <button

View File

@ -55,7 +55,7 @@ function formatTranscript(content: string): React.ReactNode[] {
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
components={{ components={{
p: "span", p: "span",
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
a: ({ node: _node, ...props }) => ( a: ({ node: _node, ...props }) => (
<a <a
className="text-sky-600 hover:text-sky-800 underline" className="text-sky-600 hover:text-sky-800 underline"
@ -107,7 +107,7 @@ function formatTranscript(content: string): React.ReactNode[] {
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
components={{ components={{
p: "span", p: "span",
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
a: ({ node: _node, ...props }) => ( a: ({ node: _node, ...props }) => (
<a <a
className="text-sky-600 hover:text-sky-800 underline" className="text-sky-600 hover:text-sky-800 underline"

View File

@ -53,7 +53,7 @@ export default function WordCloud({
) )
.padding(5) .padding(5)
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees .rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
.fontSize((d) => (d as any).size) .fontSize((d: CloudWord) => d.size)
.on("end", draw); .on("end", draw);
layout.start(); layout.start();

View File

@ -5,7 +5,7 @@ import ISO6391 from "iso-639-1";
import countries from "i18n-iso-countries"; import countries from "i18n-iso-countries";
// Register locales for i18n-iso-countries // Register locales for i18n-iso-countries
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" }; import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
countries.registerLocale(enLocale); countries.registerLocale(enLocale);
// This type is used internally for parsing the CSV records // This type is used internally for parsing the CSV records
@ -374,6 +374,60 @@ function isTruthyValue(value?: string): boolean {
return truthyValues.includes(value.toLowerCase()); return truthyValues.includes(value.toLowerCase());
} }
/**
* Safely parses a date string into a Date object.
* Handles potential errors and various formats, prioritizing D-M-YYYY HH:MM:SS.
* @param dateStr The date string to parse.
* @returns A Date object or null if parsing fails.
*/
function safeParseDate(dateStr?: string): Date | null {
if (!dateStr) return null;
// Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots)
const dateTimeRegex =
/^(\d{1,2})[\.-](\d{1,2})[\.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/;
const match = dateStr.match(dateTimeRegex);
if (match) {
const day = match[1];
const month = match[2];
const year = match[3];
const hour = match[4];
const minute = match[5];
const second = match[6];
// Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time)
// Ensure month and day are two digits
const formattedDateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
try {
const date = new Date(formattedDateStr);
// Basic validation: check if the constructed date is valid
if (!isNaN(date.getTime())) {
console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`);
return date;
}
} catch (e) {
console.warn(`[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`, e);
}
}
// Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed
try {
const parsedDate = new Date(dateStr);
if (!isNaN(parsedDate.getTime())) {
console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`);
return parsedDate;
}
} catch (e) {
console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e);
}
console.warn(`Failed to parse date string: ${dateStr}`);
return null;
}
export async function fetchAndParseCsv( export async function fetchAndParseCsv(
url: string, url: string,
username?: string, username?: string,
@ -418,13 +472,6 @@ export async function fetchAndParseCsv(
trim: true, trim: true,
}); });
// Helper function to safely parse dates
function safeParseDate(dateStr?: string): Date | null {
if (!dateStr) return null;
const date = new Date(dateStr);
return !isNaN(date.getTime()) ? date : null;
}
// Coerce types for relevant columns // Coerce types for relevant columns
return records.map((r) => ({ return records.map((r) => ({
id: r.session_id, id: r.session_id,

View File

@ -2,7 +2,7 @@ import ISO6391 from "iso-639-1";
import countries from "i18n-iso-countries"; import countries from "i18n-iso-countries";
// Register locales for i18n-iso-countries // Register locales for i18n-iso-countries
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" }; import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
countries.registerLocale(enLocale); countries.registerLocale(enLocale);
/** /**

View File

@ -7,6 +7,7 @@ import {
SessionApiResponse, SessionApiResponse,
SessionQuery, SessionQuery,
} from "../../../lib/types"; } from "../../../lib/types";
import { Prisma } from "@prisma/client";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -39,7 +40,7 @@ export default async function handler(
const pageSize = Number(queryPageSize) || 10; const pageSize = Number(queryPageSize) || 10;
try { try {
const whereClause: any = { companyId }; const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term // Search Term
if ( if (
@ -48,11 +49,10 @@ export default async function handler(
searchTerm.trim() !== "" searchTerm.trim() !== ""
) { ) {
const searchConditions = [ const searchConditions = [
{ id: { contains: searchTerm, mode: "insensitive" } }, { id: { contains: searchTerm } },
{ sessionId: { contains: searchTerm, mode: "insensitive" } }, { category: { contains: searchTerm } },
{ category: { contains: searchTerm, mode: "insensitive" } }, { initialMsg: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm, mode: "insensitive" } }, { transcriptContent: { contains: searchTerm } },
{ transcriptContent: { contains: searchTerm, mode: "insensitive" } },
]; ];
whereClause.OR = searchConditions; whereClause.OR = searchConditions;
} }
@ -69,22 +69,22 @@ export default async function handler(
// Date Range Filter // Date Range Filter
if (startDate && typeof startDate === "string") { if (startDate && typeof startDate === "string") {
if (!whereClause.startTime) whereClause.startTime = {}; whereClause.startTime = {
whereClause.startTime.gte = new Date(startDate); ...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
} }
if (endDate && typeof endDate === "string") { if (endDate && typeof endDate === "string") {
if (!whereClause.startTime) whereClause.startTime = {};
const inclusiveEndDate = new Date(endDate); const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime.lt = inclusiveEndDate; whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
} }
// Sorting // Sorting
let orderByClause: any = { startTime: "desc" }; const validSortKeys: { [key: string]: string; } = {
if (sortKey && typeof sortKey === "string") {
const order =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
const validSortKeys: { [key: string]: string } = {
startTime: "startTime", startTime: "startTime",
category: "category", category: "category",
language: "language", language: "language",
@ -92,14 +92,36 @@ export default async function handler(
messagesSent: "messagesSent", messagesSent: "messagesSent",
avgResponseTime: "avgResponseTime", avgResponseTime: "avgResponseTime",
}; };
if (validSortKeys[sortKey]) {
orderByClause = { [validSortKeys[sortKey]]: order }; let orderByCondition:
} | Prisma.SessionOrderByWithRelationInput
| Prisma.SessionOrderByWithRelationInput[];
const primarySortField =
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
? validSortKeys[sortKey]
: "startTime"; // Default to startTime field if sortKey is invalid/missing
const primarySortOrder =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
if (primarySortField === "startTime") {
// If sorting by startTime, it's the only sort criteria
orderByCondition = { [primarySortField]: primarySortOrder };
} else {
// If sorting by another field, use startTime: "desc" as secondary sort
orderByCondition = [
{ [primarySortField]: primarySortOrder },
{ startTime: "desc" },
];
} }
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
// which is the correct overall default sort.
const prismaSessions = await prisma.session.findMany({ const prismaSessions = await prisma.session.findMany({
where: whereClause, where: whereClause,
orderBy: orderByClause, orderBy: orderByCondition,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}); });