diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3722418..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} 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/.github/dependabot.yml b/.github/dependabot.yml index f6a6830..fac1eed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,18 +9,30 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "docker" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" - package-ecosystem: "docker-compose" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + day: "tuesday" + time: "03:00" + timezone: "Europe/Amsterdam" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..f34c617 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,29 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Build dashboard + run: npm run build + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 0b2e432..3312044 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,9 @@ Thumbs.db # Backup files *.bak + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index a268927..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "singleQuote": false, - "trailingComma": "es5", - "semi": true, - "tabWidth": 2, - "useTabs": false, - "printWidth": 80, - "bracketSpacing": true, - "endOfLine": "auto" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e570135..547dda8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "prisma.prisma", "dbaeumer.vscode-eslint", - "rvest.vs-code-prettier-eslint" + "rvest.vs-code-prettier-eslint", + "ms-playwright.playwright" ] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d01dc --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# 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 for commercial use without explicit permission. Free to use for educational or personal projects. + +## 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 cb640d6..c6dd717 100644 --- a/TODO.md +++ b/TODO.md @@ -1,45 +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 - -- [ ] **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 -- [ ] **`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. +- [ ] **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 -- [ ] 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 diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx new file mode 100644 index 0000000..fca955e --- /dev/null +++ b/app/dashboard/company/page.tsx @@ -0,0 +1,183 @@ +"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(); + // 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(""); + 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() || ""); + if (data.company.csvPassword) { + setCsvPassword(data.company.csvPassword); + } + } 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(); + }} + autoComplete="off" + > +
+ + setCsvUrl(e.target.value)} + placeholder="https://example.com/data.csv" + autoComplete="off" + /> +
+ +
+ + setCsvUsername(e.target.value)} + placeholder="Username for CSV access (if needed)" + autoComplete="off" + /> +
+ +
+ + setCsvPassword(e.target.value)} + placeholder="Password will be updated only if provided" + autoComplete="new-password" + /> +

+ Leave blank to keep current password +

+
+ +
+ + setSentimentThreshold(e.target.value)} + 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/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..ce5f813 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { ReactNode, useState, useEffect, useCallback } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Sidebar from "../../components/Sidebar"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const { status } = useSession(); + const router = useRouter(); + + const [isSidebarExpanded, setIsSidebarExpanded] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const updateStatesBasedOnScreen = () => { + const screenIsMobile = window.innerWidth < 640; // sm breakpoint for mobile + const screenIsSmallDesktop = window.innerWidth < 768 && !screenIsMobile; // between sm and md + + setIsMobile(screenIsMobile); + setIsSidebarExpanded(!screenIsSmallDesktop && !screenIsMobile); + }; + + updateStatesBasedOnScreen(); + window.addEventListener("resize", updateStatesBasedOnScreen); + return () => + window.removeEventListener("resize", updateStatesBasedOnScreen); + }, []); + + // Toggle sidebar handler - used for clicking the toggle button + const toggleSidebarHandler = useCallback(() => { + setIsSidebarExpanded((prev) => !prev); + }, []); + + // Collapse sidebar handler - used when clicking navigation links on mobile + const collapseSidebar = useCallback(() => { + if (isMobile) { + setIsSidebarExpanded(false); + } + }, [isMobile]); + + if (status === "unauthenticated") { + router.push("/login"); + return ( +
+
Redirecting to login...
+
+ ); + } + + if (status === "loading") { + return ( +
+
Loading session...
+
+ ); + } + + return ( +
+ + +
+ {/*
{children}
*/} +
{children}
+
+
+ ); +} diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx new file mode 100644 index 0000000..511a14e --- /dev/null +++ b/app/dashboard/overview/page.tsx @@ -0,0 +1,429 @@ +"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(); + console.log("Metrics from API:", { + avgSessionLength: data.metrics.avgSessionLength, + avgSessionTimeTrend: data.metrics.avgSessionTimeTrend, + totalSessionDuration: data.metrics.totalSessionDuration, + validSessionsForDuration: data.metrics.validSessionsForDuration, + }); + setMetrics(data.metrics); + setCompany(data.company); + setLoading(false); + }; + 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 ?? 0, + isPositive: (metrics.sessionTrend ?? 0) >= 0, + }} + /> + + + + } + trend={{ + value: metrics.usersTrend ?? 0, + isPositive: (metrics.usersTrend ?? 0) >= 0, + }} + /> + + + + } + trend={{ + value: metrics.avgSessionTimeTrend ?? 0, + isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0, + }} + /> + + + + } + trend={{ + value: metrics.avgResponseTimeTrend ?? 0, + isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 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/sessions/[id]/page.tsx b/app/dashboard/sessions/[id]/page.tsx index e29f192..7ff90d8 100644 --- a/app/dashboard/sessions/[id]/page.tsx +++ b/app/dashboard/sessions/[id]/page.tsx @@ -25,7 +25,7 @@ export default function SessionViewPage() { if (status === "authenticated" && id) { const fetchSession = async () => { - if (!session) setLoading(true); + setLoading(true); // Always set loading before fetch setError(null); try { const response = await fetch(`/api/dashboard/session/${id}`); @@ -52,7 +52,7 @@ export default function SessionViewPage() { setError("Session ID is missing."); setLoading(false); } - }, [id, status, router, session]); + }, [id, status, router]); // session removed from dependencies if (status === "loading") { return ( diff --git a/app/dashboard/sessions/page.tsx b/app/dashboard/sessions/page.tsx index 90a3945..628d306 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,8 +283,12 @@ 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 && (

Category:{" "} diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx new file mode 100644 index 0000000..57e10ec --- /dev/null +++ b/app/dashboard/users/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; + +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(); + }} + autoComplete="off" // Disable autofill for the form + > +
+ + setEmail(e.target.value)} + required + autoComplete="off" // Disable autofill for this input + /> +
+ +
+ + +
+ + +
+
+ +
+

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/DonutChart.tsx b/components/DonutChart.tsx index 25d0501..df33bd5 100644 --- a/components/DonutChart.tsx +++ b/components/DonutChart.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useEffect } from "react"; -import Chart from "chart.js/auto"; +import Chart, { Point, BubbleDataPoint } from "chart.js/auto"; interface DonutChartProps { data: { @@ -77,9 +77,24 @@ 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 + | Point + | [number, number] + | BubbleDataPoint + | null + ) => { + if (typeof b === "number") { + return a + b; + } + // Handle other types like Point, [number, number], BubbleDataPoint if necessary + // For now, we'll assume they don't contribute to the sum or are handled elsewhere + return a; + }, 0 - ); + ) as number; const percentage = Math.round((context.parsed * 100) / total); return `${label}: ${value} (${percentage}%)`; }, @@ -91,7 +106,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/GeographicMap.tsx b/components/GeographicMap.tsx index 3cbef2d..257a887 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import "leaflet/dist/leaflet.css"; -import countryLookup from "country-code-lookup"; +import * as countryCoder from "@rapideditor/country-coder"; // Define types for country data interface CountryData { @@ -18,36 +18,17 @@ interface GeographicMapProps { height?: number; // Optional height for the container } -// Get country coordinates from the country-code-lookup package +// Get country coordinates from the @rapideditor/country-coder package const getCountryCoordinates = (): Record => { - // Initialize with some fallback coordinates for common countries that might be missing + // Initialize with some fallback coordinates for common countries const coordinates: Record = { - // These are just in case the lookup fails for common countries US: [37.0902, -95.7129], GB: [55.3781, -3.436], BA: [43.9159, 17.6791], }; - - try { - // Get all countries from the package - const allCountries = countryLookup.countries; - - // Map through all countries and extract coordinates - allCountries.forEach((country) => { - if (country.iso2 && country.latitude && country.longitude) { - coordinates[country.iso2] = [ - parseFloat(country.latitude), - parseFloat(country.longitude), - ]; - } - }); - - return coordinates; - } catch (error) { - // eslint-disable-next-line no-console - console.error("Error loading country coordinates:", error); - return coordinates; - } + // This function now primarily returns fallbacks. + // The actual fetching using @rapideditor/country-coder will be in the component's useEffect. + return coordinates; }; // Load coordinates once when module is imported @@ -79,44 +60,68 @@ 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) - // Only include countries with known coordinates - .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; - }) - .map(([code, count]) => ({ - code, - count, - coordinates: countryCoordinates[code] || - DEFAULT_COORDINATES[code] || [0, 0], - })); + const data: CountryData[] = Object.entries(countries || {}) + .map(([code, count]) => { + let countryCoords: [number, number] | undefined = + countryCoordinates[code] || DEFAULT_COORDINATES[code]; + + if (!countryCoords) { + const feature = countryCoder.feature(code); + if (feature && feature.geometry) { + if (feature.geometry.type === "Point") { + const [lon, lat] = feature.geometry.coordinates; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } else if ( + feature.geometry.type === "Polygon" && + feature.geometry.coordinates && + feature.geometry.coordinates[0] && + feature.geometry.coordinates[0][0] + ) { + // For Polygons, use the first coordinate of the first ring as a fallback representative point + const [lon, lat] = feature.geometry.coordinates[0][0]; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } else if ( + feature.geometry.type === "MultiPolygon" && + feature.geometry.coordinates && + feature.geometry.coordinates[0] && + feature.geometry.coordinates[0][0] && + feature.geometry.coordinates[0][0][0] + ) { + // For MultiPolygons, use the first coordinate of the first ring of the first polygon + const [lon, lat] = feature.geometry.coordinates[0][0][0]; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } + } + } + + if (countryCoords) { + return { + code, + count, + coordinates: countryCoords, + }; + } + return null; // Skip if no coordinates found + }) + .filter((item): item is CountryData => item !== null); - // 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([]); } }, [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/MetricCard.tsx b/components/MetricCard.tsx index 9388599..b8ff5c2 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; @@ -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/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..07ee60f --- /dev/null +++ b/components/Sidebar.tsx @@ -0,0 +1,357 @@ +"use client"; + +import React from "react"; // No hooks needed since state is now managed by parent +import Link from "next/link"; +import Image from "next/image"; +import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; + +// Icons for the sidebar +const DashboardIcon = () => ( + + + + + + +); + +const CompanyIcon = () => ( + + + +); + +const UsersIcon = () => ( + + + +); + +const SessionsIcon = () => ( + + + +); + +const LogoutIcon = () => ( + + + +); + +const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => ( + + {isExpanded ? ( + + ) : ( + + )} + +); + +export interface SidebarProps { + isExpanded: boolean; + onToggle: () => void; + isMobile?: boolean; // Add this property to indicate mobile viewport + onNavigate?: () => void; // Function to call when navigating to a new page +} + +interface NavItemProps { + href: string; + label: string; + icon: React.ReactNode; + isExpanded: boolean; + isActive: boolean; + onNavigate?: () => void; // Function to call when navigating to a new page +} + +const NavItem: React.FC = ({ + href, + label, + icon, + isExpanded, + isActive, + onNavigate, +}) => ( + { + if (onNavigate) { + onNavigate(); + } + }} + > + + {icon} + + {isExpanded ? ( + {label} + ) : ( +
+ {label} +
+ )} + +); + +export default function Sidebar({ + isExpanded, + onToggle, + isMobile = false, + onNavigate, +}: SidebarProps) { + const pathname = usePathname() || ""; + + const handleLogout = () => { + signOut({ callbackUrl: "/login" }); + }; + + return ( + <> + {/* Backdrop overlay when sidebar is expanded on mobile */} + {isExpanded && isMobile && ( +
+ )} + +
+
+ {/* Toggle button when sidebar is collapsed - above logo */} + {!isExpanded && ( +
+ +
+ )} + + {/* Logo section with link to homepage */} + +
+ LiveDash Logo +
+ {isExpanded && ( + + LiveDash + + )} + +
+ {isExpanded && ( +
+ +
+ )} + +
+ +
+
+ + ); +} diff --git a/components/TranscriptViewer.tsx b/components/TranscriptViewer.tsx index 0629513..3815b01 100644 --- a/components/TranscriptViewer.tsx +++ b/components/TranscriptViewer.tsx @@ -55,7 +55,7 @@ function formatTranscript(content: string): React.ReactNode[] { rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins components={{ 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 }) => ( ( (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; @@ -44,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,20 +80,23 @@ export default function WordCloud({ ) .padding(5) .rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees - .fontSize((d) => (d as any).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})`) + .attr( + "transform", + `translate(${dimensions.width / 2},${dimensions.height / 2})` + ) .selectAll("text") .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,17 +115,17 @@ 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 return () => { svg.selectAll("*").remove(); }; - }, [words, width, height, isClient]); + }, [words, dimensions, isClient]); if (!isClient) { return ( @@ -106,12 +136,21 @@ export default function WordCloud({ } return ( -
+
); diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 0000000..aea16f8 --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "@playwright/test"; + +test("has title", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test("get started link", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Click the get started link. + await page.getByRole("link", { name: "Get started" }).click(); + + // Expects page to have a heading with the name of Installation. + await expect( + page.getByRole("heading", { name: "Installation" }) + ).toBeVisible(); +}); 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/csvFetcher.ts b/lib/csvFetcher.ts index 8098d3a..86f606d 100644 --- a/lib/csvFetcher.ts +++ b/lib/csvFetcher.ts @@ -5,7 +5,7 @@ import ISO6391 from "iso-639-1"; import countries from "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); // This type is used internally for parsing the CSV records @@ -374,6 +374,62 @@ function isTruthyValue(value?: string): boolean { 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( url: string, username?: string, @@ -418,13 +474,6 @@ export async function fetchAndParseCsv( 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 return records.map((r) => ({ id: r.session_id, diff --git a/lib/localization.ts b/lib/localization.ts index 867a5fc..cfdb93a 100644 --- a/lib/localization.ts +++ b/lib/localization.ts @@ -2,7 +2,7 @@ import ISO6391 from "iso-639-1"; import countries from "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); /** diff --git a/lib/metrics.ts b/lib/metrics.ts index 56eeac9..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 ]); @@ -310,156 +324,266 @@ export function sessionMetrics( sessions: ChatSession[], companyConfig: CompanyConfig = {} ): MetricsResult { - const total = sessions.length; + const totalSessions = sessions.length; // Renamed from 'total' for clarity 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 = {}; - let escalated = 0, - forwarded = 0; - let totalSentiment = 0, - sentimentCount = 0; - let totalResponse = 0, - responseCount = 0; - let totalTokens = 0, - totalTokensEur = 0; + let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult + let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult - // For sentiment distribution - let sentimentPositive = 0, - sentimentNegative = 0, - sentimentNeutral = 0; + // Variables for calculations + const uniqueUserIds = new Set(); + let totalSessionDuration = 0; + let validSessionsForDuration = 0; + let totalResponseTime = 0; + let validSessionsForResponseTime = 0; + let sentimentPositiveCount = 0; + let sentimentNeutralCount = 0; + let sentimentNegativeCount = 0; + let totalTokens = 0; + let totalTokensEur = 0; + const wordCounts: { [key: string]: number } = {}; + let alerts = 0; - // Calculate total session duration in minutes - let totalDuration = 0; - let durationCount = 0; - - const wordCounts: { [key: string]: number } = {}; // For WordCloud - - sessions.forEach((s) => { - const day = s.startTime.toISOString().slice(0, 10); - byDay[day] = (byDay[day] || 0) + 1; - - 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 - - // Process token usage by day - if (s.tokens) { - tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens; + for (const session of sessions) { + // Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId + let identifierAdded = false; + if (session.ipAddress && session.ipAddress.trim() !== "") { + uniqueUserIds.add(session.ipAddress.trim()); + identifierAdded = true; + } + // Fallback to sessionId only if ipAddress was not usable and sessionId is valid + if ( + !identifierAdded && + session.sessionId && + session.sessionId.trim() !== "" + ) { + uniqueUserIds.add(session.sessionId.trim()); } - // Process token cost by day - if (s.tokensEur) { - tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur; - } + // Avg. Session Time + if (session.startTime && session.endTime) { + const startTimeMs = new Date(session.startTime).getTime(); + const endTimeMs = new Date(session.endTime).getTime(); - if (s.endTime) { - const duration = - (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes + if (isNaN(startTimeMs)) { + console.warn( + `[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}` + ); + } + if (isNaN(endTimeMs)) { + console.warn( + `[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}` + ); + } - // Sanity check: Only include sessions with reasonable durations (less than 24 hours) - const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes - if (duration > 0 && duration < MAX_REASONABLE_DURATION) { - totalDuration += duration; - durationCount++; + if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) { + const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta + // Use the absolute difference for duration, ensuring it's not negative. + // 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).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.` + // ); + } + // If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case. + + validSessionsForDuration++; // Count this session for averaging + } + } else { + if (!session.startTime) { + console.warn( + `[metrics] Missing startTime for session ${session.id || session.sessionId}` + ); + } + 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.` + ); } } - if (s.escalated) escalated++; - if (s.forwardedHr) forwarded++; + // Avg. Response Time + if ( + session.avgResponseTime !== undefined && + session.avgResponseTime !== null && + session.avgResponseTime >= 0 + ) { + totalResponseTime += session.avgResponseTime; + validSessionsForResponseTime++; + } - if (s.sentiment != null) { - totalSentiment += s.sentiment; - sentimentCount++; + // Escalated and Forwarded + if (session.escalated) escalatedCount++; + if (session.forwardedHr) forwardedHrCount++; - // Classify sentiment - if (s.sentiment > 0.3) { - sentimentPositive++; - } else if (s.sentiment < -0.3) { - sentimentNegative++; - } else { - sentimentNeutral++; + // Sentiment + if (session.sentiment !== undefined && session.sentiment !== null) { + // Example thresholds, adjust as needed + if (session.sentiment > 0.3) sentimentPositiveCount++; + else if (session.sentiment < -0.3) sentimentNegativeCount++; + else sentimentNeutralCount++; + } + + // Sentiment Alert Check + if ( + companyConfig.sentimentAlert !== undefined && + session.sentiment !== undefined && + session.sentiment !== null && + session.sentiment < companyConfig.sentimentAlert + ) { + alerts++; + } + + // Tokens + if (session.tokens !== undefined && session.tokens !== null) { + totalTokens += session.tokens; + } + if (session.tokensEur !== undefined && session.tokensEur !== null) { + totalTokensEur += session.tokensEur; + } + + // Daily metrics + const day = new Date(session.startTime).toISOString().split("T")[0]; + byDay[day] = (byDay[day] || 0) + 1; // Sessions per day + if (session.tokens !== undefined && session.tokens !== null) { + tokensByDay[day] = (tokensByDay[day] || 0) + session.tokens; + } + if (session.tokensEur !== undefined && session.tokensEur !== null) { + tokensCostByDay[day] = (tokensCostByDay[day] || 0) + session.tokensEur; + } + + // Category metrics + if (session.category) { + byCategory[session.category] = (byCategory[session.category] || 0) + 1; + } + + // Language metrics + if (session.language) { + byLanguage[session.language] = (byLanguage[session.language] || 0) + 1; + } + + // Country metrics + if (session.country) { + byCountry[session.country] = (byCountry[session.country] || 0) + 1; + } + + // Word Cloud Data (from initial message and transcript content) + const processTextForWordCloud = (text: string | undefined | null) => { + if (!text) return; + const words = text + .toLowerCase() + .replace(/[^\w\s'-]/gi, "") + .split(/\s+/); // Keep apostrophes and hyphens + for (const word of words) { + const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens + if ( + cleanedWord && + !stopWords.has(cleanedWord) && + cleanedWord.length > 2 + ) { + wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1; + } } - } - - if (s.avgResponseTime != null) { - totalResponse += s.avgResponseTime; - responseCount++; - } - - 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 - if (words) { - words.forEach((word) => { - const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); // Remove punctuation - if ( - cleanedWord && - !stopWords.has(cleanedWord) && - cleanedWord.length > 2 - ) { - // Check if not a stop word and length > 2 - wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1; - } - }); - } - } - }); - - // Now add sentiment alert logic: - let belowThreshold = 0; - const threshold = companyConfig.sentimentAlert ?? null; - if (threshold != null) { - for (const s of sessions) { - if (s.sentiment != null && s.sentiment < threshold) belowThreshold++; - } + }; + processTextForWordCloud(session.initialMsg); + processTextForWordCloud(session.transcriptContent); } - // Calculate average sessions per day - const dayCount = Object.keys(byDay).length; - const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0; - - // Calculate average session length + const uniqueUsers = uniqueUserIds.size; const avgSessionLength = - durationCount > 0 ? totalDuration / durationCount : null; + validSessionsForDuration > 0 + ? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes + : 0; + const avgResponseTime = + validSessionsForResponseTime > 0 + ? totalResponseTime / validSessionsForResponseTime + : 0; // in seconds - // 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 + .sort(([, a], [, b]) => b - a) + .slice(0, 50) // Top 50 words + .map(([text, value]) => ({ text, value })); + + // Calculate avgSessionsPerDay + const numDaysWithSessions = Object.keys(byDay).length; + 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, + // calculatedAvgSessionLength: avgSessionLength, + // }); return { - totalSessions: total, - avgSessionsPerDay, - avgSessionLength, - 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 - 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, - totalTokens, - totalTokensEur, - sentimentThreshold: threshold, - lastUpdated: Date.now(), // Add current timestamp - - // New metrics for enhanced dashboard - sentimentPositiveCount: sentimentPositive, - sentimentNeutralCount: sentimentNeutral, - sentimentNegativeCount: sentimentNegative, + totalSessions, + uniqueUsers, + avgSessionLength, // Corrected to match MetricsResult interface + avgResponseTime, // Corrected to match MetricsResult interface + escalatedCount, + forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount) + sentimentPositiveCount, + sentimentNeutralCount, + sentimentNegativeCount, + days: byDay, // Corrected to match MetricsResult interface (days) + categories: byCategory, // Corrected to match MetricsResult interface (categories) + languages: byLanguage, // Corrected to match MetricsResult interface (languages) + countries: byCountry, // Corrected to match MetricsResult interface (countries) tokensByDay, tokensCostByDay, - wordCloudData, // Added word cloud data + totalTokens, + totalTokensEur, + wordCloudData, + belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount) + avgSessionsPerDay, // Added to satisfy MetricsResult interface + // 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, }; } diff --git a/lib/types.ts b/lib/types.ts index e55da4f..383b8ec 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -131,6 +131,17 @@ 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 + + // Debug properties + totalSessionDuration?: number; + validSessionsForDuration?: number; } export interface ApiResponse { diff --git a/next.config.js b/next.config.js index 4fce1fd..690ec80 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ -/** @type {import('next').NextConfig} */ +/** + * @type {import('next').NextConfig} + **/ const nextConfig = { reactStrictMode: true, // Allow cross-origin requests from specific origins in development diff --git a/package-lock.json b/package-lock.json index 3197eb4..9c6ef9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "livedash-node", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "livedash-node", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@prisma/client": "^6.8.2", + "@rapideditor/country-coder": "^5.4.0", "@types/d3": "^7.4.3", "@types/d3-cloud": "^1.2.9", "@types/geojson": "^7946.0.16", @@ -17,7 +18,6 @@ "bcryptjs": "^3.0.2", "chart.js": "^4.0.0", "chartjs-plugin-annotation": "^3.1.0", - "country-code-lookup": "^0.1.3", "csv-parse": "^5.5.0", "d3": "^7.9.0", "d3-cloud": "^1.2.7", @@ -38,6 +38,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.27.0", + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.7", "@types/bcryptjs": "^2.4.2", "@types/node": "^22.15.21", @@ -49,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", @@ -1069,6 +1072,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/client": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz", @@ -1151,6 +1170,18 @@ "@prisma/debug": "6.8.2" } }, + "node_modules/@rapideditor/country-coder": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rapideditor/country-coder/-/country-coder-5.4.0.tgz", + "integrity": "sha512-5Kjy2hnDcJZnPpRXMrTNY+jTkwhenaniCD4K6oLdZHYnY0GSM8gIIkOmoB3UikVVcot5vhz6i0QVqbTSyxAvrQ==", + "license": "ISC", + "dependencies": { + "which-polygon": "^2.2.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1176,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", @@ -1834,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", @@ -3031,12 +3082,6 @@ "node": ">= 0.6" } }, - "node_modules/country-code-lookup": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/country-code-lookup/-/country-code-lookup-0.1.3.tgz", - "integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A==", - "license": "MIT" - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -4649,6 +4694,21 @@ "node": ">=12.20.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4800,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", @@ -5737,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", @@ -5753,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", @@ -6042,6 +6167,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lineclip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lineclip/-/lineclip-1.1.5.tgz", + "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", @@ -6117,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", @@ -6279,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", @@ -6358,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", @@ -7343,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", @@ -7362,6 +7711,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7462,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", @@ -7526,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", @@ -7547,6 +7948,21 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", + "license": "ISC" + }, + "node_modules/rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "license": "MIT", + "dependencies": { + "quickselect": "^1.0.1" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -8086,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", @@ -8657,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", @@ -8682,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", @@ -8972,6 +9421,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-polygon": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/which-polygon/-/which-polygon-2.2.1.tgz", + "integrity": "sha512-RlpWbqz12OMT0r2lEHk7IUPXz0hb1L/ZZsGushB2P2qxuBu1aq1+bcTfsLtfoRBYHsED6ruBMiwFaidvXZfQVw==", + "license": "ISC", + "dependencies": { + "lineclip": "^1.1.5", + "rbush": "^2.0.1" + } + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", diff --git a/package.json b/package.json index 94ff784..4da3bdb 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,11 @@ { "name": "livedash-node", - "version": "0.1.0", - "private": true, "type": "module", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint", - "lint:fix": "eslint --fix './**/*.{ts,tsx}'", - "format": "prettier --write .", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", - "prisma:seed": "node prisma/seed.mjs" - }, + "version": "0.2.0", + "private": true, "dependencies": { "@prisma/client": "^6.8.2", + "@rapideditor/country-coder": "^5.4.0", "@types/d3": "^7.4.3", "@types/d3-cloud": "^1.2.9", "@types/geojson": "^7946.0.16", @@ -24,7 +14,6 @@ "bcryptjs": "^3.0.2", "chart.js": "^4.0.0", "chartjs-plugin-annotation": "^3.1.0", - "country-code-lookup": "^0.1.3", "csv-parse": "^5.5.0", "d3": "^7.9.0", "d3-cloud": "^1.2.7", @@ -45,6 +34,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.27.0", + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.7", "@types/bcryptjs": "^2.4.2", "@types/node": "^22.15.21", @@ -56,11 +46,77 @@ "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", "typescript": "^5.0.0" + }, + "scripts": { + "build": "next build", + "dev": "next dev --turbopack", + "format": "npx prettier --write .", + "format:check": "npx prettier --check .", + "lint": "next lint", + "lint:fix": "npx eslint --fix", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "node prisma/seed.mjs", + "prisma:studio": "prisma studio", + "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, + "endOfLine": "auto", + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "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" + ] } } 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(); } diff --git a/pages/api/dashboard/sessions.ts b/pages/api/dashboard/sessions.ts index fd7a820..625cd11 100644 --- a/pages/api/dashboard/sessions.ts +++ b/pages/api/dashboard/sessions.ts @@ -7,6 +7,7 @@ import { SessionApiResponse, SessionQuery, } from "../../../lib/types"; +import { Prisma } from "@prisma/client"; export default async function handler( req: NextApiRequest, @@ -39,7 +40,7 @@ export default async function handler( const pageSize = Number(queryPageSize) || 10; try { - const whereClause: any = { companyId }; + const whereClause: Prisma.SessionWhereInput = { companyId }; // Search Term if ( @@ -48,11 +49,10 @@ export default async function handler( searchTerm.trim() !== "" ) { const searchConditions = [ - { id: { contains: searchTerm, mode: "insensitive" } }, - { sessionId: { contains: searchTerm, mode: "insensitive" } }, - { category: { contains: searchTerm, mode: "insensitive" } }, - { initialMsg: { contains: searchTerm, mode: "insensitive" } }, - { transcriptContent: { contains: searchTerm, mode: "insensitive" } }, + { id: { contains: searchTerm } }, + { category: { contains: searchTerm } }, + { initialMsg: { contains: searchTerm } }, + { transcriptContent: { contains: searchTerm } }, ]; whereClause.OR = searchConditions; } @@ -69,37 +69,59 @@ export default async function handler( // Date Range Filter if (startDate && typeof startDate === "string") { - if (!whereClause.startTime) whereClause.startTime = {}; - whereClause.startTime.gte = new Date(startDate); + whereClause.startTime = { + ...((whereClause.startTime as object) || {}), + gte: new Date(startDate), + }; } if (endDate && typeof endDate === "string") { - if (!whereClause.startTime) whereClause.startTime = {}; const inclusiveEndDate = new Date(endDate); inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); - whereClause.startTime.lt = inclusiveEndDate; + whereClause.startTime = { + ...((whereClause.startTime as object) || {}), + lt: inclusiveEndDate, + }; } // Sorting - let orderByClause: any = { startTime: "desc" }; - if (sortKey && typeof sortKey === "string") { - const order = - sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; - const validSortKeys: { [key: string]: string } = { - startTime: "startTime", - category: "category", - language: "language", - sentiment: "sentiment", - messagesSent: "messagesSent", - avgResponseTime: "avgResponseTime", - }; - if (validSortKeys[sortKey]) { - orderByClause = { [validSortKeys[sortKey]]: order }; - } + const validSortKeys: { [key: string]: string } = { + startTime: "startTime", + category: "category", + language: "language", + sentiment: "sentiment", + messagesSent: "messagesSent", + avgResponseTime: "avgResponseTime", + }; + + 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({ where: whereClause, - orderBy: orderByClause, + orderBy: orderByCondition, skip: (page - 1) * pageSize, take: pageSize, }); 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 0d4b813..86bd4dd 100644 --- a/pages/api/reset-password.ts +++ b/pages/api/reset-password.ts @@ -1,43 +1,63 @@ import { prisma } from "../../lib/prisma"; import bcrypt from "bcryptjs"; -import type { IncomingMessage, ServerResponse } from "http"; - -type NextApiRequest = IncomingMessage & { - body: { - token: string; - password: string; - [key: string]: unknown; - }; -}; - -type NextApiResponse = ServerResponse & { - status: (code: number) => NextApiResponse; - json: (data: Record) => void; - end: () => void; -}; +import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types export default async function handler( - req: NextApiRequest, - res: NextApiResponse + req: NextApiRequest, // Use official NextApiRequest + res: NextApiResponse // Use official NextApiResponse ) { - if (req.method !== "POST") return res.status(405).end(); - const { token, password } = req.body; - const user = await prisma.user.findFirst({ - where: { - resetToken: token, - resetTokenExpiry: { gte: new Date() }, - }, - }); - if (!user) return res.status(400).json({ error: "Invalid or expired token" }); + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405 + return res.status(405).end(`Method ${req.method} Not Allowed`); + } - const hash = await bcrypt.hash(password, 10); - await prisma.user.update({ - where: { id: user.id }, - data: { - password: hash, - resetToken: null, - resetTokenExpiry: null, - }, - }); - res.status(200).end(); + // It's good practice to explicitly type the expected body for clarity and safety + const { token, password } = req.body as { token?: string; password?: string }; + + if (!token || !password) { + return res.status(400).json({ error: "Token and password are required." }); + } + + if (password.length < 8) { + // Example: Add password complexity rule + return res + .status(400) + .json({ error: "Password must be at least 8 characters long." }); + } + + try { + const user = await prisma.user.findFirst({ + where: { + resetToken: token, + resetTokenExpiry: { gte: new Date() }, + }, + }); + + if (!user) { + return res.status(400).json({ + error: "Invalid or expired token. Please request a new password reset.", + }); + } + + const hash = await bcrypt.hash(password, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hash, + resetToken: null, + resetTokenExpiry: null, + }, + }); + + // Instead of just res.status(200).end(), send a success message + return res + .status(200) + .json({ message: "Password has been reset successfully." }); + } 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.", + }); + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e6af932 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env.development') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run start", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..3fee590 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,489 @@ +import { test, expect, type Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("https://demo.playwright.dev/todomvc"); +}); + +const TODO_ITEMS = [ + "buy some cheese", + "feed the cat", + "book a doctors appointment", +] as const; + +test.describe("New Todo", () => { + test("should allow me to add todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Make sure the list only has one todo item. + await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + + // Make sure the list now has two todo items. + await expect(page.getByTestId("todo-title")).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1], + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test("should clear text input field when an item is added", async ({ + page, + }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test("should append new items to the bottom of the list", async ({ + page, + }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + // Check test using different methods. + await expect(page.getByText("3 items left")).toBeVisible(); + await expect(todoCount).toHaveText("3 items left"); + await expect(todoCount).toContainText("3"); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe("Mark all as completed", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should allow me to mark all items as completed", async ({ page }) => { + // Complete all todos. + await page.getByLabel("Mark all as complete").check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId("todo-item")).toHaveClass([ + "completed", + "completed", + "completed", + ]); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test("should allow me to clear the complete state of all items", async ({ + page, + }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]); + }); + + test("complete all checkbox should update state when items are completed / cleared", async ({ + page, + }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe("Item", () => { + test("should allow me to mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + // Check first item. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").check(); + await expect(firstTodo).toHaveClass("completed"); + + // Check second item. + const secondTodo = page.getByTestId("todo-item").nth(1); + await expect(secondTodo).not.toHaveClass("completed"); + await secondTodo.getByRole("checkbox").check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).toHaveClass("completed"); + }); + + test("should allow me to un-mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const firstTodo = page.getByTestId("todo-item").nth(0); + const secondTodo = page.getByTestId("todo-item").nth(1); + const firstTodoCheckbox = firstTodo.getByRole("checkbox"); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test("should allow me to edit an item", async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId("todo-item"); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue( + TODO_ITEMS[1] + ); + await secondTodo + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter"); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); +}); + +test.describe("Editing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should hide other controls when editing", async ({ page }) => { + const todoItem = page.getByTestId("todo-item").nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole("checkbox")).not.toBeVisible(); + await expect( + todoItem.locator("label", { + hasText: TODO_ITEMS[1], + }) + ).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should save edits on blur", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .dispatchEvent("blur"); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should trim entered text", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill(" buy some sausages "); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Enter"); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should remove the item if an empty text string was entered", async ({ + page, + }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill(""); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Enter"); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should cancel edits on escape", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Escape"); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe("Counter", () => { + test("should display the current number of todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + await expect(todoCount).toContainText("1"); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + await expect(todoCount).toContainText("2"); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe("Clear completed button", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test("should display the correct text", async ({ page }) => { + await page.locator(".todo-list li .toggle").first().check(); + await expect( + page.getByRole("button", { name: "Clear completed" }) + ).toBeVisible(); + }); + + test("should remove completed items when clicked", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).getByRole("checkbox").check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should be hidden when there are no items that are completed", async ({ + page, + }) => { + await page.locator(".todo-list li .toggle").first().check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect( + page.getByRole("button", { name: "Clear completed" }) + ).toBeHidden(); + }); +}); + +test.describe("Persistence", () => { + test("should persist its data", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const todoItems = page.getByTestId("todo-item"); + const firstTodoCheck = todoItems.nth(0).getByRole("checkbox"); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + }); +}); + +test.describe("Routing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test("should allow me to display active items", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should respect the back button", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step("Showing all items", async () => { + await page.getByRole("link", { name: "All" }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step("Showing active items", async () => { + await page.getByRole("link", { name: "Active" }).click(); + }); + + await test.step("Showing completed items", async () => { + await page.getByRole("link", { name: "Completed" }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test("should allow me to display completed items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Completed" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(1); + }); + + test("should allow me to display all items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await page.getByRole("link", { name: "Completed" }).click(); + await page.getByRole("link", { name: "All" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(3); + }); + + test("should highlight the currently applied filter", async ({ page }) => { + await expect(page.getByRole("link", { name: "All" })).toHaveClass( + "selected" + ); + + //create locators for active and completed links + const activeLink = page.getByRole("link", { name: "Active" }); + const completedLink = page.getByRole("link", { name: "Completed" }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass("selected"); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass("selected"); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage["react-todos"]).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage( + page: Page, + expected: number +) { + return await page.waitForFunction((e) => { + return ( + JSON.parse(localStorage["react-todos"]).filter( + (todo: any) => todo.completed + ).length === e + ); + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction((t) => { + return JSON.parse(localStorage["react-todos"]) + .map((todo: any) => todo.title) + .includes(t); + }, title); +} diff --git a/tsconfig.json b/tsconfig.json index 19a89ba..6a25500 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,36 +1,36 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noImplicitAny": false, // Allow implicit any types - "forceConsistentCasingInFileNames": true, - "noEmit": true, "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true, + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "noEmit": true, + "noImplicitAny": false, // Allow implicit any types + "paths": { + "@/*": ["./*"] + }, "plugins": [ { "name": "next" } ], - "paths": { - "@/*": ["./*"] - }, - "strictNullChecks": true + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "target": "es5" }, + "exclude": ["node_modules"], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "components/SessionDetails.tsx.bak" - ], - "exclude": ["node_modules"] + ] }