mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 21:12:08 +01:00
Compare commits
16 Commits
a17b66c078
...
a360f461ab
| Author | SHA1 | Date | |
|---|---|---|---|
| a360f461ab | |||
| bbcdff0ffc | |||
| 940b416563 | |||
| cb86d26786 | |||
| a265f3236c | |||
| be63dba540 | |||
| 01f4dd60f9 | |||
|
9fad25e5f9
|
|||
|
13d0f8ee8d
|
|||
|
303226e3a9
|
|||
|
cbbdc8a1dc
|
|||
|
8dcb892ae9
|
|||
|
f005b2ec0a
|
|||
|
ed6e5b0c36
|
|||
|
efb5261c7d
|
|||
|
e3134aa451
|
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* @kjanat
|
||||||
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@ -9,18 +9,30 @@ updates:
|
|||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
day: "tuesday"
|
||||||
|
time: "03:00"
|
||||||
|
timezone: "Europe/Amsterdam"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
day: "tuesday"
|
||||||
|
time: "03:00"
|
||||||
|
timezone: "Europe/Amsterdam"
|
||||||
|
|
||||||
- package-ecosystem: "docker" # See documentation for possible values
|
- package-ecosystem: "docker" # See documentation for possible values
|
||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
day: "tuesday"
|
||||||
|
time: "03:00"
|
||||||
|
timezone: "Europe/Amsterdam"
|
||||||
|
|
||||||
- package-ecosystem: "docker-compose" # See documentation for possible values
|
- package-ecosystem: "docker-compose" # See documentation for possible values
|
||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
day: "tuesday"
|
||||||
|
time: "03:00"
|
||||||
|
timezone: "Europe/Amsterdam"
|
||||||
|
|||||||
29
.github/workflows/playwright.yml
vendored
Normal file
29
.github/workflows/playwright.yml
vendored
Normal file
@ -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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -255,3 +255,9 @@ Thumbs.db
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"semi": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"endOfLine": "auto"
|
|
||||||
}
|
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -2,6 +2,7 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"prisma.prisma",
|
"prisma.prisma",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"rvest.vs-code-prettier-eslint"
|
"rvest.vs-code-prettier-eslint",
|
||||||
|
"ms-playwright.playwright"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
115
README.md
Normal file
115
README.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# LiveDash-Node
|
||||||
|
|
||||||
|
A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics.
|
||||||
|
|
||||||
|
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000)
|
||||||
|
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB)
|
||||||
|
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6)
|
||||||
|
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748)
|
||||||
|
.*%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 <http://localhost:3000>
|
||||||
|
|
||||||
|
## 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/)
|
||||||
125
TODO.md
125
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
|
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
||||||
|
- Investigate integrating real data sources with server-side analytics
|
||||||
- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events).
|
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
||||||
- [ ] **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.
|
|
||||||
|
|
||||||
## Component Specific
|
## 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`).
|
- [ ] **Implement robust emailing of temporary passwords**
|
||||||
- [ ] **`components/GeographicMap.tsx`:** Check if `GeographicMap.tsx.bak` is still needed or can be removed.
|
- File: `pages/api/dashboard/users.ts`
|
||||||
- [ ] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting.
|
- Set up proper email service integration
|
||||||
- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords.
|
|
||||||
|
- [x] **Session page improvements** ✅
|
||||||
|
- File: `app/dashboard/sessions/page.tsx`
|
||||||
|
- Implemented pagination, advanced filtering, and sorting
|
||||||
|
|
||||||
## File Cleanup
|
## 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
|
||||||
|
|||||||
183
app/dashboard/company/page.tsx
Normal file
183
app/dashboard/company/page.tsx
Normal file
@ -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<Company | null>(null);
|
||||||
|
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
|
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||||
|
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||||
|
const [sentimentThreshold, setSentimentThreshold] = useState<string>("");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
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 <div className="text-center py-10">Loading settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin access
|
||||||
|
if (session?.user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
|
<p>You don't have permission to view company settings.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
Company Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid gap-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">
|
||||||
|
CSV Data Source URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvUrl}
|
||||||
|
onChange={(e) => setCsvUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/data.csv"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">CSV Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvUsername}
|
||||||
|
onChange={(e) => setCsvUsername(e.target.value)}
|
||||||
|
placeholder="Username for CSV access (if needed)"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">CSV Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvPassword}
|
||||||
|
onChange={(e) => setCsvPassword(e.target.value)}
|
||||||
|
placeholder="Password will be updated only if provided"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Leave blank to keep current password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">
|
||||||
|
Sentiment Alert Threshold
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={sentimentThreshold}
|
||||||
|
onChange={(e) => setSentimentThreshold(e.target.value)}
|
||||||
|
placeholder="Threshold value (0-100)"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Percentage of negative sentiment sessions to trigger alert (0-100)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/dashboard/layout.tsx
Normal file
82
app/dashboard/layout.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">Redirecting to login...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">Loading session...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100">
|
||||||
|
<Sidebar
|
||||||
|
isExpanded={isSidebarExpanded}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onToggle={toggleSidebarHandler}
|
||||||
|
onNavigate={collapseSidebar}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
||||||
|
${
|
||||||
|
isSidebarExpanded
|
||||||
|
? "pl-4 sm:pl-6 md:pl-10"
|
||||||
|
: "pl-20 sm:pl-20 md:pl-6"
|
||||||
|
}
|
||||||
|
sm:pr-6 md:py-6 md:pr-10`}
|
||||||
|
>
|
||||||
|
{/* <div className="w-full mx-auto">{children}</div> */}
|
||||||
|
<div className="max-w-7xl mx-auto">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
app/dashboard/overview/page.tsx
Normal file
429
app/dashboard/overview/page.tsx
Normal file
@ -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<MetricsResult | null>(null);
|
||||||
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
|
const [, setLoading] = useState<boolean>(false);
|
||||||
|
const [refreshing, setRefreshing] = useState<boolean>(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 <div className="text-center py-10">Loading session...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return <div className="text-center py-10">Redirecting to login...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metrics || !company) {
|
||||||
|
return <div className="text-center py-10">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<WelcomeBanner companyName={company.name} />
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
||||||
|
<p className="text-slate-500 mt-1">
|
||||||
|
Dashboard updated{" "}
|
||||||
|
<span className="font-medium text-slate-600">
|
||||||
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
||||||
|
<button
|
||||||
|
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing || isAuditor}
|
||||||
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Refreshing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Refresh Data"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
title="Total Sessions"
|
||||||
|
value={metrics.totalSessions}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.sessionTrend ?? 0,
|
||||||
|
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Unique Users"
|
||||||
|
value={metrics.uniqueUsers}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.usersTrend ?? 0,
|
||||||
|
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Session Time"
|
||||||
|
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.avgSessionTimeTrend ?? 0,
|
||||||
|
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Response Time"
|
||||||
|
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.avgResponseTimeTrend ?? 0,
|
||||||
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Sessions Over Time
|
||||||
|
</h3>
|
||||||
|
<SessionsLineChart sessionsPerDay={metrics.days} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Conversation Sentiment
|
||||||
|
</h3>
|
||||||
|
<DonutChart
|
||||||
|
data={{
|
||||||
|
labels: ["Positive", "Neutral", "Negative"],
|
||||||
|
values: [
|
||||||
|
getSentimentData().positive,
|
||||||
|
getSentimentData().neutral,
|
||||||
|
getSentimentData().negative,
|
||||||
|
],
|
||||||
|
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
|
||||||
|
}}
|
||||||
|
centerText={{
|
||||||
|
title: "Total",
|
||||||
|
value: metrics.totalSessions,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Sessions by Category
|
||||||
|
</h3>
|
||||||
|
<CategoriesBarChart categories={metrics.categories || {}} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Languages Used
|
||||||
|
</h3>
|
||||||
|
<LanguagePieChart languages={metrics.languages || {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Geographic Distribution
|
||||||
|
</h3>
|
||||||
|
<GeographicMap countries={getCountryData()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Common Topics
|
||||||
|
</h3>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<WordCloud words={getWordCloudData()} width={500} height={400} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Response Time Distribution
|
||||||
|
</h3>
|
||||||
|
<ResponseTimeDistribution
|
||||||
|
data={getResponseTimeData()}
|
||||||
|
average={metrics.avgResponseTime || 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800">
|
||||||
|
Token Usage & Costs
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto">
|
||||||
|
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
||||||
|
<span className="font-semibold mr-1">Total Tokens:</span>
|
||||||
|
{metrics.totalTokens?.toLocaleString() || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
||||||
|
<span className="font-semibold mr-1">Total Cost:</span>€
|
||||||
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TokenUsageChart tokenData={getTokenData()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our exported component
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardContent />;
|
||||||
|
}
|
||||||
@ -1,440 +1,104 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { FC } from "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";
|
|
||||||
|
|
||||||
// Safely wrapped component with useSession
|
const DashboardPage: FC = () => {
|
||||||
function DashboardContent() {
|
const { data: session, status } = useSession();
|
||||||
const { data: session, status } = useSession(); // Add status from useSession
|
const router = useRouter();
|
||||||
const router = useRouter(); // Initialize useRouter
|
const [loading, setLoading] = useState(true);
|
||||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
|
||||||
const [, setLoading] = useState<boolean>(false);
|
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const isAdmin = session?.user?.role === "admin";
|
|
||||||
const isAuditor = session?.user?.role === "auditor";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Once session is loaded, redirect appropriately
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.push("/login");
|
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 (loading) {
|
||||||
if (status === "authenticated") {
|
return (
|
||||||
const fetchData = async () => {
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
setLoading(true);
|
<div className="text-center">
|
||||||
const res = await fetch("/api/dashboard/metrics");
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
|
||||||
const data = await res.json();
|
<p className="text-lg text-gray-600">Loading dashboard...</p>
|
||||||
setMetrics(data.metrics);
|
</div>
|
||||||
setCompany(data.company);
|
</div>
|
||||||
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 <div className="text-center py-10">Loading session...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
return <div className="text-center py-10">Redirecting to login...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metrics || !company) {
|
|
||||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<string, number>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<WelcomeBanner companyName={company.name} />
|
<div className="bg-white rounded-xl shadow p-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<p className="text-slate-500 mt-1">
|
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
Dashboard updated{" "}
|
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
|
||||||
<span className="font-medium text-slate-600">
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
View your chat session metrics and analytics
|
||||||
</span>
|
</p>
|
||||||
</p>
|
<button
|
||||||
</div>
|
onClick={() => router.push("/dashboard/overview")}
|
||||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
<button
|
|
||||||
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing || isAuditor}
|
|
||||||
>
|
|
||||||
{refreshing ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Refresh Data
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
|
||||||
onClick={() => signOut()}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
View Analytics
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
title="Total Sessions"
|
|
||||||
value={metrics.totalSessions.toLocaleString()}
|
|
||||||
icon="💬"
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Sessions/Day"
|
|
||||||
value={metrics.avgSessionsPerDay?.toFixed(1) || 0}
|
|
||||||
icon="📊"
|
|
||||||
trend={{ value: 5.2, label: "vs last week" }}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Session Time"
|
|
||||||
value={
|
|
||||||
metrics.avgSessionLength
|
|
||||||
? `${metrics.avgSessionLength.toFixed(1)} min`
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
icon="⏱️"
|
|
||||||
trend={{ value: -2.1, label: "vs last week", isPositive: false }}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Response Time"
|
|
||||||
value={
|
|
||||||
metrics.avgResponseTime
|
|
||||||
? `${metrics.avgResponseTime.toFixed(2)}s`
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
icon="⚡"
|
|
||||||
trend={{ value: -1.8, label: "vs last week", isPositive: true }}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-1">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Sentiment Distribution
|
|
||||||
</h3>
|
|
||||||
<DonutChart
|
|
||||||
data={{
|
|
||||||
labels: ["Positive", "Neutral", "Negative"],
|
|
||||||
values: [
|
|
||||||
getSentimentData().positive,
|
|
||||||
getSentimentData().neutral,
|
|
||||||
getSentimentData().negative,
|
|
||||||
],
|
|
||||||
colors: [
|
|
||||||
"rgba(34, 197, 94, 0.8)",
|
|
||||||
"rgba(249, 115, 22, 0.8)",
|
|
||||||
"rgba(239, 68, 68, 0.8)",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
centerText={{
|
|
||||||
title: "Overall",
|
|
||||||
value: `${((getSentimentData().positive / (getSentimentData().positive + getSentimentData().neutral + getSentimentData().negative)) * 100).toFixed(0)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Case Handling Statistics
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
title="Escalation Rate"
|
|
||||||
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
|
||||||
icon="⚠️"
|
|
||||||
variant={
|
|
||||||
(metrics.escalatedCount || 0) > metrics.totalSessions * 0.1
|
|
||||||
? "warning"
|
|
||||||
: "success"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="HR Forwarded"
|
|
||||||
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
|
||||||
icon="👥"
|
|
||||||
variant={
|
|
||||||
(metrics.forwardedCount || 0) > metrics.totalSessions * 0.05
|
|
||||||
? "warning"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Resolved Rate"
|
|
||||||
value={`${(((metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)) / metrics.totalSessions) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)} sessions resolved`}
|
|
||||||
icon="✅"
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Sessions by Day
|
|
||||||
</h3>
|
|
||||||
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Top Categories
|
|
||||||
</h3>
|
|
||||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow overflow-hidden">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Transcript Word Cloud
|
|
||||||
</h3>
|
|
||||||
<WordCloud words={getWordCloudData()} width={400} height={300} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Geographic Distribution
|
|
||||||
</h3>
|
|
||||||
<GeographicMap countries={getCountryData()} height={300} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Response Time Distribution
|
|
||||||
</h3>
|
|
||||||
<ResponseTimeDistribution
|
|
||||||
responseTimes={getResponseTimeData()}
|
|
||||||
targetResponseTime={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">Languages</h3>
|
|
||||||
<LanguagePieChart languages={metrics.languages || {}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800">
|
|
||||||
Token Usage & Costs
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
|
||||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
|
||||||
{metrics.totalTokens?.toLocaleString() || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
|
||||||
<span className="font-semibold mr-1">Total Cost:</span>€
|
|
||||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TokenUsageChart tokenData={getTokenData()} />
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<DashboardSettings company={company} session={session} />
|
|
||||||
<UserManagement session={session} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our exported component
|
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
export default function DashboardPage() {
|
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
||||||
return (
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
Browse and analyze conversation sessions
|
||||||
<div className="max-w-7xl mx-auto">
|
</p>
|
||||||
<DashboardContent />
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/sessions")}
|
||||||
|
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
View Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session?.user?.role === "admin" && (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h2 className="text-lg font-semibold text-purple-700">
|
||||||
|
Company Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Configure company settings and integrations
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/company")}
|
||||||
|
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Manage Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.user?.role === "admin" && (
|
||||||
|
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h2 className="text-lg font-semibold text-amber-700">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Invite and manage user accounts
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/users")}
|
||||||
|
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Manage Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function SessionViewPage() {
|
|||||||
|
|
||||||
if (status === "authenticated" && id) {
|
if (status === "authenticated" && id) {
|
||||||
const fetchSession = async () => {
|
const fetchSession = async () => {
|
||||||
if (!session) setLoading(true);
|
setLoading(true); // Always set loading before fetch
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||||
@ -52,7 +52,7 @@ export default function SessionViewPage() {
|
|||||||
setError("Session ID is missing.");
|
setError("Session ID is missing.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [id, status, router, session]);
|
}, [id, status, router]); // session removed from dependencies
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export default function SessionsPage() {
|
|||||||
// Pagination states
|
// Pagination states
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
const [pageSize, setPageSize] = useState(10); // Or make this configurable
|
const [pageSize, setPageSize] = useState(10); // Or make this configurable
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -283,8 +283,12 @@ export default function SessionsPage() {
|
|||||||
Session ID: {session.sessionId || session.id}
|
Session ID: {session.sessionId || session.id}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mb-1">
|
<p className="text-sm text-gray-500 mb-1">
|
||||||
Start Time: {new Date(session.startTime).toLocaleString()}
|
Start Time{/* (Local) */}:{" "}
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
{/* <p className="text-xs text-gray-400 mb-1">
|
||||||
|
Start Time (Raw API): {session.startTime.toString()}
|
||||||
|
</p> */}
|
||||||
{session.category && (
|
{session.category && (
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Category:{" "}
|
Category:{" "}
|
||||||
|
|||||||
212
app/dashboard/users/page.tsx
Normal file
212
app/dashboard/users/page.tsx
Normal file
@ -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<UserItem[]>([]);
|
||||||
|
const [email, setEmail] = useState<string>("");
|
||||||
|
const [role, setRole] = useState<string>("user");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
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 <div className="text-center py-10">Loading users...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin access
|
||||||
|
if (session?.user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
|
<p>You don't have permission to view user management.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
inviteUser();
|
||||||
|
}}
|
||||||
|
autoComplete="off" // Disable autofill for the form
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="off" // Disable autofill for this input
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">Role</label>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="auditor">Auditor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
||||||
|
>
|
||||||
|
Invite User
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-6 py-4 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
No users found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<span
|
||||||
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
|
user.role === "admin"
|
||||||
|
? "bg-purple-100 text-purple-800"
|
||||||
|
: user.role === "auditor"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-green-100 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{/* For future: Add actions like edit, delete, etc. */}
|
||||||
|
<span className="text-gray-400">
|
||||||
|
No actions available
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1 @@
|
|||||||
body {
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
background: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|||||||
@ -21,9 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="bg-gray-100 min-h-screen font-sans">
|
<body className="bg-gray-100 min-h-screen font-sans">
|
||||||
<Providers>
|
<Providers>{children}</Providers>
|
||||||
<div className="max-w-5xl mx-auto py-8">{children}</div>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import Chart from "chart.js/auto";
|
import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
|
||||||
|
|
||||||
interface DonutChartProps {
|
interface DonutChartProps {
|
||||||
data: {
|
data: {
|
||||||
@ -77,9 +77,24 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
|||||||
const label = context.label || "";
|
const label = context.label || "";
|
||||||
const value = context.formattedValue;
|
const value = context.formattedValue;
|
||||||
const total = context.chart.data.datasets[0].data.reduce(
|
const total = context.chart.data.datasets[0].data.reduce(
|
||||||
(a: number, b: any) => a + (typeof b === "number" ? b : 0),
|
(
|
||||||
|
a: number,
|
||||||
|
b:
|
||||||
|
| number
|
||||||
|
| 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
|
0
|
||||||
);
|
) as number;
|
||||||
const percentage = Math.round((context.parsed * 100) / total);
|
const percentage = Math.round((context.parsed * 100) / total);
|
||||||
return `${label}: ${value} (${percentage}%)`;
|
return `${label}: ${value} (${percentage}%)`;
|
||||||
},
|
},
|
||||||
@ -91,7 +106,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "centerText",
|
id: "centerText",
|
||||||
beforeDraw: function (chart: any) {
|
beforeDraw: function (chart: Chart<"doughnut">) {
|
||||||
const height = chart.height;
|
const height = chart.height;
|
||||||
const ctx = chart.ctx;
|
const ctx = chart.ctx;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import countryLookup from "country-code-lookup";
|
import * as countryCoder from "@rapideditor/country-coder";
|
||||||
|
|
||||||
// Define types for country data
|
// Define types for country data
|
||||||
interface CountryData {
|
interface CountryData {
|
||||||
@ -18,36 +18,17 @@ interface GeographicMapProps {
|
|||||||
height?: number; // Optional height for the container
|
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<string, [number, number]> => {
|
const getCountryCoordinates = (): Record<string, [number, number]> => {
|
||||||
// Initialize with some fallback coordinates for common countries that might be missing
|
// Initialize with some fallback coordinates for common countries
|
||||||
const coordinates: Record<string, [number, number]> = {
|
const coordinates: Record<string, [number, number]> = {
|
||||||
// These are just in case the lookup fails for common countries
|
|
||||||
US: [37.0902, -95.7129],
|
US: [37.0902, -95.7129],
|
||||||
GB: [55.3781, -3.436],
|
GB: [55.3781, -3.436],
|
||||||
BA: [43.9159, 17.6791],
|
BA: [43.9159, 17.6791],
|
||||||
};
|
};
|
||||||
|
// This function now primarily returns fallbacks.
|
||||||
try {
|
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||||
// Get all countries from the package
|
return coordinates;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load coordinates once when module is imported
|
// 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
|
// Process country data when client is ready and dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isClient) return;
|
if (!isClient || !countries) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate CountryData array for the Map component
|
// Generate CountryData array for the Map component
|
||||||
const data: CountryData[] = Object.entries(countries)
|
const data: CountryData[] = Object.entries(countries || {})
|
||||||
// Only include countries with known coordinates
|
.map(([code, count]) => {
|
||||||
.filter(([code]) => {
|
let countryCoords: [number, number] | undefined =
|
||||||
// If no coordinates found, log to help with debugging
|
countryCoordinates[code] || DEFAULT_COORDINATES[code];
|
||||||
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
|
|
||||||
// eslint-disable-next-line no-console
|
if (!countryCoords) {
|
||||||
console.warn(`Missing coordinates for country code: ${code}`);
|
const feature = countryCoder.feature(code);
|
||||||
return false;
|
if (feature && feature.geometry) {
|
||||||
}
|
if (feature.geometry.type === "Point") {
|
||||||
return true;
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
})
|
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
.map(([code, count]) => ({
|
} else if (
|
||||||
code,
|
feature.geometry.type === "Polygon" &&
|
||||||
count,
|
feature.geometry.coordinates &&
|
||||||
coordinates: countryCoordinates[code] ||
|
feature.geometry.coordinates[0] &&
|
||||||
DEFAULT_COORDINATES[code] || [0, 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(
|
console.log(
|
||||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||||
);
|
);
|
||||||
|
|
||||||
setCountryData(data);
|
setCountryData(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error processing geographic data:", error);
|
console.error("Error processing geographic data:", error);
|
||||||
setCountryData([]);
|
setCountryData([]);
|
||||||
}
|
}
|
||||||
}, [countries, countryCoordinates, isClient]);
|
}, [countries, countryCoordinates, isClient]);
|
||||||
|
|
||||||
// Find the max count for scaling circles - handle empty countries object
|
// Find the max count for scaling circles - handle empty or null countries object
|
||||||
const countryValues = Object.values(countries);
|
const countryValues = countries ? Object.values(countries) : [];
|
||||||
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
|
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
|
||||||
|
|
||||||
// Show loading state during SSR or until client-side rendering takes over
|
// Show loading state during SSR or until client-side rendering takes over
|
||||||
|
|||||||
@ -4,7 +4,7 @@ interface MetricCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
value: string | number | null | undefined;
|
value: string | number | null | undefined;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: React.ReactNode;
|
||||||
trend?: {
|
trend?: {
|
||||||
value: number;
|
value: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -67,9 +67,6 @@ export default function MetricCard({
|
|||||||
>
|
>
|
||||||
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
||||||
{Math.abs(trend.value).toFixed(1)}%
|
{Math.abs(trend.value).toFixed(1)}%
|
||||||
{trend.label && (
|
|
||||||
<span className="text-gray-500 ml-1">{trend.label}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,28 +7,30 @@ import annotationPlugin from "chartjs-plugin-annotation";
|
|||||||
Chart.register(annotationPlugin);
|
Chart.register(annotationPlugin);
|
||||||
|
|
||||||
interface ResponseTimeDistributionProps {
|
interface ResponseTimeDistributionProps {
|
||||||
responseTimes: number[];
|
data: number[];
|
||||||
|
average: number;
|
||||||
targetResponseTime?: number;
|
targetResponseTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResponseTimeDistribution({
|
export default function ResponseTimeDistribution({
|
||||||
responseTimes,
|
data,
|
||||||
|
average,
|
||||||
targetResponseTime,
|
targetResponseTime,
|
||||||
}: ResponseTimeDistributionProps) {
|
}: ResponseTimeDistributionProps) {
|
||||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current || !responseTimes.length) return;
|
if (!ref.current || !data || !data.length) return;
|
||||||
|
|
||||||
const ctx = ref.current.getContext("2d");
|
const ctx = ref.current.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
// 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);
|
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||||
|
|
||||||
// Count responses in each bin
|
// Count responses in each bin
|
||||||
responseTimes.forEach((time) => {
|
data.forEach((time) => {
|
||||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||||
bins[binIndex]++;
|
bins[binIndex]++;
|
||||||
});
|
});
|
||||||
@ -63,26 +65,40 @@ export default function ResponseTimeDistribution({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
annotation: targetResponseTime
|
annotation: {
|
||||||
? {
|
annotations: {
|
||||||
annotations: {
|
averageLine: {
|
||||||
targetLine: {
|
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",
|
type: "line",
|
||||||
yMin: 0,
|
yMin: 0,
|
||||||
yMax: Math.max(...bins),
|
yMax: Math.max(...bins),
|
||||||
xMin: targetResponseTime,
|
xMin: targetResponseTime,
|
||||||
xMax: targetResponseTime,
|
xMax: targetResponseTime,
|
||||||
borderColor: "rgba(75, 192, 192, 1)",
|
borderColor: "rgba(75, 192, 192, 0.7)",
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
label: {
|
label: {
|
||||||
display: true,
|
display: true,
|
||||||
content: "Target",
|
content: "Target",
|
||||||
position: "start",
|
position: "end",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
},
|
: undefined,
|
||||||
}
|
},
|
||||||
: undefined,
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
@ -103,7 +119,7 @@ export default function ResponseTimeDistribution({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => chart.destroy();
|
return () => chart.destroy();
|
||||||
}, [responseTimes, targetResponseTime]);
|
}, [data, average, targetResponseTime]);
|
||||||
|
|
||||||
return <canvas ref={ref} height={180} />;
|
return <canvas ref={ref} height={180} />;
|
||||||
}
|
}
|
||||||
|
|||||||
357
components/Sidebar.tsx
Normal file
357
components/Sidebar.tsx
Normal file
@ -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 = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompanyIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UsersIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SessionsIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LogoutIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-gray-600 group-hover:text-sky-700 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
) : (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4 6h16M4 12h16M4 18h7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<NavItemProps> = ({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
isExpanded,
|
||||||
|
isActive,
|
||||||
|
onNavigate,
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
||||||
|
isActive
|
||||||
|
? "bg-sky-100 text-sky-800 font-medium"
|
||||||
|
: "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
|
text-white bg-gray-800 z-50
|
||||||
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300"
|
||||||
|
onClick={onToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`fixed md:relative h-screen bg-white shadow-md transition-all duration-300
|
||||||
|
${
|
||||||
|
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
||||||
|
} flex flex-col overflow-visible z-20`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center pt-5 pb-3 border-b relative">
|
||||||
|
{/* Toggle button when sidebar is collapsed - above logo */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // Prevent any navigation
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||||
|
title="Expand sidebar"
|
||||||
|
>
|
||||||
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo section with link to homepage */}
|
||||||
|
<Link href="/" className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`relative ${isExpanded ? "w-16" : "w-10 mt-8"} aspect-square mb-1 transition-all duration-300`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="LiveDash Logo"
|
||||||
|
fill
|
||||||
|
className="transition-all duration-300"
|
||||||
|
priority
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<span className="text-lg font-bold text-sky-700 mt-1 transition-opacity duration-300">
|
||||||
|
LiveDash
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="absolute top-3 right-3 z-30">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // Prevent any navigation
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<nav
|
||||||
|
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
||||||
|
>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard"
|
||||||
|
label="Dashboard"
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard"}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/overview"
|
||||||
|
label="Analytics"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/overview"}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/sessions"
|
||||||
|
label="Sessions"
|
||||||
|
icon={<SessionsIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname.startsWith("/dashboard/sessions")}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/company"
|
||||||
|
label="Company Settings"
|
||||||
|
icon={<CompanyIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/company"}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/users"
|
||||||
|
label="User Management"
|
||||||
|
icon={<UsersIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/users"}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
||||||
|
isExpanded ? "" : "justify-center"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
||||||
|
<LogoutIcon />
|
||||||
|
</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<span>Logout</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
|
text-white bg-gray-800 z-50
|
||||||
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -55,7 +55,7 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
components={{
|
components={{
|
||||||
p: "span",
|
p: "span",
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
a: ({ node: _node, ...props }) => (
|
a: ({ node: _node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className="text-sky-600 hover:text-sky-800 underline"
|
className="text-sky-600 hover:text-sky-800 underline"
|
||||||
@ -107,7 +107,7 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
components={{
|
components={{
|
||||||
p: "span",
|
p: "span",
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
a: ({ node: _node, ...props }) => (
|
a: ({ node: _node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className="text-sky-600 hover:text-sky-800 underline"
|
className="text-sky-600 hover:text-sky-800 underline"
|
||||||
|
|||||||
@ -2,15 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { select } from "d3-selection";
|
import { select } from "d3-selection";
|
||||||
import cloud from "d3-cloud";
|
import cloud, { Word } from "d3-cloud";
|
||||||
|
|
||||||
interface CloudWord {
|
|
||||||
text: string;
|
|
||||||
size: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
rotate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WordCloudProps {
|
interface WordCloudProps {
|
||||||
words: {
|
words: {
|
||||||
@ -19,20 +11,55 @@ interface WordCloudProps {
|
|||||||
}[];
|
}[];
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WordCloud({
|
export default function WordCloud({
|
||||||
words,
|
words,
|
||||||
width = 500,
|
width: initialWidth = 500,
|
||||||
height = 300,
|
height: initialHeight = 300,
|
||||||
|
minWidth = 200,
|
||||||
|
minHeight = 200,
|
||||||
}: WordCloudProps) {
|
}: WordCloudProps) {
|
||||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
const [dimensions, setDimensions] = useState({
|
||||||
|
width: initialWidth,
|
||||||
|
height: initialHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set isClient to true on initial render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!svgRef.current || !isClient || !words.length) return;
|
if (!svgRef.current || !isClient || !words.length) return;
|
||||||
|
|
||||||
@ -44,7 +71,7 @@ export default function WordCloud({
|
|||||||
|
|
||||||
// Configure the layout
|
// Configure the layout
|
||||||
const layout = cloud()
|
const layout = cloud()
|
||||||
.size([width, height])
|
.size([dimensions.width, dimensions.height])
|
||||||
.words(
|
.words(
|
||||||
words.map((d) => ({
|
words.map((d) => ({
|
||||||
text: d.text,
|
text: d.text,
|
||||||
@ -53,20 +80,23 @@ export default function WordCloud({
|
|||||||
)
|
)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
|
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
|
||||||
.fontSize((d) => (d as any).size)
|
.fontSize((d: Word) => d.size || 10)
|
||||||
.on("end", draw);
|
.on("end", draw);
|
||||||
|
|
||||||
layout.start();
|
layout.start();
|
||||||
|
|
||||||
function draw(words: CloudWord[]) {
|
function draw(words: Word[]) {
|
||||||
svg
|
svg
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("transform", `translate(${width / 2},${height / 2})`)
|
.attr(
|
||||||
|
"transform",
|
||||||
|
`translate(${dimensions.width / 2},${dimensions.height / 2})`
|
||||||
|
)
|
||||||
.selectAll("text")
|
.selectAll("text")
|
||||||
.data(words)
|
.data(words)
|
||||||
.enter()
|
.enter()
|
||||||
.append("text")
|
.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("font-family", "Inter, Arial, sans-serif")
|
||||||
.style("fill", () => {
|
.style("fill", () => {
|
||||||
// Create a nice gradient of colors
|
// Create a nice gradient of colors
|
||||||
@ -85,17 +115,17 @@ export default function WordCloud({
|
|||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr(
|
.attr(
|
||||||
"transform",
|
"transform",
|
||||||
(d: CloudWord) =>
|
(d: Word) =>
|
||||||
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
|
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
|
||||||
)
|
)
|
||||||
.text((d: CloudWord) => d.text);
|
.text((d: Word) => d.text || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
svg.selectAll("*").remove();
|
svg.selectAll("*").remove();
|
||||||
};
|
};
|
||||||
}, [words, width, height, isClient]);
|
}, [words, dimensions, isClient]);
|
||||||
|
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
@ -106,12 +136,21 @@ export default function WordCloud({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center w-full h-full">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex justify-center w-full h-full"
|
||||||
|
style={{ minHeight: `${minHeight}px` }}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
width={width}
|
width={dimensions.width}
|
||||||
height={height}
|
height={dimensions.height}
|
||||||
|
className="w-full h-full"
|
||||||
aria-label="Word cloud visualization of categories"
|
aria-label="Word cloud visualization of categories"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
20
e2e/example.spec.ts
Normal file
20
e2e/example.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
@ -26,13 +26,13 @@ const eslintConfig = [
|
|||||||
"coverage/",
|
"coverage/",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "warn",
|
||||||
"no-console": "warn",
|
"no-console": "off",
|
||||||
"no-trailing-spaces": "error",
|
"no-trailing-spaces": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "warn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import ISO6391 from "iso-639-1";
|
|||||||
import countries from "i18n-iso-countries";
|
import countries from "i18n-iso-countries";
|
||||||
|
|
||||||
// Register locales for i18n-iso-countries
|
// Register locales for i18n-iso-countries
|
||||||
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
|
||||||
countries.registerLocale(enLocale);
|
countries.registerLocale(enLocale);
|
||||||
|
|
||||||
// This type is used internally for parsing the CSV records
|
// This type is used internally for parsing the CSV records
|
||||||
@ -374,6 +374,62 @@ function isTruthyValue(value?: string): boolean {
|
|||||||
return truthyValues.includes(value.toLowerCase());
|
return truthyValues.includes(value.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parses a date string into a Date object.
|
||||||
|
* Handles potential errors and various formats, prioritizing D-M-YYYY HH:MM:SS.
|
||||||
|
* @param dateStr The date string to parse.
|
||||||
|
* @returns A Date object or null if parsing fails.
|
||||||
|
*/
|
||||||
|
function safeParseDate(dateStr?: string): Date | null {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
|
||||||
|
// Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots)
|
||||||
|
const dateTimeRegex =
|
||||||
|
/^(\d{1,2})[.-](\d{1,2})[.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/;
|
||||||
|
const match = dateStr.match(dateTimeRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const day = match[1];
|
||||||
|
const month = match[2];
|
||||||
|
const year = match[3];
|
||||||
|
const hour = match[4];
|
||||||
|
const minute = match[5];
|
||||||
|
const second = match[6];
|
||||||
|
|
||||||
|
// Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time)
|
||||||
|
// Ensure month and day are two digits
|
||||||
|
const formattedDateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(formattedDateStr);
|
||||||
|
// Basic validation: check if the constructed date is valid
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
// console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed
|
||||||
|
try {
|
||||||
|
const parsedDate = new Date(dateStr);
|
||||||
|
if (!isNaN(parsedDate.getTime())) {
|
||||||
|
// console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`);
|
||||||
|
return parsedDate;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Failed to parse date string: ${dateStr}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAndParseCsv(
|
export async function fetchAndParseCsv(
|
||||||
url: string,
|
url: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
@ -418,13 +474,6 @@ export async function fetchAndParseCsv(
|
|||||||
trim: true,
|
trim: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to safely parse dates
|
|
||||||
function safeParseDate(dateStr?: string): Date | null {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return !isNaN(date.getTime()) ? date : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coerce types for relevant columns
|
// Coerce types for relevant columns
|
||||||
return records.map((r) => ({
|
return records.map((r) => ({
|
||||||
id: r.session_id,
|
id: r.session_id,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import ISO6391 from "iso-639-1";
|
|||||||
import countries from "i18n-iso-countries";
|
import countries from "i18n-iso-countries";
|
||||||
|
|
||||||
// Register locales for i18n-iso-countries
|
// Register locales for i18n-iso-countries
|
||||||
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
|
||||||
countries.registerLocale(enLocale);
|
countries.registerLocale(enLocale);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
850
lib/metrics.ts
850
lib/metrics.ts
@ -13,295 +13,309 @@ interface CompanyConfig {
|
|||||||
sentimentAlert?: number;
|
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
|
// List of common stop words - this can be expanded
|
||||||
const stopWords = new Set([
|
const stopWords = new Set([
|
||||||
"assistant",
|
"assistant",
|
||||||
"user",
|
"user",
|
||||||
// Web
|
// Web
|
||||||
|
"bmp",
|
||||||
|
"co",
|
||||||
"com",
|
"com",
|
||||||
"www",
|
"css",
|
||||||
"http",
|
"gif",
|
||||||
"https",
|
|
||||||
"www2",
|
|
||||||
"href",
|
"href",
|
||||||
"html",
|
"html",
|
||||||
"php",
|
"http",
|
||||||
"js",
|
"https",
|
||||||
"css",
|
|
||||||
"xml",
|
|
||||||
"json",
|
|
||||||
"txt",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"png",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"svg",
|
|
||||||
"org",
|
|
||||||
"net",
|
|
||||||
"co",
|
|
||||||
"io",
|
"io",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"net",
|
||||||
|
"org",
|
||||||
|
"php",
|
||||||
|
"png",
|
||||||
|
"svg",
|
||||||
|
"txt",
|
||||||
|
"www",
|
||||||
|
"www2",
|
||||||
|
"xml",
|
||||||
// English stop words
|
// English stop words
|
||||||
"a",
|
"a",
|
||||||
|
"about",
|
||||||
|
"above",
|
||||||
|
"after",
|
||||||
|
"again",
|
||||||
|
"against",
|
||||||
|
"ain",
|
||||||
|
"all",
|
||||||
|
"am",
|
||||||
"an",
|
"an",
|
||||||
"the",
|
"any",
|
||||||
"is",
|
|
||||||
"are",
|
"are",
|
||||||
"was",
|
"aren",
|
||||||
"were",
|
"at",
|
||||||
"be",
|
"be",
|
||||||
"been",
|
"been",
|
||||||
|
"before",
|
||||||
"being",
|
"being",
|
||||||
"have",
|
"below",
|
||||||
"has",
|
"between",
|
||||||
"had",
|
"both",
|
||||||
"do",
|
"by",
|
||||||
"does",
|
"bye",
|
||||||
"did",
|
|
||||||
"will",
|
|
||||||
"would",
|
|
||||||
"should",
|
|
||||||
"can",
|
"can",
|
||||||
"could",
|
"could",
|
||||||
"may",
|
"couldn",
|
||||||
"might",
|
"d",
|
||||||
"must",
|
"did",
|
||||||
"am",
|
"didn",
|
||||||
"i",
|
"do",
|
||||||
"you",
|
"does",
|
||||||
"he",
|
"doesn",
|
||||||
"she",
|
"don",
|
||||||
"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",
|
|
||||||
"down",
|
"down",
|
||||||
"out",
|
"during",
|
||||||
"off",
|
|
||||||
"over",
|
|
||||||
"under",
|
|
||||||
"again",
|
|
||||||
"further",
|
|
||||||
"then",
|
|
||||||
"once",
|
|
||||||
"here",
|
|
||||||
"there",
|
|
||||||
"when",
|
|
||||||
"where",
|
|
||||||
"why",
|
|
||||||
"how",
|
|
||||||
"all",
|
|
||||||
"any",
|
|
||||||
"both",
|
|
||||||
"each",
|
"each",
|
||||||
"few",
|
"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",
|
"more",
|
||||||
"most",
|
"most",
|
||||||
"other",
|
"must",
|
||||||
"some",
|
"mustn",
|
||||||
"such",
|
"my",
|
||||||
|
"needn",
|
||||||
"no",
|
"no",
|
||||||
"nor",
|
"nor",
|
||||||
"not",
|
"not",
|
||||||
"only",
|
|
||||||
"own",
|
|
||||||
"same",
|
|
||||||
"so",
|
|
||||||
"than",
|
|
||||||
"too",
|
|
||||||
"very",
|
|
||||||
"s",
|
|
||||||
"t",
|
|
||||||
"just",
|
|
||||||
"don",
|
|
||||||
"shouldve",
|
|
||||||
"now",
|
"now",
|
||||||
"d",
|
|
||||||
"ll",
|
|
||||||
"m",
|
|
||||||
"o",
|
"o",
|
||||||
"re",
|
"of",
|
||||||
"ve",
|
"off",
|
||||||
"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",
|
|
||||||
"ok",
|
"ok",
|
||||||
"okay",
|
"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",
|
"yeah",
|
||||||
"bye",
|
"yes",
|
||||||
"goodbye",
|
"you",
|
||||||
|
"your",
|
||||||
|
"yours",
|
||||||
// French stop words
|
// French stop words
|
||||||
|
"des",
|
||||||
|
"donc",
|
||||||
|
"et",
|
||||||
"la",
|
"la",
|
||||||
"le",
|
"le",
|
||||||
"les",
|
"les",
|
||||||
|
"mais",
|
||||||
|
"ou",
|
||||||
"un",
|
"un",
|
||||||
"une",
|
"une",
|
||||||
"des",
|
|
||||||
"et",
|
|
||||||
"ou",
|
|
||||||
"mais",
|
|
||||||
"donc",
|
|
||||||
// Dutch stop words
|
// 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",
|
"aan",
|
||||||
"uit",
|
"al",
|
||||||
"sinds",
|
|
||||||
"tijdens",
|
|
||||||
"binnen",
|
|
||||||
"buiten",
|
|
||||||
"zonder",
|
|
||||||
"volgens",
|
|
||||||
"dankzij",
|
|
||||||
"ondanks",
|
|
||||||
"behalve",
|
|
||||||
"mits",
|
|
||||||
"tenzij",
|
|
||||||
"hoewel",
|
|
||||||
"alhoewel",
|
"alhoewel",
|
||||||
"toch",
|
"als",
|
||||||
"anders",
|
"anders",
|
||||||
"echter",
|
"behalve",
|
||||||
"wel",
|
"ben",
|
||||||
"niet",
|
|
||||||
"geen",
|
|
||||||
"iets",
|
|
||||||
"niets",
|
|
||||||
"veel",
|
|
||||||
"weinig",
|
|
||||||
"meer",
|
|
||||||
"meest",
|
|
||||||
"elk",
|
|
||||||
"ieder",
|
|
||||||
"sommige",
|
|
||||||
"hoe",
|
|
||||||
"wat",
|
|
||||||
"waar",
|
|
||||||
"wie",
|
|
||||||
"wanneer",
|
|
||||||
"waarom",
|
|
||||||
"welke",
|
|
||||||
"wordt",
|
|
||||||
"worden",
|
|
||||||
"werd",
|
|
||||||
"werden",
|
|
||||||
"geworden",
|
|
||||||
"zijn",
|
|
||||||
"ben",
|
"ben",
|
||||||
"bent",
|
"bent",
|
||||||
"was",
|
"bij",
|
||||||
"waren",
|
"binnen",
|
||||||
|
"boven",
|
||||||
|
"buiten",
|
||||||
|
"dan",
|
||||||
|
"dankzij",
|
||||||
|
"dat",
|
||||||
|
"de",
|
||||||
|
"deze",
|
||||||
|
"die",
|
||||||
|
"dit",
|
||||||
|
"dit",
|
||||||
|
"door",
|
||||||
|
"dus",
|
||||||
|
"echter",
|
||||||
|
"een",
|
||||||
|
"elk",
|
||||||
|
"en",
|
||||||
|
"geen",
|
||||||
|
"gehad",
|
||||||
"geweest",
|
"geweest",
|
||||||
"hebben",
|
"geworden",
|
||||||
"heb",
|
|
||||||
"hebt",
|
|
||||||
"heeft",
|
|
||||||
"had",
|
"had",
|
||||||
"hadden",
|
"hadden",
|
||||||
"gehad",
|
"heb",
|
||||||
"kunnen",
|
"hebben",
|
||||||
|
"hebt",
|
||||||
|
"heeft",
|
||||||
|
"het",
|
||||||
|
"hij",
|
||||||
|
"hoe",
|
||||||
|
"hoewel",
|
||||||
|
"ieder",
|
||||||
|
"iets",
|
||||||
|
"ik",
|
||||||
|
"jij",
|
||||||
|
"jullie",
|
||||||
"kan",
|
"kan",
|
||||||
"kunt",
|
|
||||||
"kon",
|
"kon",
|
||||||
"konden",
|
"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",
|
"zal",
|
||||||
|
"zij",
|
||||||
|
"zijn",
|
||||||
|
"zonder",
|
||||||
|
"zullen",
|
||||||
"zult",
|
"zult",
|
||||||
// Add more domain-specific stop words if necessary
|
// Add more domain-specific stop words if necessary
|
||||||
]);
|
]);
|
||||||
@ -310,156 +324,266 @@ export function sessionMetrics(
|
|||||||
sessions: ChatSession[],
|
sessions: ChatSession[],
|
||||||
companyConfig: CompanyConfig = {}
|
companyConfig: CompanyConfig = {}
|
||||||
): MetricsResult {
|
): MetricsResult {
|
||||||
const total = sessions.length;
|
const totalSessions = sessions.length; // Renamed from 'total' for clarity
|
||||||
const byDay: DayMetrics = {};
|
const byDay: DayMetrics = {};
|
||||||
const byCategory: CategoryMetrics = {};
|
const byCategory: CategoryMetrics = {};
|
||||||
const byLanguage: LanguageMetrics = {};
|
const byLanguage: LanguageMetrics = {};
|
||||||
const byCountry: CountryMetrics = {}; // Added for country data
|
const byCountry: CountryMetrics = {};
|
||||||
const tokensByDay: DayMetrics = {};
|
const tokensByDay: DayMetrics = {};
|
||||||
const tokensCostByDay: DayMetrics = {};
|
const tokensCostByDay: DayMetrics = {};
|
||||||
|
|
||||||
let escalated = 0,
|
let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
|
||||||
forwarded = 0;
|
let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
|
||||||
let totalSentiment = 0,
|
|
||||||
sentimentCount = 0;
|
|
||||||
let totalResponse = 0,
|
|
||||||
responseCount = 0;
|
|
||||||
let totalTokens = 0,
|
|
||||||
totalTokensEur = 0;
|
|
||||||
|
|
||||||
// For sentiment distribution
|
// Variables for calculations
|
||||||
let sentimentPositive = 0,
|
const uniqueUserIds = new Set<string>();
|
||||||
sentimentNegative = 0,
|
let totalSessionDuration = 0;
|
||||||
sentimentNeutral = 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
|
for (const session of sessions) {
|
||||||
let totalDuration = 0;
|
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
|
||||||
let durationCount = 0;
|
let identifierAdded = false;
|
||||||
|
if (session.ipAddress && session.ipAddress.trim() !== "") {
|
||||||
const wordCounts: { [key: string]: number } = {}; // For WordCloud
|
uniqueUserIds.add(session.ipAddress.trim());
|
||||||
|
identifierAdded = true;
|
||||||
sessions.forEach((s) => {
|
}
|
||||||
const day = s.startTime.toISOString().slice(0, 10);
|
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
|
||||||
byDay[day] = (byDay[day] || 0) + 1;
|
if (
|
||||||
|
!identifierAdded &&
|
||||||
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
session.sessionId &&
|
||||||
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
session.sessionId.trim() !== ""
|
||||||
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry
|
) {
|
||||||
|
uniqueUserIds.add(session.sessionId.trim());
|
||||||
// Process token usage by day
|
|
||||||
if (s.tokens) {
|
|
||||||
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process token cost by day
|
// Avg. Session Time
|
||||||
if (s.tokensEur) {
|
if (session.startTime && session.endTime) {
|
||||||
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
|
const startTimeMs = new Date(session.startTime).getTime();
|
||||||
}
|
const endTimeMs = new Date(session.endTime).getTime();
|
||||||
|
|
||||||
if (s.endTime) {
|
if (isNaN(startTimeMs)) {
|
||||||
const duration =
|
console.warn(
|
||||||
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
|
`[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)
|
if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) {
|
||||||
const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes
|
const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta
|
||||||
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
|
// Use the absolute difference for duration, ensuring it's not negative.
|
||||||
totalDuration += duration;
|
// If times are identical, duration will be 0.
|
||||||
durationCount++;
|
// 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++;
|
// Avg. Response Time
|
||||||
if (s.forwardedHr) forwarded++;
|
if (
|
||||||
|
session.avgResponseTime !== undefined &&
|
||||||
|
session.avgResponseTime !== null &&
|
||||||
|
session.avgResponseTime >= 0
|
||||||
|
) {
|
||||||
|
totalResponseTime += session.avgResponseTime;
|
||||||
|
validSessionsForResponseTime++;
|
||||||
|
}
|
||||||
|
|
||||||
if (s.sentiment != null) {
|
// Escalated and Forwarded
|
||||||
totalSentiment += s.sentiment;
|
if (session.escalated) escalatedCount++;
|
||||||
sentimentCount++;
|
if (session.forwardedHr) forwardedHrCount++;
|
||||||
|
|
||||||
// Classify sentiment
|
// Sentiment
|
||||||
if (s.sentiment > 0.3) {
|
if (session.sentiment !== undefined && session.sentiment !== null) {
|
||||||
sentimentPositive++;
|
// Example thresholds, adjust as needed
|
||||||
} else if (s.sentiment < -0.3) {
|
if (session.sentiment > 0.3) sentimentPositiveCount++;
|
||||||
sentimentNegative++;
|
else if (session.sentiment < -0.3) sentimentNegativeCount++;
|
||||||
} else {
|
else sentimentNeutralCount++;
|
||||||
sentimentNeutral++;
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
processTextForWordCloud(session.initialMsg);
|
||||||
if (s.avgResponseTime != null) {
|
processTextForWordCloud(session.transcriptContent);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate average sessions per day
|
const uniqueUsers = uniqueUserIds.size;
|
||||||
const dayCount = Object.keys(byDay).length;
|
|
||||||
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
|
||||||
|
|
||||||
// Calculate average session length
|
|
||||||
const avgSessionLength =
|
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)
|
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
|
||||||
.map(([text, value]) => ({ text, value }))
|
.sort(([, a], [, b]) => b - a)
|
||||||
.sort((a, b) => b.value - a.value)
|
.slice(0, 50) // Top 50 words
|
||||||
.slice(0, 500); // Take top 500 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 {
|
return {
|
||||||
totalSessions: total,
|
totalSessions,
|
||||||
avgSessionsPerDay,
|
uniqueUsers,
|
||||||
avgSessionLength,
|
avgSessionLength, // Corrected to match MetricsResult interface
|
||||||
days: byDay,
|
avgResponseTime, // Corrected to match MetricsResult interface
|
||||||
languages: byLanguage,
|
escalatedCount,
|
||||||
categories: byCategory, // This will be empty if we are not using categories for word cloud
|
forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
|
||||||
countries: byCountry, // Added countries to the result
|
sentimentPositiveCount,
|
||||||
belowThresholdCount: belowThreshold,
|
sentimentNeutralCount,
|
||||||
// Additional metrics not in the interface - using type assertion
|
sentimentNegativeCount,
|
||||||
escalatedCount: escalated,
|
days: byDay, // Corrected to match MetricsResult interface (days)
|
||||||
forwardedCount: forwarded,
|
categories: byCategory, // Corrected to match MetricsResult interface (categories)
|
||||||
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
|
languages: byLanguage, // Corrected to match MetricsResult interface (languages)
|
||||||
avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
|
countries: byCountry, // Corrected to match MetricsResult interface (countries)
|
||||||
totalTokens,
|
|
||||||
totalTokensEur,
|
|
||||||
sentimentThreshold: threshold,
|
|
||||||
lastUpdated: Date.now(), // Add current timestamp
|
|
||||||
|
|
||||||
// New metrics for enhanced dashboard
|
|
||||||
sentimentPositiveCount: sentimentPositive,
|
|
||||||
sentimentNeutralCount: sentimentNeutral,
|
|
||||||
sentimentNegativeCount: sentimentNegative,
|
|
||||||
tokensByDay,
|
tokensByDay,
|
||||||
tokensCostByDay,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/types.ts
11
lib/types.ts
@ -131,6 +131,17 @@ export interface MetricsResult {
|
|||||||
tokensByDay?: DayMetrics;
|
tokensByDay?: DayMetrics;
|
||||||
tokensCostByDay?: DayMetrics;
|
tokensCostByDay?: DayMetrics;
|
||||||
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
|
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<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
**/
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Allow cross-origin requests from specific origins in development
|
// Allow cross-origin requests from specific origins in development
|
||||||
|
|||||||
477
package-lock.json
generated
477
package-lock.json
generated
@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "livedash-node",
|
"name": "livedash-node",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "livedash-node",
|
"name": "livedash-node",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@rapideditor/country-coder": "^5.4.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/d3-cloud": "^1.2.9",
|
"@types/d3-cloud": "^1.2.9",
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
@ -17,7 +18,6 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chart.js": "^4.0.0",
|
"chart.js": "^4.0.0",
|
||||||
"chartjs-plugin-annotation": "^3.1.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"country-code-lookup": "^0.1.3",
|
|
||||||
"csv-parse": "^5.5.0",
|
"csv-parse": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-cloud": "^1.2.7",
|
"d3-cloud": "^1.2.7",
|
||||||
@ -38,6 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.27.0",
|
"@eslint/js": "^9.27.0",
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
@ -49,8 +50,10 @@
|
|||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-config-next": "^15.3.2",
|
"eslint-config-next": "^15.3.2",
|
||||||
"eslint-plugin-prettier": "^5.4.0",
|
"eslint-plugin-prettier": "^5.4.0",
|
||||||
|
"markdownlint-cli2": "^0.18.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-jinja-template": "^2.1.0",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@ -1069,6 +1072,22 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.8.2",
|
"version": "6.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
|
||||||
@ -1151,6 +1170,18 @@
|
|||||||
"@prisma/debug": "6.8.2"
|
"@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": {
|
"node_modules/@react-leaflet/core": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
@ -1176,6 +1207,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@ -1834,6 +1878,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/leaflet": {
|
||||||
"version": "1.9.18",
|
"version": "1.9.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.18.tgz",
|
||||||
@ -3031,12 +3082,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@ -4649,6 +4694,21 @@
|
|||||||
"node": ">=12.20.0"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@ -4800,6 +4860,37 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -5737,6 +5828,13 @@
|
|||||||
"json5": "lib/cli.js"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@ -5753,6 +5851,33 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -6042,6 +6167,22 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@ -6117,6 +6258,98 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -6279,6 +6512,13 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@ -6358,6 +6598,102 @@
|
|||||||
"micromark-util-types": "^2.0.0"
|
"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": {
|
"node_modules/micromark-factory-destination": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||||
@ -7343,6 +7679,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -7362,6 +7711,38 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"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": ">=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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
@ -7526,6 +7917,16 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -7547,6 +7948,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
@ -8086,6 +8502,19 @@
|
|||||||
"is-arrayish": "^0.3.1"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -8657,6 +9086,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
@ -8682,6 +9118,19 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
@ -8972,6 +9421,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||||
|
|||||||
84
package.json
84
package.json
@ -1,21 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "livedash-node",
|
"name": "livedash-node",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"version": "0.2.0",
|
||||||
"dev": "next dev --turbopack",
|
"private": true,
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@rapideditor/country-coder": "^5.4.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/d3-cloud": "^1.2.9",
|
"@types/d3-cloud": "^1.2.9",
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
@ -24,7 +14,6 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chart.js": "^4.0.0",
|
"chart.js": "^4.0.0",
|
||||||
"chartjs-plugin-annotation": "^3.1.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"country-code-lookup": "^0.1.3",
|
|
||||||
"csv-parse": "^5.5.0",
|
"csv-parse": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-cloud": "^1.2.7",
|
"d3-cloud": "^1.2.7",
|
||||||
@ -45,6 +34,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.27.0",
|
"@eslint/js": "^9.27.0",
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
@ -56,11 +46,77 @@
|
|||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-config-next": "^15.3.2",
|
"eslint-config-next": "^15.3.2",
|
||||||
"eslint-plugin-prettier": "^5.4.0",
|
"eslint-plugin-prettier": "^5.4.0",
|
||||||
|
"markdownlint-cli2": "^0.18.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-jinja-template": "^2.1.0",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.0.0"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,12 @@ export default async function handler(
|
|||||||
data: { csvUrl },
|
data: { csvUrl },
|
||||||
});
|
});
|
||||||
res.json({ ok: true });
|
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 {
|
} else {
|
||||||
res.status(405).end();
|
res.status(405).end();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
SessionApiResponse,
|
SessionApiResponse,
|
||||||
SessionQuery,
|
SessionQuery,
|
||||||
} from "../../../lib/types";
|
} from "../../../lib/types";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
@ -39,7 +40,7 @@ export default async function handler(
|
|||||||
const pageSize = Number(queryPageSize) || 10;
|
const pageSize = Number(queryPageSize) || 10;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const whereClause: any = { companyId };
|
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||||
|
|
||||||
// Search Term
|
// Search Term
|
||||||
if (
|
if (
|
||||||
@ -48,11 +49,10 @@ export default async function handler(
|
|||||||
searchTerm.trim() !== ""
|
searchTerm.trim() !== ""
|
||||||
) {
|
) {
|
||||||
const searchConditions = [
|
const searchConditions = [
|
||||||
{ id: { contains: searchTerm, mode: "insensitive" } },
|
{ id: { contains: searchTerm } },
|
||||||
{ sessionId: { contains: searchTerm, mode: "insensitive" } },
|
{ category: { contains: searchTerm } },
|
||||||
{ category: { contains: searchTerm, mode: "insensitive" } },
|
{ initialMsg: { contains: searchTerm } },
|
||||||
{ initialMsg: { contains: searchTerm, mode: "insensitive" } },
|
{ transcriptContent: { contains: searchTerm } },
|
||||||
{ transcriptContent: { contains: searchTerm, mode: "insensitive" } },
|
|
||||||
];
|
];
|
||||||
whereClause.OR = searchConditions;
|
whereClause.OR = searchConditions;
|
||||||
}
|
}
|
||||||
@ -69,37 +69,59 @@ export default async function handler(
|
|||||||
|
|
||||||
// Date Range Filter
|
// Date Range Filter
|
||||||
if (startDate && typeof startDate === "string") {
|
if (startDate && typeof startDate === "string") {
|
||||||
if (!whereClause.startTime) whereClause.startTime = {};
|
whereClause.startTime = {
|
||||||
whereClause.startTime.gte = new Date(startDate);
|
...((whereClause.startTime as object) || {}),
|
||||||
|
gte: new Date(startDate),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (endDate && typeof endDate === "string") {
|
if (endDate && typeof endDate === "string") {
|
||||||
if (!whereClause.startTime) whereClause.startTime = {};
|
|
||||||
const inclusiveEndDate = new Date(endDate);
|
const inclusiveEndDate = new Date(endDate);
|
||||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||||
whereClause.startTime.lt = inclusiveEndDate;
|
whereClause.startTime = {
|
||||||
|
...((whereClause.startTime as object) || {}),
|
||||||
|
lt: inclusiveEndDate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
let orderByClause: any = { startTime: "desc" };
|
const validSortKeys: { [key: string]: string } = {
|
||||||
if (sortKey && typeof sortKey === "string") {
|
startTime: "startTime",
|
||||||
const order =
|
category: "category",
|
||||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
|
language: "language",
|
||||||
const validSortKeys: { [key: string]: string } = {
|
sentiment: "sentiment",
|
||||||
startTime: "startTime",
|
messagesSent: "messagesSent",
|
||||||
category: "category",
|
avgResponseTime: "avgResponseTime",
|
||||||
language: "language",
|
};
|
||||||
sentiment: "sentiment",
|
|
||||||
messagesSent: "messagesSent",
|
let orderByCondition:
|
||||||
avgResponseTime: "avgResponseTime",
|
| Prisma.SessionOrderByWithRelationInput
|
||||||
};
|
| Prisma.SessionOrderByWithRelationInput[];
|
||||||
if (validSortKeys[sortKey]) {
|
|
||||||
orderByClause = { [validSortKeys[sortKey]]: order };
|
const primarySortField =
|
||||||
}
|
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
|
||||||
|
? validSortKeys[sortKey]
|
||||||
|
: "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||||
|
|
||||||
|
const primarySortOrder =
|
||||||
|
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||||
|
|
||||||
|
if (primarySortField === "startTime") {
|
||||||
|
// If sorting by startTime, it's the only sort criteria
|
||||||
|
orderByCondition = { [primarySortField]: primarySortOrder };
|
||||||
|
} else {
|
||||||
|
// If sorting by another field, use startTime: "desc" as secondary sort
|
||||||
|
orderByCondition = [
|
||||||
|
{ [primarySortField]: primarySortOrder },
|
||||||
|
{ startTime: "desc" },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
|
||||||
|
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
|
||||||
|
// which is the correct overall default sort.
|
||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
const prismaSessions = await prisma.session.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
orderBy: orderByClause,
|
orderBy: orderByCondition,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,27 +1,20 @@
|
|||||||
import { prisma } from "../../lib/prisma";
|
import { prisma } from "../../lib/prisma";
|
||||||
import { sendEmail } from "../../lib/sendEmail";
|
import { sendEmail } from "../../lib/sendEmail";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
type NextApiRequest = IncomingMessage & {
|
|
||||||
body: {
|
|
||||||
email: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type NextApiResponse = ServerResponse & {
|
|
||||||
status: (code: number) => NextApiResponse;
|
|
||||||
json: (data: Record<string, unknown>) => void;
|
|
||||||
end: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
if (req.method !== "POST") {
|
||||||
const { email } = req.body;
|
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 } });
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
if (!user) return res.status(200).end(); // always 200 for privacy
|
if (!user) return res.status(200).end(); // always 200 for privacy
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,63 @@
|
|||||||
import { prisma } from "../../lib/prisma";
|
import { prisma } from "../../lib/prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types
|
||||||
|
|
||||||
type NextApiRequest = IncomingMessage & {
|
|
||||||
body: {
|
|
||||||
token: string;
|
|
||||||
password: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type NextApiResponse = ServerResponse & {
|
|
||||||
status: (code: number) => NextApiResponse;
|
|
||||||
json: (data: Record<string, unknown>) => void;
|
|
||||||
end: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest, // Use official NextApiRequest
|
||||||
res: NextApiResponse
|
res: NextApiResponse // Use official NextApiResponse
|
||||||
) {
|
) {
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
if (req.method !== "POST") {
|
||||||
const { token, password } = req.body;
|
res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405
|
||||||
const user = await prisma.user.findFirst({
|
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||||
where: {
|
}
|
||||||
resetToken: token,
|
|
||||||
resetTokenExpiry: { gte: new Date() },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 10);
|
// It's good practice to explicitly type the expected body for clarity and safety
|
||||||
await prisma.user.update({
|
const { token, password } = req.body as { token?: string; password?: string };
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
if (!token || !password) {
|
||||||
password: hash,
|
return res.status(400).json({ error: "Token and password are required." });
|
||||||
resetToken: null,
|
}
|
||||||
resetTokenExpiry: null,
|
|
||||||
},
|
if (password.length < 8) {
|
||||||
});
|
// Example: Add password complexity rule
|
||||||
res.status(200).end();
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
playwright.config.ts
Normal file
79
playwright.config.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
489
tests-examples/demo-todo-app.spec.ts
Normal file
489
tests-examples/demo-todo-app.spec.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -1,36 +1,36 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitAny": false, // Allow implicit any types
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"moduleResolution": "node",
|
"incremental": true,
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"noImplicitAny": false, // Allow implicit any types
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"resolveJsonModule": true,
|
||||||
"@/*": ["./*"]
|
"skipLibCheck": true,
|
||||||
},
|
"strict": true,
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true,
|
||||||
|
"target": "es5"
|
||||||
},
|
},
|
||||||
|
"exclude": ["node_modules"],
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"components/SessionDetails.tsx.bak"
|
"components/SessionDetails.tsx.bak"
|
||||||
],
|
]
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user