mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:52:10 +01:00
Enhance session handling and improve data parsing; add safe date parsing utility
This commit is contained in:
@ -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>("");
|
||||||
|
|||||||
@ -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" });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user