24 Commits

Author SHA1 Message Date
bb078b4d6a Fix error handling to properly use formatError utility and improve environment detection
- Actually use the formatError function in the error handling code
- Update formatError function to accept env parameter for Cloudflare Workers
- Improve environment detection to use WORKER_ENV instead of process.env
- Update tests to match new function signature
- Add comprehensive test coverage for error formatting
- Fix linting issues and ensure proper TypeScript types
2025-06-10 00:46:21 +02:00
33577bb2d5 Potential fix for code scanning alert no. 6: Information exposure through a stack trace
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-06-10 00:35:27 +02:00
adea8ae6b7 Refactor error payload logic 2025-06-10 00:27:54 +02:00
ef8601dd72 chore: secure error response 2025-06-10 00:17:24 +02:00
5aaca6de99 Potential fix for code scanning alert no. 2: Workflow does not contain permissions (#6)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-06-10 00:11:10 +02:00
71c8aff125 Implement Cloudflare D1 support with Prisma, update scripts, and enhance documentation 2025-06-01 05:22:44 +02:00
0c18e8be57 Add initial wrangler configuration for livedash-node project
- Created wrangler.json with project metadata and settings
- Configured D1 database binding for database interaction
- Enabled observability for monitoring
- Added placeholders for smart placement, environment variables, static assets, and service bindings
2025-06-01 04:51:57 +02:00
c9e24298cd Bump node-cron from 4.0.6 to 4.0.7 (#3)
Bumps [node-cron](https://github.com/merencia/node-cron) from 4.0.6 to 4.0.7.
- [Release notes](https://github.com/merencia/node-cron/releases)
- [Commits](https://github.com/merencia/node-cron/compare/v4.0.6...v4.0.7)

---
updated-dependencies:
- dependency-name: node-cron
  dependency-version: 4.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 12:37:39 +02:00
a360f461ab Merge pull request #2 from kjanat/feature/sidebar
Feature/sidebar
2025-05-23 00:22:45 +02:00
bbcdff0ffc Update app/dashboard/company/page.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-23 00:21:24 +02:00
940b416563 Revert DonutChart.tsx 2025-05-23 00:19:55 +02:00
cb86d26786 Update components/DonutChart.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-23 00:13:16 +02:00
a265f3236c Update README.md
Typo
2025-05-23 00:10:45 +02:00
be63dba540 Update playwright.yml
renamed step
2025-05-23 00:09:00 +02:00
01f4dd60f9 Update playwright.yml 2025-05-22 23:59:47 +02:00
9fad25e5f9 Update TODO.md with new tasks and enhance README.md with project details and setup instructions 2025-05-22 23:55:30 +02:00
13d0f8ee8d Add markdownlint-cli2 for markdown linting; configure Prettier with Jinja template support and update linting scripts 2025-05-22 22:29:06 +02:00
303226e3a9 Refactor trend calculations and improve WordCloud component responsiveness; remove trend labels for cleaner display 2025-05-22 22:21:40 +02:00
cbbdc8a1dc Enhance dashboard layout and sidebar functionality; improve session metrics calculations and API error handling 2025-05-22 21:53:18 +02:00
8dcb892ae9 Add Playwright testing framework and implement initial tests; update .gitignore and package files 2025-05-22 19:46:04 +02:00
f005b2ec0a Update dashboard metrics and session handling
- Refactor DashboardContent to improve trend calculations for user metrics and session time.
- Modify SessionViewPage to ensure loading state is set before fetching session data.
- Adjust SessionsPage to clean up display of session start time and remove unnecessary comments.
- Enhance DonutChart to handle various data point types and improve percentage calculations.
- Update GeographicMap to utilize @rapideditor/country-coder for country coordinates.
- Improve safeParseDate function in csvFetcher for better date handling and error logging.
- Refactor sessionMetrics to clarify variable names and improve session duration calculations.
- Update next.config.js for better configuration clarity.
- Bump package version to 0.2.0 and update dependencies in package.json and package-lock.json.
- Clean up API handler for dashboard sessions to improve readability and maintainability.
- Adjust tsconfig.json for better module resolution and strict type checking.
2025-05-22 19:21:49 +02:00
ed6e5b0c36 Enhance session handling and improve data parsing; add safe date parsing utility 2025-05-22 16:11:33 +02:00
efb5261c7d Refactor components and enhance metrics calculations:
- Update access denied messages to use HTML entities.
- Add autoComplete attributes to forms for better user experience.
- Improve trend calculations in sessionMetrics function.
- Update MetricCard props to accept React nodes for icons.
- Integrate Next.js Image component in Sidebar for optimization.
- Adjust ESLint rules for better code quality.
- Add new properties for trends in MetricsResult interface.
- Bump version to 0.2.0 in package.json.
2025-05-22 14:44:28 +02:00
e3134aa451 Add comprehensive dashboard features
Introduce company settings, user management, and layout components
Implement session-based Company and User pages for admin access
Integrate chart components for dynamic data visualization
Add Sidebar for modular navigation
Revamp global styles with Tailwind CSS

Enhances user experience and administrative control
2025-05-22 14:12:36 +02:00
60 changed files with 18373 additions and 10198 deletions

View File

@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @kjanat

View File

@ -9,18 +9,30 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "tuesday"
time: "03:00"
timezone: "Europe/Amsterdam"
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "tuesday"
time: "03:00"
timezone: "Europe/Amsterdam"
- package-ecosystem: "docker" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "tuesday"
time: "03:00"
timezone: "Europe/Amsterdam"
- package-ecosystem: "docker-compose" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "tuesday"
time: "03:00"
timezone: "Europe/Amsterdam"

31
.github/workflows/playwright.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Playwright Tests
permissions:
contents: read
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

192
.gitignore vendored
View File

@ -255,3 +255,195 @@ Thumbs.db
# Backup files
*.bak
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# Created by https://www.toptal.com/developers/gitignore/api/node,macos
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Moved from ./templates for ignoring all locks in templates
templates/**/*-lock.*
templates/**/*.lock
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node,macos
# Wrangler output
.wrangler/
build/
# Turbo output
.turbo/
.dev.vars*
test-transcript-format.js

54
.prettierignore Normal file
View File

@ -0,0 +1,54 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
.next/
out/
dist/
build/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Database
*.db
*.sqlite
prisma/migrations/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git/
# Coverage reports
coverage/
# Playwright
test-results/
playwright-report/
playwright/.cache/
# Generated files
*.generated.*
pnpm-lock.yaml

View File

@ -1,10 +0,0 @@
{
"singleQuote": false,
"trailingComma": "es5",
"semi": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 80,
"bracketSpacing": true,
"endOfLine": "auto"
}

View File

@ -2,6 +2,7 @@
"recommendations": [
"prisma.prisma",
"dbaeumer.vscode-eslint",
"rvest.vs-code-prettier-eslint"
"rvest.vs-code-prettier-eslint",
"ms-playwright.playwright"
]
}

115
README.md Normal file
View 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.
![Next.js](<https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22next%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000>)
![React](<https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22react%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
![TypeScript](<https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22typescript%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6>)
![Prisma](<https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22prisma%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748>)
![TailwindCSS](<https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkjanat%2Flivedash-node%2Fmaster%2Fpackage.json&search=%22tailwindcss%22%5Cs*%3A%5Cs*%22%5C%5E(%3F%3Cversion%3E%5Cd%2B%5C.%5Cd*).*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4>)
## Features
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
- **Interactive Visualizations**: Geographic maps, response time distributions, and more
- **Advanced Analytics**: Detailed metrics and insights about user behavior
- **User Management**: Secure authentication with role-based access control
- **Customizable Dashboard**: Filter and sort data based on your specific needs
- **Session Details**: In-depth analysis of individual user sessions
## Tech Stack
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
- **Backend**: Next.js API Routes, Node.js
- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL
- **Authentication**: NextAuth.js
- **Visualization**: Chart.js, D3.js, React Leaflet
- **Data Processing**: Node-cron for scheduled tasks
## Getting Started
### Prerequisites
- Node.js (LTS version recommended)
- npm or yarn
### Installation
1. Clone this repository:
```bash
git clone https://github.com/kjanat/livedash-node.git
cd livedash-node
```
2. Install dependencies:
```bash
npm install
```
3. Set up the database:
```bash
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
```
4. Start the development server:
```bash
npm run dev
```
5. Open your browser and navigate to <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/)

137
TODO.md
View File

@ -1,45 +1,108 @@
# Application Improvement TODOs
# TODO.md
This file lists general areas for improvement and tasks that are broader in scope or don't map to a single specific file.
## Dashboard Integration
## General Enhancements & Features
- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events).
- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV).
- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them.
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources.
## Robustness and Maintainability
- [ ] **Comprehensive Testing:**
- [ ] Implement unit tests (e.g., for utility functions, API logic).
- [ ] Implement integration tests (e.g., for API endpoints with the database).
- [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress).
- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging.
- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast).
## Security Enhancements
- [ ] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented).
- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts.
- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized.
## Code Quality and Development Practices
- [ ] **Code Reviews:** Enforce code reviews for all changes.
- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations.
- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates.
- [ ] **Documentation:**
- Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations.
- Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied.
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
- Investigate integrating real data sources with server-side analytics
- Replace simulated data mentioned in `docs/dashboard-components.md`
## Component Specific
- [ ] **`components/SessionDetails.tsx.new`:** Review, complete TODOs within the file, and integrate as the primary `SessionDetails.tsx` component, removing/archiving older versions (`SessionDetails.tsx`, `SessionDetails.tsx.bak`).
- [ ] **`components/GeographicMap.tsx`:** Check if `GeographicMap.tsx.bak` is still needed or can be removed.
- [ ] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting.
- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords.
- [ ] **Implement robust emailing of temporary passwords**
- File: `pages/api/dashboard/users.ts`
- Set up proper email service integration
- [x] **Session page improvements**
- File: `app/dashboard/sessions/page.tsx`
- Implemented pagination, advanced filtering, and sorting
## File Cleanup
- [ ] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`).
- [x] **Remove backup files**
- Reviewed and removed `.bak` and `.new` files after integration
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
## Database Schema Improvements
- [ ] **Update EndTime field**
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
- [ ] **Add database indices**
- Add appropriate indices to improve query performance
- Focus on dashboard metrics and session listing queries
- [ ] **Implement production email service**
- Replace console logging in `lib/sendEmail.ts`
- Consider providers: Nodemailer, SendGrid, AWS SES
## General Enhancements & Features
- [ ] **Real-time updates**
- Implement for dashboard and session list
- Consider WebSockets or Server-Sent Events
- [ ] **Data export functionality**
- Allow users (especially admins) to export session data
- Support CSV format initially
- [ ] **Customizable dashboard**
- Allow users to customize dashboard view
- Let users choose which metrics/charts are most important
## Testing & Quality Assurance
- [ ] **Comprehensive testing suite**
- [ ] Unit tests for utility functions and API logic
- [ ] Integration tests for API endpoints with database
- [ ] End-to-end tests for user flows (Playwright or Cypress)
- [ ] **Error monitoring and logging**
- Integrate robust error monitoring service (Sentry)
- Enhance server-side logging
- [ ] **Accessibility improvements**
- Review application against WCAG guidelines
- Improve keyboard navigation and screen reader compatibility
- Check color contrast ratios
## Security Enhancements
- [x] **Password reset functionality**
- Implemented secure password reset mechanism
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
- [ ] **Two-Factor Authentication (2FA)**
- Consider adding 2FA, especially for admin accounts
- [ ] **Input validation and sanitization**
- Review all user inputs (API request bodies, query parameters)
- Ensure proper validation and sanitization
## Code Quality & Development
- [ ] **Code review process**
- Enforce code reviews for all changes
- [ ] **Environment configuration**
- Ensure secure management of environment-specific configurations
- [ ] **Dependency management**
- Periodically review dependencies for vulnerabilities
- Keep dependencies updated
- [ ] **Documentation updates**
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
- [ ] Verify "Dashboard Enhancements" are consistently applied
- [ ] Update documentation for improved layout and visual hierarchies

View File

@ -0,0 +1,187 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { Company } from "../../../lib/types";
interface CompanyConfigResponse {
company: Company;
}
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()) as CompanyConfigResponse;
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()) as CompanyConfigResponse;
setCompany(data.company);
} else {
const error = (await res.json()) as { message?: string; };
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&apos;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
View 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>
);
}

View File

@ -0,0 +1,434 @@
"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";
interface MetricsApiResponse {
metrics: MetricsResult;
company: Company;
}
// 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()) as MetricsApiResponse;
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()) as MetricsApiResponse;
setMetrics(data.metrics);
} else {
const errorData = (await res.json()) as { error: string; };
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 />;
}

View File

@ -1,440 +1,104 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; // Import useRouter
import {
SessionsLineChart,
CategoriesBarChart,
LanguagePieChart,
TokenUsageChart,
} from "../../components/Charts";
import DashboardSettings from "./settings";
import UserManagement from "./users";
import { Company, MetricsResult, WordCloudWord } from "../../lib/types"; // Added WordCloudWord
import MetricCard from "../../components/MetricCard";
import DonutChart from "../../components/DonutChart";
import WordCloud from "../../components/WordCloud";
import GeographicMap from "../../components/GeographicMap";
import ResponseTimeDistribution from "../../components/ResponseTimeDistribution";
import WelcomeBanner from "../../components/WelcomeBanner";
import { FC } from "react";
// Safely wrapped component with useSession
function DashboardContent() {
const { data: session, status } = useSession(); // Add status from useSession
const router = useRouter(); // Initialize useRouter
const [metrics, setMetrics] = useState<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";
const DashboardPage: FC = () => {
const { data: session, status } = useSession();
const router = useRouter();
const [loading, setLoading] = useState(true);
useEffect(() => {
// Redirect if not authenticated
// Once session is loaded, redirect appropriately
if (status === "unauthenticated") {
router.push("/login");
return; // Stop further execution in this effect
}
// Fetch metrics and company on mount if authenticated
if (status === "authenticated") {
const fetchData = async () => {
setLoading(true);
const res = await fetch("/api/dashboard/metrics");
const data = await res.json();
setMetrics(data.metrics);
setCompany(data.company);
} else if (status === "authenticated") {
setLoading(false);
};
fetchData();
}
}, [status, router]); // Add status and router to dependency array
}, [status, router]);
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>
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
<p className="text-lg text-gray-600">Loading dashboard...</p>
</div>
</div>
);
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>
<div className="space-y-6">
<div className="bg-white rounded-xl shadow p-6">
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
<p className="text-gray-600 mt-2 mb-4">
View your chat session metrics and analytics
</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}
onClick={() => router.push("/dashboard/overview")}
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
{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
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>
Sign Out
View Analytics
</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 className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
<p className="text-gray-600 mt-2 mb-4">
Browse and analyze conversation sessions
</p>
<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>
<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"
/>
{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 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
export default function DashboardPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
<div className="max-w-7xl mx-auto">
<DashboardContent />
</div>
</div>
);
}
};
export default DashboardPage;

View File

@ -8,6 +8,10 @@ import TranscriptViewer from "../../../../components/TranscriptViewer";
import { ChatSession } from "../../../../lib/types";
import Link from "next/link";
interface SessionApiResponse {
session: ChatSession;
}
export default function SessionViewPage() {
const params = useParams();
const router = useRouter(); // Initialize useRouter
@ -25,18 +29,18 @@ export default function SessionViewPage() {
if (status === "authenticated" && id) {
const fetchSession = async () => {
if (!session) setLoading(true);
setLoading(true); // Always set loading before fetch
setError(null);
try {
const response = await fetch(`/api/dashboard/session/${id}`);
if (!response.ok) {
const errorData = await response.json();
const errorData = (await response.json()) as { error: string; };
throw new Error(
errorData.error ||
`Failed to fetch session: ${response.statusText}`
);
}
const data = await response.json();
const data = (await response.json()) as SessionApiResponse;
setSession(data.session);
} catch (err) {
setError(
@ -52,7 +56,7 @@ export default function SessionViewPage() {
setError("Session ID is missing.");
setLoading(false);
}
}, [id, status, router, session]);
}, [id, status, router]); // session removed from dependencies
if (status === "loading") {
return (
@ -150,7 +154,8 @@ export default function SessionViewPage() {
<p className="text-gray-600">
No transcript content available for this session.
</p>
{session.fullTranscriptUrl && (
{session.fullTranscriptUrl &&
process.env.NODE_ENV !== "production" && (
<a
href={session.fullTranscriptUrl}
target="_blank"

View File

@ -14,6 +14,11 @@ interface FilterOptions {
languages: string[];
}
interface SessionsApiResponse {
sessions: ChatSession[];
totalSessions: number;
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [loading, setLoading] = useState(true);
@ -40,7 +45,7 @@ export default function SessionsPage() {
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const [pageSize, setPageSize] = useState(10); // Or make this configurable
useEffect(() => {
@ -58,7 +63,7 @@ export default function SessionsPage() {
if (!response.ok) {
throw new Error("Failed to fetch filter options");
}
const data = await response.json();
const data = (await response.json()) as FilterOptions;
setFilterOptions(data);
} catch (err) {
setError(
@ -88,7 +93,7 @@ export default function SessionsPage() {
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
}
const data = await response.json();
const data = (await response.json()) as SessionsApiResponse;
setSessions(data.sessions || []);
setTotalPages(Math.ceil((data.totalSessions || 0) / pageSize));
} catch (err) {
@ -283,8 +288,12 @@ export default function SessionsPage() {
Session ID: {session.sessionId || session.id}
</h2>
<p className="text-sm text-gray-500 mb-1">
Start Time: {new Date(session.startTime).toLocaleString()}
Start Time{/* (Local) */}:{" "}
{new Date(session.startTime).toLocaleString()}
</p>
{/* <p className="text-xs text-gray-400 mb-1">
Start Time (Raw API): {session.startTime.toString()}
</p> */}
{session.category && (
<p className="text-sm text-gray-700">
Category:{" "}

View File

@ -12,6 +12,10 @@ interface UserManagementProps {
session: UserSession;
}
interface UsersApiResponse {
users: UserItem[];
}
export default function UserManagement({ session }: UserManagementProps) {
const [users, setUsers] = useState<UserItem[]>([]);
const [email, setEmail] = useState<string>("");
@ -21,7 +25,7 @@ export default function UserManagement({ session }: UserManagementProps) {
useEffect(() => {
fetch("/api/dashboard/users")
.then((r) => r.json())
.then((data) => setUsers(data.users));
.then((data) => setUsers((data as UsersApiResponse).users));
}, []);
async function inviteUser() {

View File

@ -0,0 +1,216 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
interface UserItem {
id: string;
email: string;
role: string;
}
interface UsersApiResponse {
users: UserItem[];
}
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()) as UsersApiResponse;
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()) as { message?: string; };
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&apos;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>
);
}

View File

@ -1,11 +1 @@
body {
font-family: system-ui, sans-serif;
background: #f3f4f6;
}
input,
button {
font-family: inherit;
}
@import "tailwindcss";

View File

@ -21,9 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-100 min-h-screen font-sans">
<Providers>
<div className="max-w-5xl mx-auto py-8">{children}</div>
</Providers>
<Providers>{children}</Providers>
</body>
</html>
);

View File

@ -1,7 +1,7 @@
"use client";
import { useRef, useEffect } from "react";
import Chart from "chart.js/auto";
import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
interface DonutChartProps {
data: {
@ -77,9 +77,24 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
const label = context.label || "";
const value = context.formattedValue;
const total = context.chart.data.datasets[0].data.reduce(
(a: number, b: any) => a + (typeof b === "number" ? b : 0),
(
a: number,
b:
| number
| Point
| [number, number]
| BubbleDataPoint
| null
) => {
if (typeof b === "number") {
return a + b;
}
// Handle other types like Point, [number, number], BubbleDataPoint if necessary
// For now, we'll assume they don't contribute to the sum or are handled elsewhere
return a;
},
0
);
) as number;
const percentage = Math.round((context.parsed * 100) / total);
return `${label}: ${value} (${percentage}%)`;
},
@ -91,7 +106,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
? [
{
id: "centerText",
beforeDraw: function (chart: any) {
beforeDraw: function (chart: Chart<"doughnut">) {
const height = chart.height;
const ctx = chart.ctx;
ctx.restore();

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import "leaflet/dist/leaflet.css";
import countryLookup from "country-code-lookup";
import * as countryCoder from "@rapideditor/country-coder";
// Define types for country data
interface CountryData {
@ -18,36 +18,17 @@ interface GeographicMapProps {
height?: number; // Optional height for the container
}
// Get country coordinates from the country-code-lookup package
// Get country coordinates from the @rapideditor/country-coder package
const getCountryCoordinates = (): Record<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]> = {
// These are just in case the lookup fails for common countries
US: [37.0902, -95.7129],
GB: [55.3781, -3.436],
BA: [43.9159, 17.6791],
};
try {
// Get all countries from the package
const allCountries = countryLookup.countries;
// Map through all countries and extract coordinates
allCountries.forEach((country) => {
if (country.iso2 && country.latitude && country.longitude) {
coordinates[country.iso2] = [
parseFloat(country.latitude),
parseFloat(country.longitude),
];
}
});
// This function now primarily returns fallbacks.
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
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
@ -79,44 +60,68 @@ export default function GeographicMap({
// Process country data when client is ready and dependencies change
useEffect(() => {
if (!isClient) return;
if (!isClient || !countries) return;
try {
// Generate CountryData array for the Map component
const data: CountryData[] = Object.entries(countries)
// Only include countries with known coordinates
.filter(([code]) => {
// If no coordinates found, log to help with debugging
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
// eslint-disable-next-line no-console
console.warn(`Missing coordinates for country code: ${code}`);
return false;
const data: CountryData[] = Object.entries(countries || {})
.map(([code, count]) => {
let countryCoords: [number, number] | undefined =
countryCoordinates[code] || DEFAULT_COORDINATES[code];
if (!countryCoords) {
const feature = countryCoder.feature(code);
if (feature && feature.geometry) {
if (feature.geometry.type === "Point") {
const [lon, lat] = feature.geometry.coordinates;
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
} else if (
feature.geometry.type === "Polygon" &&
feature.geometry.coordinates &&
feature.geometry.coordinates[0] &&
feature.geometry.coordinates[0][0]
) {
// For Polygons, use the first coordinate of the first ring as a fallback representative point
const [lon, lat] = feature.geometry.coordinates[0][0];
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
} else if (
feature.geometry.type === "MultiPolygon" &&
feature.geometry.coordinates &&
feature.geometry.coordinates[0] &&
feature.geometry.coordinates[0][0] &&
feature.geometry.coordinates[0][0][0]
) {
// For MultiPolygons, use the first coordinate of the first ring of the first polygon
const [lon, lat] = feature.geometry.coordinates[0][0][0];
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
}
return true;
})
.map(([code, count]) => ({
}
}
if (countryCoords) {
return {
code,
count,
coordinates: countryCoordinates[code] ||
DEFAULT_COORDINATES[code] || [0, 0],
}));
coordinates: countryCoords,
};
}
return null; // Skip if no coordinates found
})
.filter((item): item is CountryData => item !== null);
// Log for debugging
// eslint-disable-next-line no-console
console.log(
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
);
setCountryData(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error processing geographic data:", error);
setCountryData([]);
}
}, [countries, countryCoordinates, isClient]);
// Find the max count for scaling circles - handle empty countries object
const countryValues = Object.values(countries);
// Find the max count for scaling circles - handle empty or null countries object
const countryValues = countries ? Object.values(countries) : [];
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
// Show loading state during SSR or until client-side rendering takes over

View File

@ -4,7 +4,7 @@ interface MetricCardProps {
title: string;
value: string | number | null | undefined;
description?: string;
icon?: string;
icon?: React.ReactNode;
trend?: {
value: number;
label?: string;
@ -67,9 +67,6 @@ export default function MetricCard({
>
{trend.isPositive !== false ? "↑" : "↓"}{" "}
{Math.abs(trend.value).toFixed(1)}%
{trend.label && (
<span className="text-gray-500 ml-1">{trend.label}</span>
)}
</span>
)}
</div>

View File

@ -7,28 +7,30 @@ import annotationPlugin from "chartjs-plugin-annotation";
Chart.register(annotationPlugin);
interface ResponseTimeDistributionProps {
responseTimes: number[];
data: number[];
average: number;
targetResponseTime?: number;
}
export default function ResponseTimeDistribution({
responseTimes,
data,
average,
targetResponseTime,
}: ResponseTimeDistributionProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !responseTimes.length) return;
if (!ref.current || !data || !data.length) return;
const ctx = ref.current.getContext("2d");
if (!ctx) return;
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
const maxTime = Math.ceil(Math.max(...responseTimes));
const maxTime = Math.ceil(Math.max(...data));
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
// Count responses in each bin
responseTimes.forEach((time) => {
data.forEach((time) => {
const binIndex = Math.min(Math.floor(time), bins.length - 1);
bins[binIndex]++;
});
@ -63,27 +65,41 @@ export default function ResponseTimeDistribution({
responsive: true,
plugins: {
legend: { display: false },
annotation: targetResponseTime
? {
annotation: {
annotations: {
targetLine: {
averageLine: {
type: "line",
yMin: 0,
yMax: Math.max(...bins),
xMin: average,
xMax: average,
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 2,
label: {
display: true,
content: "Avg: " + average.toFixed(1) + "s",
position: "start",
},
},
targetLine: targetResponseTime
? {
type: "line",
yMin: 0,
yMax: Math.max(...bins),
xMin: targetResponseTime,
xMax: targetResponseTime,
borderColor: "rgba(75, 192, 192, 1)",
borderColor: "rgba(75, 192, 192, 0.7)",
borderWidth: 2,
label: {
display: true,
content: "Target",
position: "start",
},
},
position: "end",
},
}
: undefined,
},
},
},
scales: {
y: {
beginAtZero: true,
@ -103,7 +119,7 @@ export default function ResponseTimeDistribution({
});
return () => chart.destroy();
}, [responseTimes, targetResponseTime]);
}, [data, average, targetResponseTime]);
return <canvas ref={ref} height={180} />;
}

View File

@ -146,7 +146,8 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
{/* Fallback to link only if we only have the URL but no content - this might also be redundant if parent handles all transcript display */}
{(!session.transcriptContent ||
session.transcriptContent.length === 0) &&
session.fullTranscriptUrl && (
session.fullTranscriptUrl &&
process.env.NODE_ENV !== "production" && (
<div className="flex justify-between pt-2">
<span className="text-gray-600">Transcript:</span>
<a

357
components/Sidebar.tsx Normal file
View 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>
</>
);
}

View File

@ -2,7 +2,7 @@
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; // Import rehype-raw
import rehypeRaw from "rehype-raw";
interface TranscriptViewerProps {
transcriptContent: string;
@ -23,6 +23,7 @@ function formatTranscript(content: string): React.ReactNode[] {
const elements: React.ReactNode[] = [];
let currentSpeaker: string | null = null;
let currentMessages: string[] = [];
let currentTimestamp: string | null = null;
// Process each line
lines.forEach((line) => {
@ -32,8 +33,15 @@ function formatTranscript(content: string): React.ReactNode[] {
return;
}
// Check if this is a new speaker line
if (line.startsWith("User:") || line.startsWith("Assistant:")) {
// Check if this is a new speaker line with or without datetime
// Format 1: [29.05.2025 21:26:44] User: message
// Format 2: User: message
const datetimeMatch = line.match(
/^\[([^\]]+)\]\s*(User|Assistant):\s*(.*)$/
);
const simpleMatch = line.match(/^(User|Assistant):\s*(.*)$/);
if (datetimeMatch || simpleMatch) {
// If we have accumulated messages for a previous speaker, add them
if (currentSpeaker && currentMessages.length > 0) {
elements.push(
@ -48,6 +56,11 @@ function formatTranscript(content: string): React.ReactNode[] {
: "bg-gray-100 text-gray-800"
}`}
>
{currentTimestamp && (
<div className="text-xs opacity-60 mb-1">
{currentTimestamp}
</div>
)}
{currentMessages.map((msg, i) => (
// Use ReactMarkdown to render each message part
<ReactMarkdown
@ -55,7 +68,7 @@ function formatTranscript(content: string): React.ReactNode[] {
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
components={{
p: "span",
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
a: ({ node: _node, ...props }) => (
<a
className="text-sky-600 hover:text-sky-800 underline"
@ -73,13 +86,23 @@ function formatTranscript(content: string): React.ReactNode[] {
currentMessages = [];
}
// Set the new current speaker
currentSpeaker = line.startsWith("User:") ? "User" : "Assistant";
// Add the content after "User:" or "Assistant:"
const messageContent = line.substring(line.indexOf(":") + 1).trim();
if (datetimeMatch) {
// Format with datetime: [29.05.2025 21:26:44] User: message
currentTimestamp = datetimeMatch[1];
currentSpeaker = datetimeMatch[2];
const messageContent = datetimeMatch[3].trim();
if (messageContent) {
currentMessages.push(messageContent);
}
} else if (simpleMatch) {
// Format without datetime: User: message
currentTimestamp = null;
currentSpeaker = simpleMatch[1];
const messageContent = simpleMatch[2].trim();
if (messageContent) {
currentMessages.push(messageContent);
}
}
} else if (currentSpeaker) {
// This is a continuation of the current speaker's message
currentMessages.push(line);
@ -100,6 +123,9 @@ function formatTranscript(content: string): React.ReactNode[] {
: "bg-gray-100 text-gray-800"
}`}
>
{currentTimestamp && (
<div className="text-xs opacity-60 mb-1">{currentTimestamp}</div>
)}
{currentMessages.map((msg, i) => (
// Use ReactMarkdown to render each message part
<ReactMarkdown
@ -107,7 +133,7 @@ function formatTranscript(content: string): React.ReactNode[] {
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
components={{
p: "span",
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
a: ({ node: _node, ...props }) => (
<a
className="text-sky-600 hover:text-sky-800 underline"
@ -138,6 +164,9 @@ export default function TranscriptViewer({
const formattedElements = formatTranscript(transcriptContent);
// Hide "View Full Raw" button in production environment
const isProduction = process.env.NODE_ENV === "production";
return (
<div className="bg-white shadow-lg rounded-lg p-4 md:p-6 mt-6">
<div className="flex justify-between items-center mb-4">
@ -145,7 +174,7 @@ export default function TranscriptViewer({
Session Transcript
</h2>
<div className="flex items-center space-x-3">
{transcriptUrl && (
{transcriptUrl && !isProduction && (
<a
href={transcriptUrl}
target="_blank"

View File

@ -2,15 +2,7 @@
import { useRef, useEffect, useState } from "react";
import { select } from "d3-selection";
import cloud from "d3-cloud";
interface CloudWord {
text: string;
size: number;
x?: number;
y?: number;
rotate?: number;
}
import cloud, { Word } from "d3-cloud";
interface WordCloudProps {
words: {
@ -19,20 +11,55 @@ interface WordCloudProps {
}[];
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
}
export default function WordCloud({
words,
width = 500,
height = 300,
width: initialWidth = 500,
height: initialHeight = 300,
minWidth = 200,
minHeight = 200,
}: WordCloudProps) {
const svgRef = useRef<SVGSVGElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [isClient, setIsClient] = useState(false);
const [dimensions, setDimensions] = useState({
width: initialWidth,
height: initialHeight,
});
// Set isClient to true on initial render
useEffect(() => {
setIsClient(true);
}, []);
// Add effect to detect container size changes
useEffect(() => {
if (!containerRef.current || !isClient) return;
// Create ResizeObserver to detect size changes
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
// Ensure minimum dimensions
const newWidth = Math.max(width, minWidth);
const newHeight = Math.max(height, minHeight);
setDimensions({ width: newWidth, height: newHeight });
}
});
// Start observing the container
resizeObserver.observe(containerRef.current);
// Cleanup
return () => {
resizeObserver.disconnect();
};
}, [isClient, minWidth, minHeight]);
// Effect to render the word cloud whenever dimensions or words change
useEffect(() => {
if (!svgRef.current || !isClient || !words.length) return;
@ -44,7 +71,7 @@ export default function WordCloud({
// Configure the layout
const layout = cloud()
.size([width, height])
.size([dimensions.width, dimensions.height])
.words(
words.map((d) => ({
text: d.text,
@ -53,20 +80,23 @@ export default function WordCloud({
)
.padding(5)
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
.fontSize((d) => (d as any).size)
.fontSize((d: Word) => d.size || 10)
.on("end", draw);
layout.start();
function draw(words: CloudWord[]) {
function draw(words: Word[]) {
svg
.append("g")
.attr("transform", `translate(${width / 2},${height / 2})`)
.attr(
"transform",
`translate(${dimensions.width / 2},${dimensions.height / 2})`
)
.selectAll("text")
.data(words)
.enter()
.append("text")
.style("font-size", (d: CloudWord) => `${d.size}px`)
.style("font-size", (d: Word) => `${d.size || 10}px`)
.style("font-family", "Inter, Arial, sans-serif")
.style("fill", () => {
// Create a nice gradient of colors
@ -85,17 +115,17 @@ export default function WordCloud({
.attr("text-anchor", "middle")
.attr(
"transform",
(d: CloudWord) =>
(d: Word) =>
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
)
.text((d: CloudWord) => d.text);
.text((d: Word) => d.text || "");
}
// Cleanup function
return () => {
svg.selectAll("*").remove();
};
}, [words, width, height, isClient]);
}, [words, dimensions, isClient]);
if (!isClient) {
return (
@ -106,12 +136,21 @@ export default function WordCloud({
}
return (
<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
ref={svgRef}
width={width}
height={height}
width={dimensions.width}
height={dimensions.height}
className="w-full h-full"
aria-label="Word cloud visualization of categories"
style={{
maxWidth: "100%",
maxHeight: "100%",
}}
/>
</div>
);

227
docs/D1_CLI_ACCESS.md Normal file
View File

@ -0,0 +1,227 @@
# D1 Database Command Line Access
This guide shows you how to access and manage your Cloudflare D1 database `d1-notso-livedash` from the command line.
## Quick Reference
### Using the Custom D1 CLI Script
```bash
# Simple and fast commands
pnpm d1 tables # List all tables
pnpm d1 info # Database information
pnpm d1 schema User # Show table schema
pnpm d1 query "SELECT COUNT(*) FROM User" # Execute SQL
pnpm d1 export backup.sql # Export database
# Remote (production) commands
pnpm d1 --remote info # Production database info
pnpm d1 --remote query "SELECT * FROM Company LIMIT 5"
```
### Using Package.json Scripts
```bash
# Database information
pnpm d1:list # List all D1 databases
pnpm d1:info # Local database info
pnpm d1:info:remote # Remote database info
# Backup and export
pnpm d1:export # Export local database
pnpm d1:export:remote # Export remote database
pnpm d1:schema # Export schema only
```
### Direct Wrangler Commands
```bash
# Basic operations
npx wrangler d1 list
npx wrangler d1 info d1-notso-livedash
npx wrangler d1 execute d1-notso-livedash --command "SELECT * FROM User"
# Remote operations (add --remote flag)
npx wrangler d1 info d1-notso-livedash --remote
npx wrangler d1 execute d1-notso-livedash --remote --command "SELECT COUNT(*) FROM Company"
```
## Database Schema
Your D1 database contains these tables:
### Company Table
```sql
- id (TEXT, PRIMARY KEY)
- name (TEXT, NOT NULL)
- csvUrl (TEXT, NOT NULL)
- csvUsername (TEXT)
- csvPassword (TEXT)
- sentimentAlert (REAL)
- dashboardOpts (TEXT)
- createdAt (DATETIME, NOT NULL, DEFAULT CURRENT_TIMESTAMP)
- updatedAt (DATETIME, NOT NULL)
```
### User Table
```sql
- id (TEXT, PRIMARY KEY)
- email (TEXT, NOT NULL)
- password (TEXT, NOT NULL)
- companyId (TEXT, NOT NULL)
- role (TEXT, NOT NULL)
- resetToken (TEXT)
- resetTokenExpiry (DATETIME)
```
### Session Table
```sql
- id (TEXT, PRIMARY KEY)
- userId (TEXT, NOT NULL)
- expiresAt (DATETIME, NOT NULL)
```
## Common SQL Queries
### Data Exploration
```sql
-- Check table sizes
SELECT 'Company' as table_name, COUNT(*) as count FROM Company
UNION ALL
SELECT 'User' as table_name, COUNT(*) as count FROM User
UNION ALL
SELECT 'Session' as table_name, COUNT(*) as count FROM Session;
-- Show all table names
SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;
-- Get table schema
PRAGMA table_info(User);
```
### Business Queries
```sql
-- List companies with user counts
SELECT c.name, c.id, COUNT(u.id) as user_count
FROM Company c
LEFT JOIN User u ON c.id = u.companyId
GROUP BY c.id, c.name;
-- Find admin users
SELECT u.email, c.name as company
FROM User u
JOIN Company c ON u.companyId = c.id
WHERE u.role = 'admin';
-- Active sessions
SELECT COUNT(*) as active_sessions
FROM Session
WHERE expiresAt > datetime('now');
```
## Local vs Remote Databases
- **Local Database**: Located at `.wrangler/state/v3/d1/` (for development)
- **Remote Database**: Cloudflare's production D1 database
### When to Use Each:
- **Local**: Development, testing, safe experimentation
- **Remote**: Production data, deployment verification
## Database Statistics
Current database info:
- **Database ID**: d4ee7efe-d37a-48e4-bed7-fdfaa5108131
- **Region**: WEUR (Western Europe)
- **Size**: ~53.2 kB
- **Tables**: 6 (including system tables)
- **Read Queries (24h)**: 65
- **Write Queries (24h)**: 8
## Scripts Available
### `/scripts/d1.js` (Recommended)
Simple, fast CLI for common operations:
```bash
node scripts/d1.js tables
node scripts/d1.js schema User
node scripts/d1.js query "SELECT * FROM Company"
node scripts/d1.js --remote info
```
### `/scripts/d1-query.js`
Simple query executor:
```bash
node scripts/d1-query.js "SELECT COUNT(*) FROM User"
node scripts/d1-query.js --remote "SELECT * FROM Company"
```
### `/scripts/d1-manager.js`
Comprehensive database management (if needed for advanced operations):
```bash
node scripts/d1-manager.js info
node scripts/d1-manager.js backup
```
## Backup and Recovery
### Create Backups
```bash
# Quick backup
pnpm d1 export backup_$(date +%Y%m%d).sql
# Automated backup with timestamp
npx wrangler d1 export d1-notso-livedash --output backups/backup_$(date +%Y%m%d_%H%M%S).sql
# Schema only backup
npx wrangler d1 export d1-notso-livedash --no-data --output schema.sql
```
### Restore from Backup
```bash
# Apply SQL file to database
npx wrangler d1 execute d1-notso-livedash --file backup.sql
```
## Troubleshooting
### Common Issues
1. **"wrangler not found"**: Use `npx wrangler` instead of `wrangler`
2. **Permission denied**: Ensure you're logged into Cloudflare: `npx wrangler login`
3. **Database not found**: Check `wrangler.json` for correct binding name
### Debug Commands
```bash
# Check Wrangler authentication
npx wrangler whoami
# Verify database configuration
npx wrangler d1 list
# Test database connectivity
npx wrangler d1 execute d1-notso-livedash --command "SELECT 1"
```
## Security Notes
- Local database is for development only
- Never expose production database credentials
- Use `--remote` flag carefully in production
- Regular backups are recommended for production data

20
e2e/example.spec.ts Normal file
View 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();
});

View File

@ -26,13 +26,13 @@ const eslintConfig = [
"coverage/",
],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"react/no-unescaped-entities": "off",
"no-console": "warn",
"no-trailing-spaces": "error",
"react/no-unescaped-entities": "warn",
"no-console": "off",
"no-trailing-spaces": "warn",
"prefer-const": "error",
"no-unused-vars": "off",
"no-unused-vars": "warn",
},
},
];

View File

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

View File

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

View File

@ -13,295 +13,309 @@ interface CompanyConfig {
sentimentAlert?: number;
}
// Helper function to calculate trend percentages
function calculateTrendPercentage(current: number, previous: number): number {
if (previous === 0) return 0; // Avoid division by zero
return ((current - previous) / previous) * 100;
}
// Mock data for previous period - in a real app, this would come from database
const mockPreviousPeriodData = {
totalSessions: 120,
uniqueUsers: 85,
avgSessionLength: 240, // in seconds
avgResponseTime: 1.7, // in seconds
};
// List of common stop words - this can be expanded
const stopWords = new Set([
"assistant",
"user",
// Web
"bmp",
"co",
"com",
"www",
"http",
"https",
"www2",
"css",
"gif",
"href",
"html",
"php",
"js",
"css",
"xml",
"json",
"txt",
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"org",
"net",
"co",
"http",
"https",
"io",
"jpeg",
"jpg",
"js",
"json",
"net",
"org",
"php",
"png",
"svg",
"txt",
"www",
"www2",
"xml",
// English stop words
"a",
"about",
"above",
"after",
"again",
"against",
"ain",
"all",
"am",
"an",
"the",
"is",
"any",
"are",
"was",
"were",
"aren",
"at",
"be",
"been",
"before",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"should",
"below",
"between",
"both",
"by",
"bye",
"can",
"could",
"may",
"might",
"must",
"am",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"mine",
"yours",
"hers",
"ours",
"theirs",
"to",
"of",
"in",
"on",
"at",
"by",
"for",
"with",
"about",
"against",
"between",
"into",
"through",
"during",
"before",
"after",
"above",
"below",
"from",
"up",
"couldn",
"d",
"did",
"didn",
"do",
"does",
"doesn",
"don",
"down",
"out",
"off",
"over",
"under",
"again",
"further",
"then",
"once",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"any",
"both",
"during",
"each",
"few",
"for",
"from",
"further",
"goodbye",
"had",
"hadn",
"has",
"hasn",
"have",
"haven",
"he",
"hello",
"her",
"here",
"hers",
"hi",
"him",
"his",
"how",
"i",
"in",
"into",
"is",
"isn",
"it",
"its",
"just",
"ll",
"m",
"ma",
"may",
"me",
"might",
"mightn",
"mine",
"more",
"most",
"other",
"some",
"such",
"must",
"mustn",
"my",
"needn",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
"too",
"very",
"s",
"t",
"just",
"don",
"shouldve",
"now",
"d",
"ll",
"m",
"o",
"re",
"ve",
"y",
"ain",
"aren",
"couldn",
"didn",
"doesn",
"hadn",
"hasn",
"haven",
"isn",
"ma",
"mightn",
"mustn",
"needn",
"shan",
"shouldn",
"wasn",
"weren",
"won",
"wouldn",
"hi",
"hello",
"thanks",
"thank",
"please",
"of",
"off",
"ok",
"okay",
"yes",
"on",
"once",
"only",
"other",
"our",
"ours",
"out",
"over",
"own",
"please",
"re",
"s",
"same",
"shan",
"she",
"should",
"shouldn",
"shouldve",
"so",
"some",
"such",
"t",
"than",
"thank",
"thanks",
"the",
"their",
"theirs",
"them",
"then",
"there",
"they",
"through",
"to",
"too",
"under",
"up",
"us",
"ve",
"very",
"was",
"wasn",
"we",
"were",
"weren",
"when",
"where",
"why",
"will",
"with",
"won",
"would",
"wouldn",
"y",
"yeah",
"bye",
"goodbye",
"yes",
"you",
"your",
"yours",
// French stop words
"des",
"donc",
"et",
"la",
"le",
"les",
"mais",
"ou",
"un",
"une",
"des",
"et",
"ou",
"mais",
"donc",
// Dutch stop words
"dit",
"ben",
"de",
"het",
"ik",
"jij",
"hij",
"zij",
"wij",
"jullie",
"deze",
"dit",
"dat",
"die",
"een",
"en",
"of",
"maar",
"want",
"omdat",
"dus",
"als",
"ook",
"dan",
"nu",
"nog",
"al",
"naar",
"voor",
"van",
"door",
"met",
"bij",
"tot",
"om",
"over",
"tussen",
"onder",
"boven",
"tegen",
"aan",
"uit",
"sinds",
"tijdens",
"binnen",
"buiten",
"zonder",
"volgens",
"dankzij",
"ondanks",
"behalve",
"mits",
"tenzij",
"hoewel",
"al",
"alhoewel",
"toch",
"als",
"anders",
"echter",
"wel",
"niet",
"geen",
"iets",
"niets",
"veel",
"weinig",
"meer",
"meest",
"elk",
"ieder",
"sommige",
"hoe",
"wat",
"waar",
"wie",
"wanneer",
"waarom",
"welke",
"wordt",
"worden",
"werd",
"werden",
"geworden",
"zijn",
"behalve",
"ben",
"ben",
"bent",
"was",
"waren",
"bij",
"binnen",
"boven",
"buiten",
"dan",
"dankzij",
"dat",
"de",
"deze",
"die",
"dit",
"dit",
"door",
"dus",
"echter",
"een",
"elk",
"en",
"geen",
"gehad",
"geweest",
"hebben",
"heb",
"hebt",
"heeft",
"geworden",
"had",
"hadden",
"gehad",
"kunnen",
"heb",
"hebben",
"hebt",
"heeft",
"het",
"hij",
"hoe",
"hoewel",
"ieder",
"iets",
"ik",
"jij",
"jullie",
"kan",
"kunt",
"kon",
"konden",
"zullen",
"kunnen",
"kunt",
"maar",
"meer",
"meest",
"met",
"mits",
"naar",
"niet",
"niets",
"nog",
"nu",
"of",
"om",
"omdat",
"ondanks",
"onder",
"ook",
"over",
"sinds",
"sommige",
"tegen",
"tenzij",
"tijdens",
"toch",
"tot",
"tussen",
"uit",
"van",
"veel",
"volgens",
"voor",
"waar",
"waarom",
"wanneer",
"want",
"waren",
"was",
"wat",
"weinig",
"wel",
"welke",
"werd",
"werden",
"wie",
"wij",
"worden",
"wordt",
"zal",
"zij",
"zijn",
"zonder",
"zullen",
"zult",
// Add more domain-specific stop words if necessary
]);
@ -310,156 +324,266 @@ export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {}
): MetricsResult {
const total = sessions.length;
const totalSessions = sessions.length; // Renamed from 'total' for clarity
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
const byCountry: CountryMetrics = {}; // Added for country data
const byCountry: CountryMetrics = {};
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
let escalated = 0,
forwarded = 0;
let totalSentiment = 0,
sentimentCount = 0;
let totalResponse = 0,
responseCount = 0;
let totalTokens = 0,
totalTokensEur = 0;
let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
// For sentiment distribution
let sentimentPositive = 0,
sentimentNegative = 0,
sentimentNeutral = 0;
// Variables for calculations
const uniqueUserIds = new Set<string>();
let totalSessionDuration = 0;
let validSessionsForDuration = 0;
let totalResponseTime = 0;
let validSessionsForResponseTime = 0;
let sentimentPositiveCount = 0;
let sentimentNeutralCount = 0;
let sentimentNegativeCount = 0;
let totalTokens = 0;
let totalTokensEur = 0;
const wordCounts: { [key: string]: number } = {};
let alerts = 0;
// Calculate total session duration in minutes
let totalDuration = 0;
let durationCount = 0;
const wordCounts: { [key: string]: number } = {}; // For WordCloud
sessions.forEach((s) => {
const day = s.startTime.toISOString().slice(0, 10);
byDay[day] = (byDay[day] || 0) + 1;
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry
// Process token usage by day
if (s.tokens) {
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
for (const session of sessions) {
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
let identifierAdded = false;
if (session.ipAddress && session.ipAddress.trim() !== "") {
uniqueUserIds.add(session.ipAddress.trim());
identifierAdded = true;
}
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
if (
!identifierAdded &&
session.sessionId &&
session.sessionId.trim() !== ""
) {
uniqueUserIds.add(session.sessionId.trim());
}
// Process token cost by day
if (s.tokensEur) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
// Avg. Session Time
if (session.startTime && session.endTime) {
const startTimeMs = new Date(session.startTime).getTime();
const endTimeMs = new Date(session.endTime).getTime();
if (isNaN(startTimeMs)) {
console.warn(
`[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}`
);
}
if (isNaN(endTimeMs)) {
console.warn(
`[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
);
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) {
const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta
// Use the absolute difference for duration, ensuring it's not negative.
// If times are identical, duration will be 0.
// If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference.
const duration = Math.abs(timeDifference);
// console.log(
// `[metrics] duration is ${duration} for session ${session.id || session.sessionId}`
// );
// Sanity check: Only include sessions with reasonable durations (less than 24 hours)
const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
totalDuration += duration;
durationCount++;
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
}
if (s.escalated) escalated++;
if (s.forwardedHr) forwarded++;
if (s.sentiment != null) {
totalSentiment += s.sentiment;
sentimentCount++;
// Classify sentiment
if (s.sentiment > 0.3) {
sentimentPositive++;
} else if (s.sentiment < -0.3) {
sentimentNegative++;
} else {
sentimentNeutral++;
if (!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.avgResponseTime != null) {
totalResponse += s.avgResponseTime;
responseCount++;
// Avg. Response Time
if (
session.avgResponseTime !== undefined &&
session.avgResponseTime !== null &&
session.avgResponseTime >= 0
) {
totalResponseTime += session.avgResponseTime;
validSessionsForResponseTime++;
}
totalTokens += s.tokens || 0;
totalTokensEur += s.tokensEur || 0;
// Escalated and Forwarded
if (session.escalated) escalatedCount++;
if (session.forwardedHr) forwardedHrCount++;
// 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
// Sentiment
if (session.sentiment !== undefined && session.sentiment !== null) {
// Example thresholds, adjust as needed
if (session.sentiment > 0.3) sentimentPositiveCount++;
else if (session.sentiment < -0.3) sentimentNegativeCount++;
else sentimentNeutralCount++;
}
// Sentiment Alert Check
if (
companyConfig.sentimentAlert !== undefined &&
session.sentiment !== undefined &&
session.sentiment !== null &&
session.sentiment < companyConfig.sentimentAlert
) {
alerts++;
}
// Tokens
if (session.tokens !== undefined && session.tokens !== null) {
totalTokens += session.tokens;
}
if (session.tokensEur !== undefined && session.tokensEur !== null) {
totalTokensEur += session.tokensEur;
}
// Daily metrics
const day = new Date(session.startTime).toISOString().split("T")[0];
byDay[day] = (byDay[day] || 0) + 1; // Sessions per day
if (session.tokens !== undefined && session.tokens !== null) {
tokensByDay[day] = (tokensByDay[day] || 0) + session.tokens;
}
if (session.tokensEur !== undefined && session.tokensEur !== null) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + session.tokensEur;
}
// Category metrics
if (session.category) {
byCategory[session.category] = (byCategory[session.category] || 0) + 1;
}
// Language metrics
if (session.language) {
byLanguage[session.language] = (byLanguage[session.language] || 0) + 1;
}
// Country metrics
if (session.country) {
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
}
// Word Cloud Data (from initial message and transcript content)
const processTextForWordCloud = (text: string | undefined | null) => {
if (!text) return;
const words = text
.toLowerCase()
.replace(/[^\w\s'-]/gi, "")
.split(/\s+/); // Keep apostrophes and hyphens
for (const word of words) {
const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens
if (
cleanedWord &&
!stopWords.has(cleanedWord) &&
cleanedWord.length > 2
) {
// Check if not a stop word and length > 2
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
});
}
}
});
// Now add sentiment alert logic:
let belowThreshold = 0;
const threshold = companyConfig.sentimentAlert ?? null;
if (threshold != null) {
for (const s of sessions) {
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
}
};
processTextForWordCloud(session.initialMsg);
processTextForWordCloud(session.transcriptContent);
}
// Calculate average sessions per day
const dayCount = Object.keys(byDay).length;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
// Calculate average session length
const uniqueUsers = uniqueUserIds.size;
const avgSessionLength =
durationCount > 0 ? totalDuration / durationCount : null;
validSessionsForDuration > 0
? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes
: 0;
const avgResponseTime =
validSessionsForResponseTime > 0
? totalResponseTime / validSessionsForResponseTime
: 0; // in seconds
// Prepare wordCloudData
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
.map(([text, value]) => ({ text, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 500); // Take top 500 words
.sort(([, a], [, b]) => b - a)
.slice(0, 50) // Top 50 words
.map(([text, value]) => ({ text, value }));
// Calculate avgSessionsPerDay
const numDaysWithSessions = Object.keys(byDay).length;
const avgSessionsPerDay =
numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0;
// Calculate trends
const totalSessionsTrend = calculateTrendPercentage(
totalSessions,
mockPreviousPeriodData.totalSessions
);
const uniqueUsersTrend = calculateTrendPercentage(
uniqueUsers,
mockPreviousPeriodData.uniqueUsers
);
const avgSessionLengthTrend = calculateTrendPercentage(
avgSessionLength,
mockPreviousPeriodData.avgSessionLength
);
const avgResponseTimeTrend = calculateTrendPercentage(
avgResponseTime,
mockPreviousPeriodData.avgResponseTime
);
// console.log("Debug metrics calculation:", {
// totalSessionDuration,
// validSessionsForDuration,
// calculatedAvgSessionLength: avgSessionLength,
// });
return {
totalSessions: total,
avgSessionsPerDay,
avgSessionLength,
days: byDay,
languages: byLanguage,
categories: byCategory, // This will be empty if we are not using categories for word cloud
countries: byCountry, // Added countries to the result
belowThresholdCount: belowThreshold,
// Additional metrics not in the interface - using type assertion
escalatedCount: escalated,
forwardedCount: forwarded,
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
lastUpdated: Date.now(), // Add current timestamp
// New metrics for enhanced dashboard
sentimentPositiveCount: sentimentPositive,
sentimentNeutralCount: sentimentNeutral,
sentimentNegativeCount: sentimentNegative,
totalSessions,
uniqueUsers,
avgSessionLength, // Corrected to match MetricsResult interface
avgResponseTime, // Corrected to match MetricsResult interface
escalatedCount,
forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
sentimentPositiveCount,
sentimentNeutralCount,
sentimentNegativeCount,
days: byDay, // Corrected to match MetricsResult interface (days)
categories: byCategory, // Corrected to match MetricsResult interface (categories)
languages: byLanguage, // Corrected to match MetricsResult interface (languages)
countries: byCountry, // Corrected to match MetricsResult interface (countries)
tokensByDay,
tokensCostByDay,
wordCloudData, // Added word cloud data
totalTokens,
totalTokensEur,
wordCloudData,
belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount)
avgSessionsPerDay, // Added to satisfy MetricsResult interface
// Map trend values to the expected property names in MetricsResult
sessionTrend: totalSessionsTrend,
usersTrend: uniqueUsersTrend,
avgSessionTimeTrend: avgSessionLengthTrend,
// For response time, a negative trend is actually positive (faster responses are better)
avgResponseTimeTrend: -avgResponseTimeTrend, // Invert as lower response time is better
// Additional fields
sentimentThreshold: companyConfig.sentimentAlert,
lastUpdated: Date.now(),
totalSessionDuration,
validSessionsForDuration,
};
}

View File

@ -1,5 +1,6 @@
// Simple Prisma client setup
// Prisma client setup with support for Cloudflare D1
import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";
// Add prisma to the NodeJS global type
// This approach avoids NodeJS.Global which is not available
@ -9,12 +10,24 @@ declare const global: {
prisma: PrismaClient | undefined;
};
// Initialize Prisma Client
const prisma = global.prisma || new PrismaClient();
// Check if we're running in Cloudflare Workers environment
const isCloudflareWorker = typeof globalThis.DB !== 'undefined';
// Save in global if we're in development
if (process.env.NODE_ENV !== "production") {
// Initialize Prisma Client
let prisma: PrismaClient;
if (isCloudflareWorker) {
// In Cloudflare Workers, use D1 adapter
const adapter = new PrismaD1(globalThis.DB);
prisma = new PrismaClient({ adapter });
} else {
// In Next.js/Node.js, use regular SQLite
prisma = global.prisma || new PrismaClient();
// Save in global if we're in development
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma;
}
}
export { prisma };

View File

@ -10,6 +10,41 @@ interface SessionCreateData {
[key: string]: unknown;
}
/**
* Fetches transcript content from a URL with optional authentication
* @param url The URL to fetch the transcript from
* @param username Optional username for Basic Auth
* @param password Optional password for Basic Auth
* @returns The transcript content or null if fetching fails
*/
async function fetchTranscriptContent(
url: string,
username?: string,
password?: string
): Promise<string | null> {
try {
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
const response = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!response.ok) {
process.stderr.write(
`Error fetching transcript: ${response.statusText}\n`
);
return null;
}
return await response.text();
} catch (error) {
process.stderr.write(`Failed to fetch transcript: ${error}\n`);
return null;
}
}
export function startScheduler() {
cron.schedule("*/15 * * * *", async () => {
const companies = await prisma.company.findMany();
@ -23,6 +58,16 @@ export function startScheduler() {
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
// Fetch transcript content if URL is available
let transcriptContent: string | null = null;
if (session.fullTranscriptUrl) {
transcriptContent = await fetchTranscriptContent(
session.fullTranscriptUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined
);
}
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
@ -51,6 +96,8 @@ export function startScheduler() {
? session.messagesSent
: 0,
category: session.category || null,
fullTranscriptUrl: session.fullTranscriptUrl || null,
transcriptContent: transcriptContent, // Add the transcript content
},
});
}

View File

@ -131,6 +131,17 @@ export interface MetricsResult {
tokensByDay?: DayMetrics;
tokensCostByDay?: DayMetrics;
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
// Properties for overview page cards and trends
uniqueUsers?: number;
sessionTrend?: number; // e.g., percentage change in totalSessions
usersTrend?: number; // e.g., percentage change in uniqueUsers
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
// Debug properties
totalSessionDuration?: number;
validSessionsForDuration?: number;
}
export interface ApiResponse<T> {

View File

@ -0,0 +1,54 @@
-- Initial database schema for LiveDash-Node
-- This combines the init migration and transcript_content addition
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"csvUrl" TEXT NOT NULL,
"csvUsername" TEXT,
"csvPassword" TEXT,
"sentimentAlert" REAL,
"dashboardOpts" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiry" DATETIME,
CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"companyId" TEXT NOT NULL,
"startTime" DATETIME NOT NULL,
"endTime" DATETIME NOT NULL,
"ipAddress" TEXT,
"country" TEXT,
"language" TEXT,
"messagesSent" INTEGER,
"sentiment" REAL,
"escalated" BOOLEAN,
"forwardedHr" BOOLEAN,
"fullTranscriptUrl" TEXT,
"transcriptContent" TEXT,
"avgResponseTime" REAL,
"tokens" INTEGER,
"tokensEur" REAL,
"category" TEXT,
"initialMsg" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
reactStrictMode: true,
// Allow cross-origin requests from specific origins in development

9047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,66 @@
{
"name": "livedash-node",
"version": "0.1.0",
"private": true,
"type": "module",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"format": "pnpm dlx prettier --write .",
"format:check": "pnpm dlx prettier --check .",
"format:standard": "pnpm dlx standard . --fix",
"lint": "next lint",
"lint:fix": "eslint --fix './**/*.{ts,tsx}'",
"format": "prettier --write .",
"lint:fix": "pnpm dlx eslint --fix",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "node prisma/seed.mjs"
"prisma:seed": "node prisma/seed.mjs",
"prisma:studio": "prisma studio",
"start": "next start",
"lint:md": "markdownlint-cli2 \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"",
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"",
"cf-typegen": "wrangler types",
"check": "tsc && wrangler deploy --dry-run",
"deploy": "wrangler deploy",
"dev": "next dev",
"dev:old": "next dev --turbopack",
"dev:cf": "wrangler dev",
"predeploy": "wrangler d1 migrations apply DB --remote",
"seedLocalD1": "wrangler d1 migrations apply DB --local",
"d1:list": "wrangler d1 list",
"d1:info": "wrangler d1 info d1-notso-livedash",
"d1:info:remote": "wrangler d1 info d1-notso-livedash --remote",
"d1:query": "node scripts/d1-query.js",
"d1:export": "wrangler d1 export d1-notso-livedash",
"d1:export:remote": "wrangler d1 export d1-notso-livedash --remote",
"d1:backup": "wrangler d1 export d1-notso-livedash --output backups/$(date +%Y%m%d_%H%M%S)_backup.sql",
"d1:schema": "wrangler d1 export d1-notso-livedash --no-data --output schema.sql",
"d1": "node scripts/d1.js"
},
"dependencies": {
"@prisma/adapter-d1": "^6.8.2",
"@prisma/client": "^6.8.2",
"@rapideditor/country-coder": "^5.4.0",
"@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9",
"@types/d3-selection": "^3.0.11",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.18",
"@types/node-fetch": "^2.6.12",
"bcryptjs": "^3.0.2",
"chart.js": "^4.0.0",
"chart.js": "^4.4.9",
"chartjs-plugin-annotation": "^3.1.0",
"country-code-lookup": "^0.1.3",
"csv-parse": "^5.5.0",
"csv-parse": "^5.6.0",
"d3": "^7.9.0",
"d3-cloud": "^1.2.7",
"d3-selection": "^3.0.0",
"i18n-iso-countries": "^7.14.0",
"iso-639-1": "^3.1.5",
"leaflet": "^1.9.4",
"next": "^15.3.2",
"next": "^15.3.3",
"next-auth": "^4.24.11",
"node-cron": "^4.0.6",
"node-cron": "^4.1.0",
"node-fetch": "^3.3.2",
"react": "^19.1.0",
"react-chartjs-2": "^5.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
@ -44,23 +68,93 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.27.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/bcryptjs": "^2.4.2",
"@types/node": "^22.15.21",
"@types/node-cron": "^3.0.8",
"@types/react": "^19.1.5",
"@eslint/js": "^9.28.0",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.8",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.15.29",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.27.0",
"eslint-config-next": "^15.3.2",
"eslint-plugin-prettier": "^5.4.0",
"postcss": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.33.0",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"eslint-plugin-prettier": "^5.4.1",
"markdownlint-cli2": "^0.18.1",
"postcss": "^8.5.4",
"prettier": "^3.5.3",
"prettier-plugin-jinja-template": "^2.1.0",
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tailwindcss": "^4.1.8",
"ts-node": "^10.9.2",
"typescript": "^5.0.0"
"typescript": "^5.8.3",
"wrangler": "4.18.0"
},
"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"
]
},
"cloudflare": {
"label": "Worker + D1 Database",
"products": [
"Workers",
"D1"
],
"categories": [
"storage"
],
"icon_urls": [
"https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/c6fc5da3-1e0a-4608-b2f1-9628577ec800/public",
"https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/5ca0ca32-e897-4699-d4c1-6b680512f000/public"
],
"docs_url": "https://developers.cloudflare.com/d1/",
"preview_image_url": "https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/cb7cb0a9-6102-4822-633c-b76b7bb25900/public",
"publish": true
}
}

View File

@ -12,13 +12,27 @@ interface SessionCreateData {
}
/**
* Fetches transcript content from a URL
* Fetches transcript content from a URL with optional authentication
* @param url The URL to fetch the transcript from
* @param username Optional username for Basic Auth
* @param password Optional password for Basic Auth
* @returns The transcript content or null if fetching fails
*/
async function fetchTranscriptContent(url: string): Promise<string | null> {
async function fetchTranscriptContent(
url: string,
username?: string,
password?: string
): Promise<string | null> {
try {
const response = await fetch(url);
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
const response = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!response.ok) {
process.stderr.write(
`Error fetching transcript: ${response.statusText}\n`
@ -111,7 +125,9 @@ export default async function handler(
let transcriptContent: string | null = null;
if (session.fullTranscriptUrl) {
transcriptContent = await fetchTranscriptContent(
session.fullTranscriptUrl
session.fullTranscriptUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined
);
}

View File

@ -24,6 +24,12 @@ export default async function handler(
data: { csvUrl },
});
res.json({ ok: true });
} else if (req.method === "GET") {
// Get company data
const company = await prisma.company.findUnique({
where: { id: user.companyId },
});
res.json({ company });
} else {
res.status(405).end();
}

View File

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

View File

@ -1,27 +1,20 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & {
body: {
email: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// Type the body with a type assertion
const { email } = req.body as { email: string };
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy

View File

@ -1,34 +1,43 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & {
body: {
token: string;
password: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
req: NextApiRequest, // Use official NextApiRequest
res: NextApiResponse // Use official NextApiResponse
) {
if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body;
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// It's good practice to explicitly type the expected body for clarity and safety
const { token, password } = req.body as { token?: string; password?: string };
if (!token || !password) {
return res.status(400).json({ error: "Token and password are required." });
}
if (password.length < 8) {
// Example: Add password complexity rule
return res
.status(400)
.json({ error: "Password must be at least 8 characters long." });
}
try {
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
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({
@ -39,5 +48,16 @@ export default async function handler(
resetTokenExpiry: null,
},
});
res.status(200).end();
// 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
View 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,
},
});

6883
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,12 @@
// Database schema, one company = one org, linked to users and CSV config
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
url = env("DATABASE_URL")
}
model Company {

184
scripts/d1-manager.js Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* Comprehensive D1 Database Management Script
*
* Usage Examples:
* node scripts/d1-manager.js tables
* node scripts/d1-manager.js schema Company
* node scripts/d1-manager.js count User
* node scripts/d1-manager.js query "SELECT * FROM User LIMIT 5"
* node scripts/d1-manager.js backup
* node scripts/d1-manager.js --remote query "SELECT COUNT(*) FROM Session"
*/
import { execSync } from 'child_process';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
const DB_NAME = 'd1-notso-livedash';
const args = process.argv.slice(2);
// Parse flags
const isRemote = args.includes('--remote');
const filteredArgs = args.filter(arg => !arg.startsWith('--'));
if (filteredArgs.length === 0) {
showHelp();
process.exit(1);
}
const command = filteredArgs[ 0 ];
const params = filteredArgs.slice(1);
function showHelp() {
console.log(`
🗄️ D1 Database Manager for ${DB_NAME}
Usage: node scripts/d1-manager.js [--remote] <command> [params...]
Commands:
info Show database information
tables List all tables
schema <table> Show table schema
count <table> Count rows in table
query "<sql>" Execute custom SQL query
backup [filename] Export database to SQL file
backup-schema Export just the schema
recent-logs Show recent query activity
Flags:
--remote Execute against remote D1 (production)
Examples:
node scripts/d1-manager.js tables
node scripts/d1-manager.js schema User
node scripts/d1-manager.js count Company
node scripts/d1-manager.js query "SELECT * FROM User WHERE role = 'admin'"
node scripts/d1-manager.js backup
node scripts/d1-manager.js --remote info
`);
}
function execute(sql, silent = false) {
const remoteFlag = isRemote ? '--remote' : '';
const cmd = `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --command "${sql}"`;
if (!silent) {
console.log(`🔍 Executing${isRemote ? ' (remote)' : ' (local)'}: ${sql}\\n`);
}
try {
return execSync(cmd, { encoding: 'utf8' });
} catch (error) {
console.error('❌ Query failed:', error.message);
process.exit(1);
}
}
function wranglerCommand(subcommand, silent = false) {
const remoteFlag = isRemote ? '--remote' : '';
const cmd = `npx wrangler d1 ${subcommand} ${DB_NAME} ${remoteFlag}`;
if (!silent) {
console.log(`📊 Running: ${cmd}\\n`);
}
try {
return execSync(cmd, { stdio: 'inherit' });
} catch (error) {
console.error('❌ Command failed:', error.message);
process.exit(1);
}
}
switch (command) {
case 'info':
wranglerCommand('info');
break;
case 'tables':
console.log('📋 Listing all tables:\\n');
execute("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name;");
break;
case 'schema':
if (!params[ 0 ]) {
console.error('❌ Please specify a table name');
console.log('Usage: node scripts/d1-manager.js schema <table_name>');
process.exit(1);
}
console.log(`🏗️ Schema for table '${params[ 0 ]}':\\n`);
execute(`PRAGMA table_info(${params[ 0 ]});`);
break;
case 'count':
if (!params[ 0 ]) {
console.error('❌ Please specify a table name');
console.log('Usage: node scripts/d1-manager.js count <table_name>');
process.exit(1);
}
console.log(`🔢 Row count for table '${params[ 0 ]}':\\n`);
execute(`SELECT COUNT(*) as row_count FROM ${params[ 0 ]};`);
break;
case 'query':
if (!params[ 0 ]) {
console.error('❌ Please specify a SQL query');
console.log('Usage: node scripts/d1-manager.js query "SELECT * FROM table"');
process.exit(1);
}
execute(params[ 0 ]);
break;
case 'backup':
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = params[ 0 ] || `backup_${timestamp}.sql`;
try {
mkdirSync('backups', { recursive: true });
} catch (e) {
// Directory might already exist
}
const backupPath = join('backups', filename);
console.log(`💾 Creating backup: ${backupPath}\\n`);
wranglerCommand(`export --output ${backupPath}`);
console.log(`\\n✅ Backup created successfully: ${backupPath}`);
break;
case 'backup-schema':
try {
mkdirSync('backups', { recursive: true });
} catch (e) {
// Directory might already exist
}
console.log('📜 Exporting schema only...\\n');
wranglerCommand('export --no-data --output backups/schema.sql');
console.log('\\n✅ Schema exported to backups/schema.sql');
break;
case 'recent-logs':
console.log('📊 Recent database activity:\\n');
try {
wranglerCommand('insights');
} catch (error) {
console.log(' Insights not available for this database');
}
break;
case 'all-tables-info':
console.log('📊 Information about all tables:\\n');
const tables = [ 'Company', 'User', 'Session' ];
for (const table of tables) {
console.log(`\\n🏷 Table: ${table}`);
console.log('─'.repeat(50));
execute(`SELECT COUNT(*) as row_count FROM ${table};`);
}
break;
default:
console.error(`❌ Unknown command: ${command}`);
showHelp();
process.exit(1);
}

36
scripts/d1-query.js Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Simple D1 query helper script
* Usage: node scripts/d1-query.js "SELECT * FROM User LIMIT 5"
* Usage: node scripts/d1-query.js --remote "SELECT COUNT(*) FROM Company"
*/
import { execSync } from 'child_process';
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: node scripts/d1-query.js [--remote] "SQL_QUERY"');
console.log('Examples:');
console.log(' node scripts/d1-query.js "SELECT * FROM User LIMIT 5"');
console.log(' node scripts/d1-query.js --remote "SELECT COUNT(*) FROM Company"');
process.exit(1);
}
const isRemote = args.includes('--remote');
const query = args[ args.length - 1 ];
if (!query || query.startsWith('--')) {
console.error('Error: Please provide a SQL query');
process.exit(1);
}
const remoteFlag = isRemote ? '--remote' : '';
const command = `npx wrangler d1 execute d1-notso-livedash ${remoteFlag} --command "${query}"`;
try {
console.log(`🔍 Executing${isRemote ? ' (remote)' : ' (local)'}: ${query}\n`);
execSync(command, { stdio: 'inherit' });
} catch (error) {
console.error('Query failed:', error.message);
process.exit(1);
}

89
scripts/d1.js Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Simple D1 Database CLI
* Usage: node scripts/d1.js <command> [args...]
*/
import { execSync } from 'child_process';
const DB_NAME = 'd1-notso-livedash';
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(`
🗄️ Simple D1 CLI for ${DB_NAME}
Usage: node scripts/d1.js <command> [args...]
Commands:
list List databases
info Show database info
tables List all tables
schema <table> Show table schema
query "<sql>" Execute SQL query
export [file] Export database
Add --remote flag for production database
Examples:
node scripts/d1.js tables
node scripts/d1.js schema User
node scripts/d1.js query "SELECT COUNT(*) FROM Company"
node scripts/d1.js --remote info
`);
process.exit(0);
}
const isRemote = args.includes('--remote');
const filteredArgs = args.filter(arg => !arg.startsWith('--'));
const [ command, ...params ] = filteredArgs;
const remoteFlag = isRemote ? '--remote' : '';
function run(cmd) {
try {
console.log(`💫 ${cmd}`);
execSync(cmd, { stdio: 'inherit' });
} catch (error) {
console.error('❌ Command failed');
process.exit(1);
}
}
switch (command) {
case 'list':
run('npx wrangler d1 list');
break;
case 'info':
run(`npx wrangler d1 info ${DB_NAME} ${remoteFlag}`);
break;
case 'tables':
run(`npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --command "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"`);
break;
case 'schema':
if (!params[ 0 ]) {
console.error('❌ Please specify table name');
process.exit(1);
}
run(`npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --command "PRAGMA table_info(${params[ 0 ]})"`);
break;
case 'query':
if (!params[ 0 ]) {
console.error('❌ Please specify SQL query');
process.exit(1);
}
run(`npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --command "${params[ 0 ]}"`);
break;
case 'export':
const filename = params[ 0 ] || `backup_${new Date().toISOString().slice(0, 10)}.sql`;
run(`npx wrangler d1 export ${DB_NAME} ${remoteFlag} --output ${filename}`);
break;
default:
console.error(`❌ Unknown command: ${command}`);
process.exit(1);
}

View File

@ -16,6 +16,7 @@ async function main() {
select: {
id: true,
fullTranscriptUrl: true,
companyId: true,
},
});
@ -28,10 +29,44 @@ async function main() {
let successCount = 0;
let errorCount = 0;
// Group sessions by company to fetch credentials once per company
const sessionsByCompany = new Map<string, typeof sessionsToUpdate>();
for (const session of sessionsToUpdate) {
if (!sessionsByCompany.has(session.companyId)) {
sessionsByCompany.set(session.companyId, []);
}
sessionsByCompany.get(session.companyId)!.push(session);
}
for (const [companyId, companySessions] of Array.from(
sessionsByCompany.entries()
)) {
// Fetch company credentials once per company
const company = await prisma.company.findUnique({
where: { id: companyId },
select: {
csvUsername: true,
csvPassword: true,
name: true,
},
});
if (!company) {
console.warn(`Company ${companyId} not found, skipping sessions.`);
errorCount += companySessions.length;
continue;
}
console.log(
`Processing ${companySessions.length} sessions for company: ${company.name}`
);
for (const session of companySessions) {
if (!session.fullTranscriptUrl) {
// Should not happen due to query, but good for type safety
console.warn(`Session ${session.id} has no fullTranscriptUrl, skipping.`);
console.warn(
`Session ${session.id} has no fullTranscriptUrl, skipping.`
);
continue;
}
@ -39,7 +74,19 @@ async function main() {
`Fetching transcript for session ${session.id} from ${session.fullTranscriptUrl}...`
);
try {
const response = await fetch(session.fullTranscriptUrl);
// Prepare authentication if credentials are available
const authHeader =
company.csvUsername && company.csvPassword
? "Basic " +
Buffer.from(
`${company.csvUsername}:${company.csvPassword}`
).toString("base64")
: undefined;
const response = await fetch(session.fullTranscriptUrl, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!response.ok) {
console.error(
`Failed to fetch transcript for session ${session.id}: ${response.status} ${response.statusText}`
@ -71,6 +118,7 @@ async function main() {
errorCount++;
}
}
}
console.log("Transcript fetching complete.");
console.log(`Successfully updated: ${successCount} sessions.`);

231
src/index.ts Normal file
View File

@ -0,0 +1,231 @@
// Cloudflare Worker entry point for LiveDash-Node
// This file handles requests when deployed to Cloudflare Workers
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { formatError } from './utils/error';
export interface Env {
DB: D1Database;
NEXTAUTH_SECRET?: string;
NEXTAUTH_URL?: string;
WORKER_ENV?: string; // 'development' | 'production'
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
// Initialize Prisma with D1 adapter
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter });
const url = new URL(request.url);
// CORS headers for all responses
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Handle API routes
if (url.pathname.startsWith('/api/')) {
// Simple health check endpoint
if (url.pathname === '/api/health') {
const companyCount = await prisma.company.count();
const sessionCount = await prisma.session.count();
return new Response(
JSON.stringify({
status: 'healthy',
database: 'connected',
companies: companyCount,
sessions: sessionCount,
timestamp: new Date().toISOString()
}),
{
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
}
);
}
// Test metrics endpoint
if (url.pathname === '/api/test-metrics') {
const sessions = await prisma.session.findMany({
take: 10,
orderBy: { startTime: 'desc' }
});
return new Response(
JSON.stringify({
message: 'LiveDash API running on Cloudflare Workers with D1',
recentSessions: sessions.length,
sessions: sessions
}),
{
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
}
);
}
// For other API routes, return a placeholder response
return new Response(
JSON.stringify({
message: 'API endpoint not implemented in worker yet',
path: url.pathname,
method: request.method,
note: 'This endpoint needs to be migrated from Next.js API routes'
}),
{
status: 501,
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
}
);
}
// Handle root path - simple test page
if (url.pathname === '/') {
try {
const companies = await prisma.company.findMany();
const recentSessions = await prisma.session.findMany({
take: 5,
orderBy: { startTime: 'desc' },
include: { company: { select: { name: true } } }
});
return new Response(
`
<!DOCTYPE html>
<html>
<head>
<title>LiveDash-Node on Cloudflare Workers</title>
<link rel="stylesheet" type="text/css" href="https://static.integrations.cloudflare.com/styles.css">
<style>
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0; }
.card { background: #f8f9fa; padding: 20px; border-radius: 8px; border: 1px solid #e9ecef; }
pre { background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; font-size: 12px; }
.api-list { list-style: none; padding: 0; }
.api-list li { margin: 8px 0; }
.api-list a { color: #0066cc; text-decoration: none; }
.api-list a:hover { text-decoration: underline; }
.status { color: #28a745; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<header>
<img
src="https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/30e0d3f6-6076-40f8-7abb-8a7676f83c00/public"
/>
<h1>🎉 LiveDash-Node Successfully Connected to D1!</h1>
<p class="status">✓ Database Connected | ✓ Prisma Client Working | ✓ D1 Adapter Active</p>
</header>
<div class="grid">
<div class="card">
<h3>📊 Database Stats</h3>
<ul>
<li><strong>Companies:</strong> ${companies.length}</li>
<li><strong>Recent Sessions:</strong> ${recentSessions.length}</li>
</ul>
</div>
<div class="card">
<h3>🔗 Test API Endpoints</h3>
<ul class="api-list">
<li><a href="/api/health">/api/health</a> - Health check</li>
<li><a href="/api/test-metrics">/api/test-metrics</a> - Sample data</li>
</ul>
</div>
</div>
<div class="card">
<h3>🏢 Companies in Database</h3>
<pre>${companies.length > 0 ? JSON.stringify(companies, null, 2) : 'No companies found'}</pre>
</div>
<div class="card">
<h3>📈 Recent Sessions</h3>
<pre>${recentSessions.length > 0 ? JSON.stringify(recentSessions, null, 2) : 'No sessions found'}</pre>
</div>
<footer style="margin-top: 40px; text-align: center; color: #666;">
<small>
<a target="_blank" href="https://developers.cloudflare.com/d1/">Learn more about Cloudflare D1</a> |
<a target="_blank" href="https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-cloudflare-workers">Prisma + Workers Guide</a>
</small>
</footer>
</div>
</body>
</html>
`,
{
headers: {
'Content-Type': 'text/html',
...corsHeaders
},
}
);
} catch (dbError) {
return new Response(
`
<!DOCTYPE html>
<html>
<head><title>LiveDash-Node - Database Error</title></head>
<body>
<h1>❌ Database Connection Error</h1>
<p>Error: ${dbError instanceof Error ? dbError.message : 'Unknown database error'}</p>
<p>Check your D1 database configuration and make sure migrations have been applied.</p>
</body>
</html>
`,
{
status: 500,
headers: { 'Content-Type': 'text/html' },
}
);
}
}
// Handle all other routes
return new Response('Not Found - This endpoint is not available in the worker deployment', {
status: 404,
headers: corsHeaders
});
} catch (error) {
console.error('Worker error:', error); // Log full error details, including stack trace
// Use the formatError utility to properly format the error response
const errorPayload = formatError(error, env);
return new Response(
JSON.stringify(errorPayload),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
}
);
}
},
};

16
src/utils/error.ts Normal file
View File

@ -0,0 +1,16 @@
export function formatError(error: unknown, env?: { WORKER_ENV?: string }): Record<string, unknown> {
const payload: Record<string, unknown> = {
error: 'Internal Server Error',
message: error instanceof Error ? error.message : 'Unknown error'
};
// Only include stack trace in development environment
// In Cloudflare Workers, check environment via env parameter
const isDevelopment = env?.WORKER_ENV !== 'production';
if (isDevelopment) {
payload.stack = error instanceof Error ? error.stack : undefined;
}
return payload;
}

View 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);
}

36
tests/formatError.test.ts Normal file
View File

@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { formatError } from '../src/utils/error';
const originalEnv = process.env.NODE_ENV;
test('includes stack when not in production', () => {
const err = new Error('boom');
const payload = formatError(err, { WORKER_ENV: 'development' });
assert.ok('stack' in payload);
});
test('omits stack in production', () => {
const err = new Error('boom');
const payload = formatError(err, { WORKER_ENV: 'production' });
assert.ok(!('stack' in payload));
});
test('includes message for all environments', () => {
const err = new Error('boom');
const devPayload = formatError(err, { WORKER_ENV: 'development' });
const prodPayload = formatError(err, { WORKER_ENV: 'production' });
assert.strictEqual(devPayload.message, 'boom');
assert.strictEqual(prodPayload.message, 'boom');
});
test('handles non-Error objects', () => {
const payload = formatError('string error', { WORKER_ENV: 'development' });
assert.strictEqual(payload.message, 'Unknown error');
assert.strictEqual(payload.error, 'Internal Server Error');
});
test.after(() => {
if (originalEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = originalEnv;
});

View File

@ -1,36 +1,39 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": false, // Allow implicit any types
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node", // bundler
"noEmit": true,
"noImplicitAny": false, // Allow implicit any types
"preserveSymlinks": false,
"types": ["./worker-configuration.d.ts"],
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
"strictNullChecks": true
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"target": "ESNext"
},
"exclude": ["node_modules"],
"include": [
"src",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"components/SessionDetails.tsx.bak"
],
"exclude": ["node_modules"]
]
}

6870
worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

55
wrangler.json Normal file
View File

@ -0,0 +1,55 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"compatibility_date": "2025-04-01",
"main": "src/index.ts",
"name": "livedash",
"upload_source_maps": true,
"d1_databases": [
{
"binding": "DB",
"database_id": "d4ee7efe-d37a-48e4-bed7-fdfaa5108131",
"database_name": "d1-notso-livedash"
}
],
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}