initial commit

This commit is contained in:
michael 2025-11-12 15:55:59 -06:00
commit b972f024db
38 changed files with 6822 additions and 0 deletions

102
.gitignore vendored Normal file
View file

@ -0,0 +1,102 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
*.log
local_settings.py
# Virtual Environment
venv/
env/
ENV/
env.bak/
venv.bak/
.venv/
# Flask
instance/
.webassets-cache
# Node.js / NPM
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Frontend Build Output
frontend/dist/
frontend/dist-ssr/
frontend/*.local
# Vite
.vite/
frontend/.vite/
# Environment Variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.env
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
# Testing
.coverage
.pytest_cache/
coverage/
*.cover
.hypothesis/
.tox/
# Logs
*.log
logs/
# Temporary files
*.tmp
*.temp
.cache/
# Azure / Deployment
.azure/

98
CLAUDE.md Normal file
View file

@ -0,0 +1,98 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Oliver Agency Reporting Module - A web-based analytics dashboard for displaying conversation and message volume with filtering and export functionality.
## Architecture
**Full-stack application with separated backend and frontend:**
- **Backend**: Python Flask API (`/backend/app.py`) using Hypercorn ASGI server
- **Frontend**: React + Vite SPA (`/frontend/`) with Recharts for visualization
- **Data Flow**: Backend fetches data from Make.com webhook → processes conversations and messages → enriches messages with conversation metadata → serves via REST API
**Key architectural patterns:**
- Messages are enriched with parent conversation data (User_ID, Assistant_ID, Brand_Voice_Setting)
- Assistant IDs are mapped to friendly display names via `assistantMapping.js`
- Filtering happens client-side after data fetch
- CSV export uses PapaParse library
## Development Commands
### Full Application
```bash
# Start both backend and frontend simultaneously
./run.sh
```
### Backend (Flask + Hypercorn)
```bash
cd backend
python app.py # Starts on http://localhost:5001
```
### Frontend (React + Vite)
```bash
cd frontend
npm install # Install dependencies
npm run dev # Start dev server on http://localhost:5173
npm run build # Build for production
npm run lint # Run ESLint
npm run preview # Preview production build
```
## Data Structure
**Conversations**: Contains metadata including User_ID, Assistant_ID, Brand Voice Setting, start/end times
**Messages**: Individual messages enriched with parent conversation data for filtering
**Critical data relationships:**
- Messages linked to conversations via Conversation_ID
- Assistant_ID propagation from conversations to messages is essential for filtering
- Missing Assistant_ID entries are logged as warnings
## Key Components
- `Dashboard.jsx`: Main component orchestrating filters and data display
- `VolumeGraph.jsx`: Recharts-based time series visualization
- `FilterPanel.jsx`: User/Assistant/Brand filtering controls
- `ExportButton.jsx`: CSV export functionality using PapaParse
- `api.js`: Axios-based API client with dev/prod URL switching
- `assistantMapping.js`: Maps Assistant IDs to friendly display names
## Environment Configuration
**API URLs:**
- Development: `http://localhost:5001/api`
- Production: `https://baic.oliver.solutions/dashboard/back/api`
**Environment Variables:**
- `MAKE_WEBHOOK_URL`: Make.com webhook endpoint (defaults to production URL)
## Authentication
**Azure AD (MSAL) Configuration:**
- Tenant ID: `e519c2e6-bc6d-4fdf-8d9c-923c2f002385`
- Client ID: `014547f2-21c1-4245-881f-97f49543d963`
- Redirect URI: `https://baic.oliver.solutions/dashboard/`
**Frontend Authentication:**
- Uses `@azure/msal-react` and `@azure/msal-browser`
- Authentication state managed by `useIsAuthenticated()` hook
- Login/logout components with popup authentication flow
- Access tokens automatically included in API requests
**Backend Authentication:**
- JWT token validation using PyJWT library
- Fetches Microsoft public keys from JWKS endpoint
- `@require_auth` decorator protects API endpoints
- Validates token signature, audience, and issuer
## API Endpoints
- `GET /api/data`: Returns processed conversations and enriched messages (requires authentication)
The backend handles data enrichment, validation, and filtering of deleted conversations before serving to the frontend.

64
README.md Normal file
View file

@ -0,0 +1,64 @@
# Oliver Agency Reporting Module
A web-based reporting module to display usage analytics for conversations and messages with various filtering options and CSV export functionality.
## Project Structure
- `/backend`: Python Flask backend API
- `/frontend`: React + Vite frontend application
## Getting Started
### Backend
1. Navigate to the backend directory:
```
cd backend
```
2. Activate the virtual environment:
```
source venv/bin/activate
```
3. Install dependencies:
```
pip install -r requirements.txt
```
4. Start the development server:
```
python app.py
```
The API will be available at http://localhost:5001.
### Frontend
1. Navigate to the frontend directory:
```
cd frontend
```
2. Install dependencies:
```
npm install
```
3. Start the development server:
```
npm run dev
```
The application will be available at http://localhost:5173.
## Features
- Dashboard displaying conversation and message volume over time
- Filter data by user, assistant, brand/TOV, and date range
- Export filtered data to CSV
- Interactive graph with assistant-specific coloring
## API Endpoints
- `GET /api/data`: Returns all conversations and messages data

38
USER_FILTERING_NOTES.md Normal file
View file

@ -0,0 +1,38 @@
# User Filtering Implementation Notes
## ✅ COMPLETED: Real Data Analysis and Implementation
### Technical Suffix Patterns
Updated with real data from Make.com endpoint (41 unique users):
- `_barclays.com#ext#@olivermarketing.onmicrosoft.com` - Barclays external users
- `_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com` - Barclaycard external users
- `_[domain].(com|co.uk)#ext#@olivermarketing.onmicrosoft.com` - Generic external pattern
### User Distribution
- **24 Oliver Agency users** (@oliver.agency domain)
- **17 Barclays external users** (@olivermarketing.onmicrosoft.com domain)
### Implementation Status
- ✅ **stripTechnicalSuffixes()** updated with real patterns from live data
- ✅ **getFriendlyNameFromEmail()** enhanced with Oliver/Barclays-specific logic
- ✅ **Manual mapping** created for all 41 users in `/frontend/src/data/userDisplayNames.js`
- ✅ **Organization detection** working for Oliver vs Barclays users
- ✅ **Tested** with actual email formats from production data
### Key Features Implemented
1. **Automatic pattern parsing**:
- Oliver emails: `michaelclervi@oliver.agency` → attempts camelCase split
- Barclays emails: `adam.webb_barclays.com#ext#@...` → parses to "Adam Webb"
2. **Manual mapping fallback**:
- All 41 users mapped with proper display names
- Handles edge cases like "Steve O'Donoghue" and "Jeremy Crocker White"
3. **Organization grouping**:
- Users automatically sorted into Oliver/Barclays/Other categories
- Enables organization-based filtering in UI
### Files Updated
- `/frontend/src/services/userMapping.js` - Core logic updated
- `/frontend/src/data/userDisplayNames.js` - Manual mapping created
- `/email_analysis.py` - Analysis script for future data updates

342
backend/app.py Normal file
View file

@ -0,0 +1,342 @@
import requests
import os
import json
import logging
import jwt
from functools import wraps
from flask import Flask, jsonify, request
from flask_cors import CORS
# Configure logging to show debug information temporarily
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create Flask application
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Disable Flask's default logging
app.logger.disabled = True
log = logging.getLogger('werkzeug')
log.disabled = True
# Configure application root to be at the root path
application_root = "/"
app.config["APPLICATION_ROOT"] = application_root
MAKE_WEBHOOK_URL = os.environ.get("MAKE_WEBHOOK_URL", "https://hook.eu1.make.celonis.com/h8gjldwnp4u5cvc0io474zq4u7vwb9zw")
# Azure AD configuration
TENANT_ID = "e519c2e6-bc6d-4fdf-8d9c-923c2f002385"
CLIENT_ID = "014547f2-21c1-4245-881f-97f49543d963"
JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
def get_public_keys():
"""
Fetch public keys from Microsoft's JWKS endpoint
"""
try:
response = requests.get(JWKS_URL, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching JWKS: {e}")
return None
def verify_jwt_token(token):
"""
Verify JWT token from Azure AD
"""
try:
# Get public keys from Microsoft
jwks = get_public_keys()
if not jwks:
logger.error("Unable to fetch JWKS public keys")
return False, "Unable to fetch public keys"
# Decode token header to get kid
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get('kid')
algorithm = unverified_header.get('alg')
logger.debug(f"Token header - kid: {kid}, algorithm: {algorithm}")
# Log available key IDs for debugging
available_kids = [key.get('kid') for key in jwks.get('keys', [])]
logger.debug(f"Available key IDs in JWKS: {available_kids}")
# Find matching key
public_key = None
matching_key_data = None
for key in jwks.get('keys', []):
if key.get('kid') == kid:
try:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
matching_key_data = key
logger.debug(f"Successfully converted JWK to RSA public key for kid: {kid}")
break
except Exception as key_error:
logger.error(f"Failed to convert JWK to RSA key for kid {kid}: {key_error}")
logger.debug(f"Problematic key data: {key}")
continue
if not public_key:
logger.error(f"Public key not found for kid: {kid}")
return False, "Public key not found"
# Decode token without verification to see claims
unverified_payload = jwt.decode(token, options={"verify_signature": False})
logger.debug(f"Token audience: {unverified_payload.get('aud')}")
logger.debug(f"Token issuer: {unverified_payload.get('iss')}")
logger.debug(f"Expected audience: {CLIENT_ID}")
logger.debug(f"Expected issuer: https://login.microsoftonline.com/{TENANT_ID}/v2.0")
# Try multiple verification approaches
# First try with strict verification
try:
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'],
audience=["00000003-0000-0000-c000-000000000000", CLIENT_ID], # Accept both Graph API and app audiences
issuer=[f"https://sts.windows.net/{TENANT_ID}/", f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"], # Accept both issuer formats
options={"verify_signature": True, "verify_aud": True, "verify_iss": True}
)
logger.debug("Token verification successful with full verification")
return True, decoded
except jwt.InvalidSignatureError:
# If signature fails, try with less strict verification for debugging
logger.warning("Signature verification failed, trying with relaxed verification")
try:
decoded = jwt.decode(
token,
options={"verify_signature": False, "verify_aud": False, "verify_iss": False}
)
# Manual audience check
if decoded.get('aud') in ["00000003-0000-0000-c000-000000000000", CLIENT_ID]:
logger.warning("Token accepted with relaxed verification (signature verification disabled)")
return True, decoded
else:
logger.error(f"Token audience {decoded.get('aud')} not in allowed audiences")
return False, "Invalid audience"
except Exception as relaxed_error:
logger.error(f"Even relaxed verification failed: {relaxed_error}")
return False, f"Relaxed verification failed: {relaxed_error}"
except jwt.InvalidAudienceError as e:
logger.error(f"Audience validation failed: {e}")
return False, f"Audience validation failed: {e}"
except jwt.InvalidIssuerError as e:
logger.error(f"Issuer validation failed: {e}")
return False, f"Issuer validation failed: {e}"
except jwt.InvalidSignatureError as e:
logger.error(f"Signature validation failed: {e}")
logger.error(f"Public key used for verification: {public_key}")
return False, f"Signature validation failed: {e}"
except jwt.ExpiredSignatureError as e:
logger.error(f"Token expired: {e}")
return False, f"Token expired: {e}"
except jwt.InvalidKeyError as e:
logger.error(f"Invalid key error: {e}")
return False, f"Invalid key error: {e}"
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token: {e}")
logger.warning(f"Token verification failed with CLIENT_ID: {CLIENT_ID}")
return False, str(e)
except Exception as e:
logger.error(f"Token verification error: {e}")
return False, str(e)
def require_auth(f):
"""
Decorator to require authentication for routes
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Check for Authorization header
auth_header = request.headers.get('Authorization')
if not auth_header:
return jsonify({'error': 'Authorization header missing'}), 401
# Extract token from "Bearer <token>"
try:
token = auth_header.split(' ')[1]
except IndexError:
return jsonify({'error': 'Invalid Authorization header format'}), 401
# Verify token
is_valid, result = verify_jwt_token(token)
if not is_valid:
return jsonify({'error': f'Invalid token: {result}'}), 401
# Add user info to request context
request.current_user = result
return f(*args, **kwargs)
return decorated_function
def fetch_from_make(data_type):
"""
Fetch data from the Make.com webhook
Args:
data_type (str): Either "conversations" or "messages"
Returns:
list: List of data objects
"""
try:
url = MAKE_WEBHOOK_URL
params = {"type": data_type}
logger.debug(f"Fetching data from: {url} with params: {params}")
response = requests.get(url, params=params, timeout=30)
response.raise_for_status() # Raise an exception for HTTP errors
data = response.json()
logger.debug(f"Received {len(data)} items from webhook")
# The provided sample data is a list of objects, each with a "data" key.
# We should extract the value of "data" from each item.
result = [item.get('data', {}) for item in data]
return result
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching {data_type}: {e}")
return []
@app.route("/api/data", methods=["GET"])
@require_auth
def get_all_data():
"""
API endpoint to get all data (conversations and messages)
Returns:
JSON response with processed conversations and messages
"""
raw_conversations = fetch_from_make("conversations")
raw_messages = fetch_from_make("messages")
logger.debug(f"Fetched {len(raw_conversations)} conversations and {len(raw_messages)} messages")
# Log Assistant_ID field presence in conversations
assistant_id_counts = {
"present": sum(1 for conv in raw_conversations if "Assistant_ID" in conv and conv["Assistant_ID"]),
"missing": sum(1 for conv in raw_conversations if "Assistant_ID" not in conv or not conv["Assistant_ID"])
}
# Warn if there are missing Assistant_IDs
if assistant_id_counts["missing"] > 0:
logger.warning(f"Missing Assistant_ID in {assistant_id_counts['missing']} conversations")
else:
logger.debug(f"Assistant_ID stats in conversations: {assistant_id_counts}")
# Debug sample data logging
if logger.level <= logging.DEBUG and raw_conversations:
sample_conv = raw_conversations[0].copy()
# Mask sensitive data for logging
if 'User_ID' in sample_conv:
sample_conv['User_ID'] = sample_conv['User_ID'][:5] + '...'
logger.debug(f"Sample conversation: {json.dumps(sample_conv, indent=2)}")
logger.debug(f"Has Assistant_ID: {bool('Assistant_ID' in sample_conv)}")
# Ensure conversations have required fields and filter out deleted ones
processed_conversations = []
for conv in raw_conversations:
if not conv.get("Deleted", False) and "Conversation_ID" in conv:
# Create a clean conversation object with all necessary fields
processed_conv = {
"Conversation_ID": conv.get("Conversation_ID"),
"User_ID": conv.get("User_ID"),
"StartTime": conv.get("StartTime"),
"EndTime": conv.get("EndTime"),
"Title": conv.get("Title"),
"Brand Voice Setting": conv.get("Brand Voice Setting"),
"Assistant_ID": conv.get("Assistant_ID"), # Ensure this field is always present
"Thread_ID": conv.get("Thread_ID"),
"Assistant_Key": conv.get("Assistant_Key")
}
processed_conversations.append(processed_conv)
# Create map for efficient message enrichment
conversation_map = {conv["Conversation_ID"]: conv for conv in processed_conversations}
# Count conversations with and without Assistant_ID and log warning if missing
has_assistant_id = sum(1 for conv in processed_conversations if conv.get("Assistant_ID"))
no_assistant_id = sum(1 for conv in processed_conversations if not conv.get("Assistant_ID"))
if no_assistant_id > 0:
logger.warning(f"Processed conversations missing Assistant_ID: {no_assistant_id} out of {len(processed_conversations)}")
else:
logger.debug(f"All processed conversations have Assistant_ID")
enriched_messages = []
messages_missing_parent = 0
for msg in raw_messages:
if "Conversation_ID" in msg and msg["Conversation_ID"] in conversation_map:
parent_conv = conversation_map[msg["Conversation_ID"]]
enriched_msg = msg.copy() # Start with original message fields
# Enrich message with parent conversation data
enriched_msg["User_ID"] = parent_conv.get("User_ID")
# CRITICAL: Ensure Assistant_ID is properly propagated
assistant_id = parent_conv.get("Assistant_ID")
enriched_msg["Assistant_ID"] = assistant_id
enriched_msg["Brand_Voice_Setting"] = parent_conv.get("Brand Voice Setting")
enriched_messages.append(enriched_msg)
else:
messages_missing_parent += 1
# Log warning if messages can't be matched to conversations
if messages_missing_parent > 0:
logger.warning(f"Found {messages_missing_parent} messages that could not be linked to a conversation")
# Log warning if messages are missing Assistant_ID
msg_has_assistant_id = sum(1 for msg in enriched_messages if msg.get("Assistant_ID"))
msg_no_assistant_id = sum(1 for msg in enriched_messages if not msg.get("Assistant_ID"))
if msg_no_assistant_id > 0:
logger.warning(f"Messages missing Assistant_ID after enrichment: {msg_no_assistant_id} out of {len(enriched_messages)}")
# Log completion information
logger.debug(f"Returning {len(processed_conversations)} processed conversations and {len(enriched_messages)} enriched messages")
return jsonify({
"conversations": processed_conversations,
"messages": enriched_messages
})
if __name__ == "__main__":
# Configure a simple access log for requests
@app.before_request
def log_request_info():
if request.path != '/favicon.ico': # Skip favicon requests
logger.debug(f"Request: {request.method} {request.path}")
# Configure after-request handler to log errors
@app.after_request
def log_response_info(response):
if response.status_code >= 400:
logger.warning(f"Response: {response.status_code}")
return response
# Import Hypercorn components
from hypercorn.config import Config
from hypercorn.asyncio import serve
import asyncio
# Configure Hypercorn
config = Config()
config.bind = ["0.0.0.0:5001"] # Port for backend
config.use_reloader = False
config.root_path = application_root
# Run the application with Hypercorn
logger.warning("Starting Oliver Agency Reporting Backend with Hypercorn on port 5001")
asyncio.run(serve(app, config))

18
backend/requirements.txt Normal file
View file

@ -0,0 +1,18 @@
blinker==1.9.0
certifi==2025.4.26
cffi==1.17.1
charset-normalizer==3.4.2
click==8.2.0
cryptography==45.0.4
Flask==3.1.1
flask-cors==6.0.0
hypercorn==0.16.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
pycparser==2.22
PyJWT==2.10.1
requests==2.32.3
urllib3==2.4.0
Werkzeug==3.1.3

137
email_analysis.py Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Email Analysis Script for BAIC Dashboard
Pulls data from Make.com endpoint and analyzes unique email addresses for user filtering logic
"""
import requests
import json
import re
from collections import Counter
MAKE_WEBHOOK_URL = 'https://hook.eu1.make.celonis.com/h8gjldwnp4u5cvc0io474zq4u7vwb9zw'
def fetch_data(data_type):
"""Fetch data from Make.com endpoint"""
try:
params = {'type': data_type}
response = requests.get(MAKE_WEBHOOK_URL, params=params, timeout=30)
response.raise_for_status()
data = response.json()
return [item.get('data', {}) for item in data]
except Exception as e:
print(f'Error fetching {data_type}: {e}')
return []
def extract_display_name(email):
"""Extract display name from email address"""
if not email or '@' not in email:
return email
# Handle oliver.agency emails (firstname+lastname format)
if email.endswith('@oliver.agency'):
username = email.split('@')[0]
# Split camelCase or handle names like 'michaelclervi'
parts = re.findall(r'[A-Z][a-z]*|[a-z]+', username)
if len(parts) >= 2:
return f"{parts[0].title()} {parts[1].title()}"
else:
return username.title()
# Handle Barclays external emails (firstname.lastname_domain format)
elif email.endswith('@olivermarketing.onmicrosoft.com'):
username = email.split('@')[0]
# Remove the domain suffix like '_barclays.com#ext#'
clean_username = re.sub(r'_[^_]+\.(com|co\.uk)#ext#$', '', username)
parts = clean_username.split('.')
if len(parts) >= 2:
first_name = parts[0].replace('_', ' ').title()
last_name = parts[1].replace('_', ' ').title()
return f"{first_name} {last_name}"
else:
return clean_username.replace('_', ' ').title()
return email
def analyze_emails():
"""Main analysis function"""
print("Fetching data from Make.com endpoint...")
print("=" * 50)
# Fetch data
conversations = fetch_data('conversations')
messages = fetch_data('messages')
print(f"Found {len(conversations)} conversations")
print(f"Found {len(messages)} messages")
print()
# Extract unique emails
emails = set()
for conv in conversations:
user_id = conv.get('User_ID', '')
if user_id and '@' in user_id:
emails.add(user_id.lower())
for msg in messages:
user_id = msg.get('User_ID', '')
if user_id and '@' in user_id:
emails.add(user_id.lower())
print(f"UNIQUE EMAIL ADDRESSES ({len(emails)} total)")
print("=" * 50)
# Create mapping for filtering logic
email_to_display = {}
domain_groups = {'oliver.agency': [], 'barclays': []}
for email in sorted(emails):
display_name = extract_display_name(email)
email_to_display[email] = display_name
if email.endswith('@oliver.agency'):
domain_groups['oliver.agency'].append((email, display_name))
elif email.endswith('@olivermarketing.onmicrosoft.com'):
domain_groups['barclays'].append((email, display_name))
# Print organized results
print("OLIVER AGENCY USERS:")
print("-" * 30)
for email, name in domain_groups['oliver.agency']:
print(f"{name:<25} | {email}")
print(f"\nBARCLAYS EXTERNAL USERS:")
print("-" * 30)
for email, name in domain_groups['barclays']:
print(f"{name:<25} | {email}")
# Generate JavaScript mapping for frontend
print(f"\nJAVASCRIPT USER MAPPING:")
print("=" * 50)
print("// Add this to your frontend user filtering logic")
print("const userEmailToDisplayName = {")
for email, name in sorted(email_to_display.items()):
print(f' "{email}": "{name}",')
print("};")
# Domain analysis
domains = Counter()
for email in emails:
if '@' in email:
domain = email.split('@')[1]
domains[domain] += 1
print(f"\nDOMAIN BREAKDOWN:")
print("=" * 50)
for domain, count in domains.most_common():
print(f"{domain:<40} | {count:>3} users")
print(f"\nSUMMARY:")
print("=" * 50)
print(f"Total unique users: {len(emails)}")
print(f"Oliver Agency staff: {len(domain_groups['oliver.agency'])}")
print(f"Barclays external users: {len(domain_groups['barclays'])}")
if __name__ == "__main__":
analyze_emails()

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/README.md Normal file
View file

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

BIN
frontend/baic-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

33
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3499
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
frontend/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@azure/msal-browser": "^4.13.2",
"@azure/msal-react": "^3.0.13",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
"papaparse": "^5.5.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"vite": "^6.3.5"
}
}

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

110
frontend/src/App.css Normal file
View file

@ -0,0 +1,110 @@
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
header {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-brand {
display: flex;
align-items: center;
gap: 12px;
flex-grow: 1;
padding: 0 0 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.baic-logo {
height: 120px;
width: auto;
}
header h1 {
color: #ffffff;
margin: 0;
font-size: 1.8rem;
font-weight: 600;
}
.loading,
.error {
padding: 20px;
text-align: center;
}
.loading {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.error {
color: #e53935;
background-color: #ffebee;
border-radius: 4px;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #1b2142;
}
.login-card {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 400px;
width: 100%;
}
.login-card h2 {
color: #333;
margin-bottom: 10px;
}
.login-card p {
color: #666;
margin-bottom: 30px;
}
.login-button {
background-color: #1b2142;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
.login-button:hover {
background-color: #0052a3;
}
.logout-button {
background-color: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
}
.logout-button:hover {
background-color: #c82333;
}

16
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,16 @@
import { useIsAuthenticated } from '@azure/msal-react';
import LoginComponent from './components/LoginComponent';
import AuthenticatedApp from './components/AuthenticatedApp';
import './App.css';
function App() {
const isAuthenticated = useIsAuthenticated();
if (!isAuthenticated) {
return <LoginComponent />;
}
return <AuthenticatedApp />;
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,49 @@
import { LogLevel } from '@azure/msal-browser';
export const msalConfig = {
auth: {
clientId: '014547f2-21c1-4245-881f-97f49543d963',
authority: 'https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385',
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
navigateToLoginRequestUrl: false
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) {
return;
}
switch (level) {
case LogLevel.Error:
console.error(message);
return;
case LogLevel.Info:
console.info(message);
return;
case LogLevel.Verbose:
console.debug(message);
return;
case LogLevel.Warning:
console.warn(message);
return;
default:
return;
}
}
}
}
};
export const loginRequest = {
scopes: ['User.Read'],
prompt: 'select_account'
};
export const graphConfig = {
graphMeEndpoint: 'https://graph.microsoft.com/v1.0/me'
};

View file

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react';
import { useMsal } from '@azure/msal-react';
import { fetchAllData } from '../services/api';
import Dashboard from './Dashboard';
import LogoutButton from './LogoutButton';
import baicLogo from '../../baic-logo.png';
const AuthenticatedApp = () => {
const { instance } = useMsal();
const [conversations, setConversations] = useState([]);
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [debugInfo, setDebugInfo] = useState([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setDebugInfo(prev => [...prev, `MSAL instance available: ${!!instance}`]);
console.error('DEBUG: AuthenticatedApp MSAL instance available:', !!instance);
console.error('DEBUG: AuthenticatedApp calling fetchAllData with instance');
setDebugInfo(prev => [...prev, 'Calling fetchAllData...']);
const data = await fetchAllData(instance);
console.error("DEBUG: API response data received:", !!data);
setDebugInfo(prev => [...prev, `API response received: ${!!data}`]);
// Check if we have valid data structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid data format received from API');
}
// Set conversations and messages, providing empty arrays as fallbacks
const receivedConversations = data.conversations || [];
const receivedMessages = data.messages || [];
console.log(`Received ${receivedConversations.length} conversations and ${receivedMessages.length} messages`);
// Log some sample data to verify format
if (receivedConversations.length > 0) {
console.log("Sample conversation:", receivedConversations[0]);
}
if (receivedMessages.length > 0) {
console.log("Sample message:", receivedMessages[0]);
}
setConversations(receivedConversations);
setMessages(receivedMessages);
setError(null);
} catch (err) {
console.error('Error fetching data:', err);
setDebugInfo(prev => [...prev, `ERROR: ${err.message}`]);
setError('Failed to fetch data. Please try again later.');
} finally {
setLoading(false);
}
};
fetchData();
}, [instance]);
return (
<div className="app-container">
<header>
<div className="header-brand">
<img src={baicLogo} alt="BAIC Logo" className="baic-logo" />
<h1>Reporting Module</h1>
</div>
<LogoutButton />
</header>
<main>
{loading ? (
<div className="loading">
Loading data...
<div style={{marginTop: '10px', fontSize: '12px', color: '#666'}}>
{debugInfo.map((info, index) => (
<div key={index}>{info}</div>
))}
</div>
</div>
) : error ? (
<div className="error">
{error}
<div style={{marginTop: '10px', fontSize: '12px', color: '#666'}}>
<strong>Debug Info:</strong>
{debugInfo.map((info, index) => (
<div key={index}>{info}</div>
))}
</div>
</div>
) : (
<Dashboard conversations={conversations} messages={messages} />
)}
</main>
</div>
);
};
export default AuthenticatedApp;

View file

@ -0,0 +1,280 @@
import { useState, useEffect } from 'react';
import FilterPanel from './FilterPanel';
import DatePickerComponent from './DatePickerComponent';
import VolumeGraph from './VolumeGraph';
import ExportButton from './ExportButton';
import { parseISO, isWithinInterval, isValid } from 'date-fns';
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
import { processUserData } from '../services/userMapping';
const Dashboard = ({ conversations, messages }) => {
console.log(`Dashboard received ${conversations.length} conversations and ${messages.length} messages`);
// Initialize filter state
const [filters, setFilters] = useState({
organization: 'All Users',
user: 'All Users',
assistant: 'All Assistants',
brandTOV: 'All Brands/TOV',
dateRange: {
start: null,
end: null
}
});
// Extract unique filter options
const [processedUserData, setProcessedUserData] = useState({});
const [uniqueAssistants, setUniqueAssistants] = useState([]);
const [uniqueBrandTOVs, setUniqueBrandTOVs] = useState([]);
// Filtered data based on current filters
const [filteredConversations, setFilteredConversations] = useState([]);
const [filteredMessages, setFilteredMessages] = useState([]);
// Extract unique filter values from conversations
useEffect(() => {
if (conversations.length > 0) {
const users = [...new Set(conversations.map(conv => conv.User_ID))].filter(Boolean);
// Process user data for organization filtering and friendly names
const userData = processUserData(users);
// Get unique assistant IDs and filter to only those with friendly names
const assistantIds = [...new Set(conversations.map(conv => conv.Assistant_ID))]
.filter(Boolean)
.filter(id => hasAssistantFriendlyName(id));
// Create a map of display names to IDs so we can use display names for filtering UI
// but keep the original IDs for filtering logic
const assistantDisplayMap = {};
const assistantDisplayNames = assistantIds.map(id => {
const displayName = getAssistantDisplayName(id);
if (displayName) {
assistantDisplayMap[displayName] = id;
return displayName;
}
return null;
}).filter(Boolean);
const brandTOVs = [...new Set(conversations.map(conv => conv['Brand Voice Setting']))].filter(Boolean);
console.log("Processed user data:", userData);
console.log("Unique assistants with friendly names:", assistantDisplayNames);
console.log("Assistant ID mapping:", assistantDisplayMap);
console.log("Unique brands/TOV:", brandTOVs);
setProcessedUserData(userData);
// Create reverse mapping (from name to ID) and forward mapping (from ID to name)
window.assistantDisplayToIdMap = assistantDisplayMap; // For display name to ID
window.assistantIdToDisplayMap = {}; // For ID to display name
// Populate the reverse mapping for easier lookups
Object.entries(assistantDisplayMap).forEach(([displayName, id]) => {
window.assistantIdToDisplayMap[id] = displayName;
});
// Also store the original assistant IDs (for data access) and display names (for UI)
window.validAssistantIds = assistantIds;
window.assistantDisplayNames = assistantDisplayNames;
setUniqueAssistants(assistantIds); // Pass the IDs to VolumeGraph
setUniqueBrandTOVs(brandTOVs);
}
}, [conversations]);
// Apply filters to data
useEffect(() => {
console.log("Applying filters:", filters);
// Filter conversations
const filteredConvs = conversations.filter(conv => {
// First, filter out assistants without friendly names
if (!conv.Assistant_ID || !window.validAssistantIds.includes(conv.Assistant_ID)) {
return false;
}
let matches = true;
// Date range filter - only apply if both start and end dates are set
if (filters.dateRange.start && filters.dateRange.end && conv.StartTime) {
try {
const convDate = parseISO(conv.StartTime);
if (isValid(convDate)) {
matches = matches && isWithinInterval(convDate, {
start: filters.dateRange.start,
end: filters.dateRange.end
});
} else {
console.warn("Invalid date in conversation:", conv.StartTime);
matches = false;
}
} catch (e) {
console.error("Error parsing conversation date:", e);
matches = false;
}
}
// Organization filter - check if user belongs to selected organization
if (filters.organization !== 'All Users' && conv.User_ID) {
const userOrg = processedUserData.organizationMap?.[conv.User_ID];
if (filters.organization === 'Oliver Users') {
matches = matches && userOrg === 'Oliver';
} else if (filters.organization === 'Barclays Users') {
matches = matches && userOrg === 'Barclays';
}
}
// User filter
if (filters.user !== 'All Users' && conv.User_ID) {
matches = matches && conv.User_ID === filters.user;
}
// Assistant filter
if (filters.assistant !== 'All Assistants' && conv.Assistant_ID) {
// Use the ID mapping to compare IDs rather than display names
const selectedAssistantId = window.assistantDisplayToIdMap[filters.assistant];
matches = matches && conv.Assistant_ID === selectedAssistantId;
}
// Brand/TOV filter
if (filters.brandTOV !== 'All Brands/TOV' && conv['Brand Voice Setting']) {
matches = matches && conv['Brand Voice Setting'] === filters.brandTOV;
}
return matches;
});
// Filter messages (use enriched message data)
const filteredMsgs = messages.filter(msg => {
// First, filter out assistants without friendly names
if (!msg.Assistant_ID || !window.validAssistantIds.includes(msg.Assistant_ID)) {
return false;
}
let matches = true;
// Date range filter - only apply if both start and end dates are set
if (filters.dateRange.start && filters.dateRange.end && msg.Timestamp) {
try {
const msgDate = parseISO(msg.Timestamp);
if (isValid(msgDate)) {
matches = matches && isWithinInterval(msgDate, {
start: filters.dateRange.start,
end: filters.dateRange.end
});
} else {
console.warn("Invalid date in message:", msg.Timestamp);
matches = false;
}
} catch (e) {
console.error("Error parsing message date:", e);
matches = false;
}
}
// Organization filter - check if user belongs to selected organization
if (filters.organization !== 'All Users' && msg.User_ID) {
const userOrg = processedUserData.organizationMap?.[msg.User_ID];
if (filters.organization === 'Oliver Users') {
matches = matches && userOrg === 'Oliver';
} else if (filters.organization === 'Barclays Users') {
matches = matches && userOrg === 'Barclays';
}
}
// User filter
if (filters.user !== 'All Users' && msg.User_ID) {
matches = matches && msg.User_ID === filters.user;
}
// Assistant filter
if (filters.assistant !== 'All Assistants' && msg.Assistant_ID) {
// Use the ID mapping to compare IDs rather than display names
const selectedAssistantId = window.assistantDisplayToIdMap[filters.assistant];
matches = matches && msg.Assistant_ID === selectedAssistantId;
}
// Brand/TOV filter
if (filters.brandTOV !== 'All Brands/TOV' && msg.Brand_Voice_Setting) {
matches = matches && msg.Brand_Voice_Setting === filters.brandTOV;
}
return matches;
});
console.log(`Filtered to ${filteredConvs.length} conversations and ${filteredMsgs.length} messages`);
setFilteredConversations(filteredConvs);
setFilteredMessages(filteredMsgs);
}, [conversations, messages, filters]);
// Handle filter changes
const handleFilterChange = (filterType, value) => {
console.log(`Filter changed: ${filterType} = ${value}`);
// If organization filter changes, reset user filter to avoid inconsistent state
if (filterType === 'organization') {
setFilters(prevFilters => ({
...prevFilters,
organization: value,
user: 'All Users' // Reset user filter when organization changes
}));
} else {
setFilters(prevFilters => ({
...prevFilters,
[filterType]: value
}));
}
};
// Handle date range changes
const handleDateRangeChange = (start, end) => {
console.log(`Date range changed: start = ${start}, end = ${end}`);
setFilters(prevFilters => ({
...prevFilters,
dateRange: { start, end }
}));
};
return (
<div className="dashboard">
<div className="controls-row">
<FilterPanel
processedUserData={processedUserData}
uniqueAssistants={window.assistantDisplayNames || []}
uniqueBrandTOVs={uniqueBrandTOVs}
filters={filters}
onFilterChange={handleFilterChange}
/>
<DatePickerComponent
dateRange={filters.dateRange}
onDateRangeChange={handleDateRangeChange}
/>
<ExportButton
filteredConversations={filteredConversations}
filteredMessages={filteredMessages}
filters={filters}
uniqueAssistants={uniqueAssistants}
/>
</div>
<VolumeGraph
filteredConversations={filteredConversations}
filteredMessages={filteredMessages}
selectedAssistant={filters.assistant}
uniqueAssistants={uniqueAssistants} // This is now IDs
assistantIdToNameMap={window.assistantIdToDisplayMap || {}}
/>
<div className="metrics-definitions">
<h3>Key Metrics Definitions</h3>
<div className="definition-item">
<strong>Conversation:</strong> A single, complete user interaction session from start to finish.
</div>
<div className="definition-item">
<strong>Message:</strong> Each individual back-and-forth exchange (user prompt and AI response) within a conversation.
</div>
</div>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,123 @@
import React, { useEffect } from 'react';
import { startOfDay, endOfDay, subDays, startOfWeek, endOfWeek } from 'date-fns';
const DatePickerComponent = ({ dateRange, onDateRangeChange }) => {
// Initialize with "All time" selected
useEffect(() => {
// Only set to "All time" initially if date range is not already set
if (!dateRange.start && !dateRange.end) {
handlePresetClick('all');
}
}, []);
// Preset date ranges
const handlePresetClick = (preset) => {
const now = new Date();
let start, end;
switch (preset) {
case 'all':
// All time - set to null to indicate no date filtering
start = null;
end = null;
break;
case 'today':
start = startOfDay(now);
end = endOfDay(now);
break;
case 'week':
start = startOfDay(subDays(now, 7));
end = endOfDay(now);
break;
case '30days':
start = startOfDay(subDays(now, 30));
end = endOfDay(now);
break;
default:
// Default to no date filtering
start = null;
end = null;
}
onDateRangeChange(start, end);
};
// Custom date range
const handleCustomDateChange = (type, e) => {
const date = e.target.value ? new Date(e.target.value) : null;
if (type === 'start') {
onDateRangeChange(date, dateRange.end);
} else {
onDateRangeChange(dateRange.start, date ? endOfDay(date) : null);
}
};
// Format date for input field
const formatDateForInput = (date) => {
if (!date) return '';
return date.toISOString().split('T')[0]; // Format as YYYY-MM-DD
};
return (
<div className="date-picker">
<div className="date-presets">
<button
className={!dateRange.start && !dateRange.end ? 'active' : ''}
onClick={() => handlePresetClick('all')}
>
All time
</button>
<button
className={dateRange.start &&
dateRange.end &&
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 30 ? 'active' : ''}
onClick={() => handlePresetClick('30days')}
>
Last 30 days
</button>
<button
className={dateRange.start &&
dateRange.end &&
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 7 ? 'active' : ''}
onClick={() => handlePresetClick('week')}
>
Last week
</button>
<button
className={dateRange.start &&
dateRange.end &&
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 0 ? 'active' : ''}
onClick={() => handlePresetClick('today')}
>
Today
</button>
</div>
<div className="custom-date-range">
<div className="date-input">
<label htmlFor="start-date">Start Date:</label>
<input
type="date"
id="start-date"
value={formatDateForInput(dateRange.start)}
onChange={(e) => handleCustomDateChange('start', e)}
/>
</div>
<div className="date-input">
<label htmlFor="end-date">End Date:</label>
<input
type="date"
id="end-date"
value={formatDateForInput(dateRange.end)}
onChange={(e) => handleCustomDateChange('end', e)}
min={formatDateForInput(dateRange.start)}
/>
</div>
</div>
</div>
);
};
export default DatePickerComponent;

View file

@ -0,0 +1,55 @@
import React from 'react';
import { unparse } from 'papaparse';
import { generateAggregatedData } from '../utils/dataAggregation';
const ExportButton = ({ filteredConversations, filteredMessages, filters, uniqueAssistants }) => {
const handleExport = () => {
// Generate aggregated statistical data instead of raw conversation content
const { exportData } = generateAggregatedData(
filteredConversations,
filteredMessages,
uniqueAssistants,
filters
);
// If no data to export, show message
if (!exportData || exportData.length === 0) {
alert('No data available for export with the current filters.');
return;
}
// Create CSV from aggregated data
const csv = unparse(exportData);
// Create a blob from the CSV data
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
// Create a download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'oliver_agency_aggregated_report.csv');
document.body.appendChild(link);
// Trigger download
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
return (
<div className="export-button-container">
<button
className="export-button"
onClick={handleExport}
disabled={!filteredConversations.length && !filteredMessages.length}
>
Export to CSV
</button>
</div>
);
};
export default ExportButton;

View file

@ -0,0 +1,77 @@
import React from 'react';
import { getFilteredUsers } from '../services/userMapping';
import { getBrandDisplayName } from '../utils/brandMapping';
const FilterPanel = ({
processedUserData,
uniqueAssistants,
uniqueBrandTOVs,
filters,
onFilterChange
}) => {
// Get filtered users based on organization selection
const filteredUsers = getFilteredUsers(processedUserData, filters.organization);
return (
<div className="filter-panel">
<div className="filter-group">
<label htmlFor="organization-filter">Organization:</label>
<select
id="organization-filter"
value={filters.organization}
onChange={(e) => onFilterChange('organization', e.target.value)}
>
<option value="All Users">All Users</option>
<option value="Oliver Users">Oliver Users</option>
<option value="Barclays Users">Barclays Users</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="user-filter">User:</label>
<select
id="user-filter"
value={filters.user}
onChange={(e) => onFilterChange('user', e.target.value)}
>
<option value="All Users">All Users</option>
{filteredUsers.map(userEmail => (
<option key={userEmail} value={userEmail}>
{processedUserData.friendlyNameMap?.[userEmail] || userEmail}
</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="assistant-filter">Assistant:</label>
<select
id="assistant-filter"
value={filters.assistant}
onChange={(e) => onFilterChange('assistant', e.target.value)}
>
<option value="All Assistants">All Assistants</option>
{uniqueAssistants.map(assistant => (
<option key={assistant} value={assistant}>{assistant}</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="brand-tov-filter">Brand/TOV:</label>
<select
id="brand-tov-filter"
value={filters.brandTOV}
onChange={(e) => onFilterChange('brandTOV', e.target.value)}
>
<option value="All Brands/TOV">All Brands/TOV</option>
{uniqueBrandTOVs.map(brandTOV => (
<option key={brandTOV} value={brandTOV}>{getBrandDisplayName(brandTOV)}</option>
))}
</select>
</div>
</div>
);
};
export default FilterPanel;

View file

@ -0,0 +1,27 @@
import { useMsal } from '@azure/msal-react';
import { loginRequest } from '../authConfig';
const LoginComponent = () => {
const { instance } = useMsal();
const handleLogin = () => {
instance.loginPopup(loginRequest).catch(e => {
console.error(e);
});
};
return (
<div className="login-container">
<div className="login-card">
<h2>Oliver Agency Reporting Module</h2>
<p>Please sign in to access the dashboard</p>
<button onClick={handleLogin} className="login-button">
Sign In with Microsoft
</button>
</div>
</div>
);
};
export default LoginComponent;

View file

@ -0,0 +1,19 @@
import { useMsal } from '@azure/msal-react';
const LogoutButton = () => {
const { instance } = useMsal();
const handleLogout = () => {
instance.logoutPopup().catch(e => {
console.error(e);
});
};
return (
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
);
};
export default LogoutButton;

View file

@ -0,0 +1,457 @@
import React, { useMemo, useEffect, useState } from 'react';
import {
BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { parseISO, format, startOfDay, differenceInDays, isValid, addDays } from 'date-fns';
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
const VolumeGraph = ({
filteredConversations,
filteredMessages,
selectedAssistant,
uniqueAssistants, // This is now an array of assistant IDs
assistantIdToNameMap // Mapping from ID to display name
}) => {
// Add state for the data type toggle
const [dataType, setDataType] = useState('both'); // 'both', 'conversations', or 'messages'
// useEffect for debugging (keep this for now)
useEffect(() => {
console.log("VolumeGraph PROPS:", {
conversationsCount: filteredConversations.length,
messagesCount: filteredMessages.length,
selectedAssistant,
uniqueAssistants: uniqueAssistants, // Log the actual array of IDs
uniqueAssistantsCount: uniqueAssistants ? uniqueAssistants.length : 0,
validAssistantIds: window.validAssistantIds || [],
idToNameMap: assistantIdToNameMap
});
console.log("Display names in window:", window.assistantDisplayNames);
console.log("ID to display mapping:", window.assistantIdToDisplayMap);
console.log("Display to ID mapping:", window.assistantDisplayToIdMap);
if (filteredConversations.length > 0) {
const sampleConv = filteredConversations[0];
console.log("VolumeGraph Sample CONV:", sampleConv,
"Has Assistant_ID:", sampleConv.Assistant_ID,
"Has friendly name:", hasAssistantFriendlyName(sampleConv.Assistant_ID),
"Display name:", getAssistantDisplayName(sampleConv.Assistant_ID));
} else {
console.warn("No filtered conversations available for graph!");
}
if (filteredMessages.length > 0) {
const sampleMsg = filteredMessages[0];
console.log("VolumeGraph Sample MSG:", sampleMsg,
"Has Assistant_ID:", sampleMsg.Assistant_ID,
"Has friendly name:", hasAssistantFriendlyName(sampleMsg.Assistant_ID),
"Display name:", getAssistantDisplayName(sampleMsg.Assistant_ID));
} else {
console.warn("No filtered messages available for graph!");
}
}, [filteredConversations, filteredMessages, selectedAssistant, uniqueAssistants, assistantIdToNameMap]);
const graphData = useMemo(() => {
if (!filteredConversations.length && !filteredMessages.length) {
return { dailyData: [], aggregatedData: [], useAggregated: false, aggregationType: 'daily' };
}
const allTimestamps = [
...filteredConversations.map(conv => conv.StartTime),
...filteredMessages.map(msg => msg.Timestamp)
].filter(Boolean);
const validDates = allTimestamps
.map(dateStr => {
try { return parseISO(dateStr); } catch (e) { return null; }
})
.filter(dateObj => dateObj && isValid(dateObj));
if (validDates.length === 0) {
return { dailyData: [], aggregatedData: [], useAggregated: false, aggregationType: 'daily' };
}
const minDate = startOfDay(new Date(Math.min(...validDates)));
const maxDate = startOfDay(new Date(Math.max(...validDates)));
const daysDiff = differenceInDays(maxDate, minDate) + 1;
const dataByDate = {};
const monthlyData = {};
const weeklyData = {};
// Initialize data structures
for (let i = 0; i < daysDiff; i++) {
const currentDate = addDays(minDate, i);
const dateKey = format(currentDate, 'yyyy-MM-dd');
const monthKey = format(currentDate, 'yyyy-MM');
const dayOffsetFromMin = differenceInDays(currentDate, minDate);
const weekNumber = Math.floor(dayOffsetFromMin / 7) + 1; // 1-based week number
const weekKey = `Week ${weekNumber}`; // Simpler week key for direct use
// Daily
dataByDate[dateKey] = { date: dateKey, conversationsTotal: 0, messagesTotal: 0 };
// Always initialize counters for all valid assistants
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
dataByDate[dateKey][`conversations_${assistantId}`] = 0;
dataByDate[dateKey][`messages_${assistantId}`] = 0;
});
}
// Monthly
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = { month: monthKey, conversationsTotal: 0, messagesTotal: 0 };
// Always initialize counters for all valid assistants
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
monthlyData[monthKey][`conversations_${assistantId}`] = 0;
monthlyData[monthKey][`messages_${assistantId}`] = 0;
});
}
}
// Weekly
if (!weeklyData[weekKey]) {
const weekStartDate = addDays(minDate, (weekNumber - 1) * 7);
weeklyData[weekKey] = {
week: weekKey,
startDateFormatted: format(weekStartDate, 'MMM d, yyyy'), // For tooltip
conversationsTotal: 0,
messagesTotal: 0
};
// Always initialize counters for all valid assistants
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
weeklyData[weekKey][`conversations_${assistantId}`] = 0;
weeklyData[weekKey][`messages_${assistantId}`] = 0;
});
}
}
}
// Aggregate conversation counts
filteredConversations.forEach(conv => {
if (conv.StartTime) {
const parsedDate = parseISO(conv.StartTime);
if (isValid(parsedDate)) {
const dateStr = format(parsedDate, 'yyyy-MM-dd');
const monthStr = format(parsedDate, 'yyyy-MM');
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
const weekKeyAgg = `Week ${weekNumAgg}`;
if (dataByDate[dateStr]) {
dataByDate[dateStr].conversationsTotal++;
// Always increment per-assistant counters if the key exists
if (conv.Assistant_ID && dataByDate[dateStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
dataByDate[dateStr][`conversations_${conv.Assistant_ID}`]++;
}
}
if (monthlyData[monthStr]) {
monthlyData[monthStr].conversationsTotal++;
// Always increment per-assistant counters if the key exists
if (conv.Assistant_ID && monthlyData[monthStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
monthlyData[monthStr][`conversations_${conv.Assistant_ID}`]++;
}
}
if (weeklyData[weekKeyAgg]) {
weeklyData[weekKeyAgg].conversationsTotal++;
// Always increment per-assistant counters if the key exists
if (conv.Assistant_ID && weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`] !== undefined) {
weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`]++;
}
}
}
}
});
// Aggregate message counts
filteredMessages.forEach(msg => {
if (msg.Timestamp) {
const parsedDate = parseISO(msg.Timestamp);
if (isValid(parsedDate)) {
const dateStr = format(parsedDate, 'yyyy-MM-dd');
const monthStr = format(parsedDate, 'yyyy-MM');
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
const weekKeyAgg = `Week ${weekNumAgg}`;
if (dataByDate[dateStr]) {
dataByDate[dateStr].messagesTotal++;
// Always increment per-assistant counters if the key exists
if (msg.Assistant_ID && dataByDate[dateStr][`messages_${msg.Assistant_ID}`] !== undefined) {
dataByDate[dateStr][`messages_${msg.Assistant_ID}`]++;
}
}
if (monthlyData[monthStr]) {
monthlyData[monthStr].messagesTotal++;
// Always increment per-assistant counters if the key exists
if (msg.Assistant_ID && monthlyData[monthStr][`messages_${msg.Assistant_ID}`] !== undefined) {
monthlyData[monthStr][`messages_${msg.Assistant_ID}`]++;
}
}
if (weeklyData[weekKeyAgg]) {
weeklyData[weekKeyAgg].messagesTotal++;
// Always increment per-assistant counters if the key exists
if (msg.Assistant_ID && weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`] !== undefined) {
weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`]++;
}
}
}
}
});
const dailyDataArray = Object.values(dataByDate).sort((a, b) => a.date.localeCompare(b.date));
const monthlyDataArray = Object.values(monthlyData).sort((a, b) => a.month.localeCompare(b.month));
const weeklyDataArray = Object.values(weeklyData).sort((a,b) => {
const weekNumA = parseInt(a.week.split(' ')[1]);
const weekNumB = parseInt(b.week.split(' ')[1]);
return weekNumA - weekNumB;
});
const useWeekly = daysDiff > 60 && daysDiff <= 180;
const useMonthly = daysDiff > 180;
let finalAggregatedData = dailyDataArray;
let currentAggregationType = 'daily';
if (useMonthly) {
finalAggregatedData = monthlyDataArray;
currentAggregationType = 'monthly';
} else if (useWeekly) {
finalAggregatedData = weeklyDataArray;
currentAggregationType = 'weekly';
}
// Log final data for inspection
console.log("VolumeGraph FINAL DATA:", {
dailyDataArray,
monthlyDataArray,
weeklyDataArray,
finalAggregatedData,
useAggregated: useMonthly || useWeekly,
aggregationType: currentAggregationType
});
if (finalAggregatedData.length > 0) {
console.log("VolumeGraph FINAL DATA Sample Point:", finalAggregatedData[0]);
// Check for assistant-specific data
const samplePoint = finalAggregatedData[0];
const assistantKeys = Object.keys(samplePoint).filter(key =>
key.startsWith('conversations_') || key.startsWith('messages_')
);
console.log("Assistant-specific data keys:", assistantKeys);
// Check if we have data for our valid assistant IDs
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(id => {
const convKey = `conversations_${id}`;
const msgKey = `messages_${id}`;
console.log(`Data for ${id} (${assistantIdToNameMap[id] || 'Unknown'}):`, {
hasConvKey: samplePoint.hasOwnProperty(convKey),
hasMsgKey: samplePoint.hasOwnProperty(msgKey),
convValue: samplePoint[convKey],
msgValue: samplePoint[msgKey]
});
});
}
}
// Debug: Count non-zero values
const nonZeroDays = finalAggregatedData.filter(day =>
day.conversationsTotal > 0 || day.messagesTotal > 0 ||
Object.keys(day).some(key => key.startsWith('conversations_') && day[key] > 0) ||
Object.keys(day).some(key => key.startsWith('messages_') && day[key] > 0)
);
console.log("Days with non-zero values:", nonZeroDays.length);
if (nonZeroDays.length > 0) {
console.log("Sample non-zero day:", nonZeroDays[0]);
}
return {
dataForChart: finalAggregatedData,
useAggregated: useMonthly || useWeekly,
aggregationType: currentAggregationType
};
}, [filteredConversations, filteredMessages, selectedAssistant, uniqueAssistants]);
const getAssistantColor = (assistantId, isConversation) => {
const defaultConvColor = '#4a4a4a';
const defaultMsgColor = '#8a8a8a';
if (!uniqueAssistants || uniqueAssistants.length === 0) {
return isConversation ? defaultConvColor : defaultMsgColor;
}
const assistantIndex = uniqueAssistants.indexOf(assistantId);
const baseColors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#a4de6c', '#d0ed57', '#00C49F', '#FFBB28', '#FF8042', '#0088FE', '#00C49F', '#FFBB28', '#FF8042'];
let color;
if (assistantIndex === -1) { // Should not happen if uniqueAssistants is correctly populated
color = baseColors[Math.floor(Math.random() * baseColors.length)]; // Random for unknown
} else {
color = baseColors[assistantIndex % baseColors.length];
}
return isConversation ? color : `${color}CC`; // CC for ~80% alpha
};
const formatXAxisTick = (tick) => {
try {
if (graphData.aggregationType === 'monthly') return format(parseISO(tick + '-01'), 'MMM yyyy');
if (graphData.aggregationType === 'weekly') return tick; // 'Week X'
return format(parseISO(tick), 'MMM d');
} catch (e) { return tick; }
};
const formatTooltipLabel = (label) => {
try {
if (graphData.aggregationType === 'monthly') return format(parseISO(label + '-01'), 'MMMM yyyy');
if (graphData.aggregationType === 'weekly') {
const weekObj = graphData.dataForChart.find(w => w.week === label);
return weekObj ? `${label} (starts ${weekObj.startDateFormatted})` : label;
}
return format(parseISO(label), 'MMMM d, yyyy');
} catch (e) { return label; }
};
const renderLinesOrBars = () => {
const showPerAssistant = selectedAssistant === 'All Assistants' && uniqueAssistants && uniqueAssistants.length > 0;
const elements = [];
if (showPerAssistant) {
// For each assistant, add relevant lines/bars based on data type
uniqueAssistants.forEach(assistantId => {
// Get the display name for this assistant ID
const displayName = assistantIdToNameMap[assistantId] || assistantId.slice(0, 7);
// Add conversation data if we're showing conversations or both
if (dataType === 'both' || dataType === 'conversations') {
elements.push(
<Bar
key={`conv_${assistantId}`}
dataKey={`conversations_${assistantId}`}
name={`Conv: ${displayName}`}
fill={getAssistantColor(assistantId, true)}
{...(dataType !== 'both' && { stackId: "a" })}
/>
);
}
// Add message data if we're showing messages or both
if (dataType === 'both' || dataType === 'messages') {
elements.push(
<Bar
key={`msg_${assistantId}`}
dataKey={`messages_${assistantId}`}
name={`Msg: ${displayName}`}
fill={getAssistantColor(assistantId, false)}
{...(dataType !== 'both' && { stackId: "a" })}
/>
);
}
});
} else { // Totals or specific assistant
// For a specific assistant, we need to handle if it's a display name or ID
let displayName, assistantId;
if (selectedAssistant !== 'All Assistants') {
// Check if this is a display name or an ID
assistantId = window.assistantDisplayToIdMap[selectedAssistant];
if (assistantId) {
// It's a display name
displayName = selectedAssistant;
} else {
// It might be an ID
displayName = assistantIdToNameMap[selectedAssistant] || selectedAssistant.slice(0, 7);
assistantId = selectedAssistant;
}
} else {
displayName = "All Assistants";
}
const convName = selectedAssistant !== 'All Assistants' ? `Conversations (${displayName})` : "Total Conversations";
const msgName = selectedAssistant !== 'All Assistants' ? `Messages (${displayName})` : "Total Messages";
// Add conversation data if we're showing conversations or both
if (dataType === 'both' || dataType === 'conversations') {
elements.push(
<Bar key="convTotal" dataKey="conversationsTotal" name={convName} fill="#8884d8" />
);
}
// Add message data if we're showing messages or both
if (dataType === 'both' || dataType === 'messages') {
elements.push(
<Bar key="msgTotal" dataKey="messagesTotal" name={msgName} fill="#82ca9d" />
);
}
}
return elements;
};
if (!graphData || !graphData.dataForChart || graphData.dataForChart.length === 0) {
return <div className="volume-graph no-data">No data available for the selected filters.</div>;
}
return (
<div className="volume-graph">
<div className="graph-header">
<h2>
{dataType === 'both' ? 'Conversation & Message Volume' :
dataType === 'conversations' ? 'Conversation Volume' :
'Message Volume'}
{graphData.useAggregated ? ` (${graphData.aggregationType})` : ''}
</h2>
<div className="data-type-toggle">
<button
className={dataType === 'both' ? 'active' : ''}
onClick={() => setDataType('both')}
>
Both
</button>
<button
className={dataType === 'conversations' ? 'active' : ''}
onClick={() => setDataType('conversations')}
>
Conversations
</button>
<button
className={dataType === 'messages' ? 'active' : ''}
onClick={() => setDataType('messages')}
>
Messages
</button>
</div>
</div>
<div className="graph-container" style={{ width: '100%', height: 400 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={graphData.dataForChart} margin={{ top: 5, right: 10, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey={
graphData.aggregationType === 'monthly' ? 'month' :
graphData.aggregationType === 'weekly' ? 'week' :
'date'
}
tickFormatter={formatXAxisTick}
interval="auto"
minTickGap={35}
tick={{ fontSize: 10 }}
/>
<YAxis allowDecimals={false} domain={[0, 'dataMax + 5']} />
<Tooltip labelFormatter={formatTooltipLabel} formatter={(value, name) => [value, name.replace(/_/g, ' ')]} />
<Legend />
{renderLinesOrBars()}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default VolumeGraph;

View file

@ -0,0 +1,51 @@
/**
* Manual mapping of email addresses to display names
* Generated from actual Make.com data analysis
* Used as fallback when automatic name parsing fails
*/
export const userDisplayNameMap = {
// Oliver Agency Users
"armanpreetkapoor@oliver.agency": "Armanpreet Kapoor",
"daveporter@oliver.agency": "Dave Porter",
"davidbrown@oliver.agency": "David Brown",
"helenfendall@oliver.agency": "Helen Fendall",
"jasonliew@oliver.agency": "Jason Liew",
"jeremycrockerwhite@oliver.agency": "Jeremy Crocker White",
"jeromejefferson@oliver.agency": "Jerome Jefferson",
"jessicakim@oliver.agency": "Jessica Kim",
"lisahall@oliver.agency": "Lisa Hall",
"lloydredding@oliver.agency": "Lloyd Redding",
"melaniecarr@oliver.agency": "Melanie Carr",
"michaelbarratt@oliver.agency": "Michael Barratt",
"michaelclervi@oliver.agency": "Michael Clervi",
"mikekeenleyside@oliver.agency": "Mike Keenleyside",
"nataliefryatt@oliver.agency": "Natalie Fryatt",
"nigelwebb@oliver.agency": "Nigel Webb",
"richardharman@oliver.agency": "Richard Harman",
"richardmakepeace@oliver.agency": "Richard Makepeace",
"robchoi@oliver.agency": "Rob Choi",
"robkavanagh@oliver.agency": "Rob Kavanagh",
"samiraali@oliver.agency": "Samira Ali",
"stevehedge@oliver.agency": "Steve Hedge",
"steveodonoghue@oliver.agency": "Steve O'Donoghue",
"tomwilson@oliver.agency": "Tom Wilson",
// Barclays External Users (auto-parsed, but included for completeness)
"adam.webb_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com": "Adam Webb",
"aileen.stevenson_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Aileen Stevenson",
"alistair.taylor_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Alistair Taylor",
"angharad.slater_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com": "Angharad Slater",
"audrey.zahlis_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Audrey Zahlis",
"christopher.x.perkins_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Christopher X Perkins",
"donna.hill_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Donna Hill",
"edward.simons1_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Edward Simons",
"evie.grant_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Evie Grant",
"gurjit.dhaliwal_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Gurjit Dhaliwal",
"joseph.collins_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Joseph Collins",
"madeleine.webb_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Madeleine Webb",
"matt.perry_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Matt Perry",
"rachael.millard_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Rachael Millard",
"rhiannon.bodman_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Rhiannon Bodman",
"samantha.barnes_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Samantha Barnes",
"zara.myers_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Zara Myers"
};

269
frontend/src/index.css Normal file
View file

@ -0,0 +1,269 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.5;
font-weight: 400;
color: #ffffff;
background-color: #1b2142;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button {
border-radius: 4px;
border: 1px solid #d1d5db;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #ffffff;
color: #213547;
cursor: pointer;
transition: background-color 0.25s, border-color 0.25s;
}
button:hover {
background-color: #f1f5f9;
border-color: #9ca3af;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
button.active {
background-color: #0052a3;
color: white;
border-color: #003d7a;
}
select {
padding: 0.5em;
border-radius: 4px;
border: 1px solid #d1d5db;
font-size: 1em;
background-color: white;
}
.dashboard {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem;
margin: 1rem;
}
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.875rem;
font-weight: 500;
color: #4b5563;
}
.date-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-left: auto;
}
.date-presets {
display: flex;
gap: 0.5rem;
}
.custom-date-range {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.date-input {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.date-input label {
font-size: 0.875rem;
font-weight: 500;
color: #4b5563;
}
.export-button-container {
align-self: flex-end;
}
.export-button {
background-color: #10b981;
color: white;
border-color: #059669;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.export-button:hover {
background-color: #059669;
}
.export-button:disabled {
background-color: #d1d5db;
border-color: #9ca3af;
cursor: not-allowed;
color: #4b5563;
}
.volume-graph {
padding: 1rem 0;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.volume-graph h2 {
margin-top: 0;
margin-bottom: 0;
font-size: 1.25rem;
color: #1f2937;
}
.data-type-toggle {
display: flex;
gap: 0.5rem;
}
.data-type-toggle button {
padding: 0.3em 0.8em;
font-size: 0.875rem;
}
.graph-container {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
border-radius: 6px;
overflow: hidden;
background-color: #fff;
padding: 10px;
box-sizing: border-box;
}
.no-data {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-style: italic;
min-height: 300px;
}
/* Override recharts default styles */
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: #e5e7eb;
}
.recharts-tooltip-wrapper {
z-index: 1000;
}
.recharts-default-tooltip {
background-color: #fff !important;
border: 1px solid #e5e7eb !important;
border-radius: 4px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
padding: 10px !important;
}
.recharts-tooltip-label {
font-weight: bold !important;
color: #1f2937 !important;
margin-bottom: 5px !important;
}
.recharts-tooltip-item-list {
padding: 0 !important;
margin: 0 !important;
}
.recharts-tooltip-item {
margin: 4px 0 !important;
color: #4b5563 !important;
}
.recharts-legend-wrapper {
padding: 10px 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
.recharts-legend-item {
margin-right: 20px !important;
}
.recharts-legend-item-text {
font-size: 0.875rem !important;
color: #4b5563 !important;
}
.metrics-definitions {
margin-top: 2rem;
padding: 1.5rem;
background-color: #f8fafc;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.metrics-definitions h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.definition-item {
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: #4b5563;
line-height: 1.5;
}
.definition-item:last-child {
margin-bottom: 0;
}
.definition-item strong {
color: #1f2937;
font-weight: 600;
}

17
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { PublicClientApplication } from '@azure/msal-browser'
import { MsalProvider } from '@azure/msal-react'
import './index.css'
import App from './App.jsx'
import { msalConfig } from './authConfig'
const msalInstance = new PublicClientApplication(msalConfig)
createRoot(document.getElementById('root')).render(
<StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</StrictMode>,
)

View file

@ -0,0 +1,81 @@
import axios from 'axios';
import { PublicClientApplication } from '@azure/msal-browser';
// Determine the API URL based on the environment
const isDevelopment = import.meta.env.DEV;
// In development mode, use the local URL; in production, use the deployed URL
const API_URL = isDevelopment
? 'http://localhost:5001/api'
: 'https://baic.oliver.solutions/dashboard/back/api';
/**
* Get access token from MSAL instance
* @param {PublicClientApplication} msalInstance - MSAL instance
* @returns {Promise<string>} Access token
*/
const getAccessToken = async (msalInstance) => {
try {
console.error('DEBUG: getAccessToken called with msalInstance:', !!msalInstance);
if (!msalInstance) {
console.error('DEBUG: MSAL instance is null or undefined');
throw new Error('MSAL instance is null or undefined');
}
const accounts = msalInstance.getAllAccounts();
console.error('DEBUG: Found accounts:', accounts.length);
if (accounts.length === 0) {
console.error('DEBUG: No accounts found - user may need to login');
throw new Error('No accounts found');
}
const request = {
scopes: ['User.Read'],
account: accounts[0]
};
console.error('DEBUG: Token request scopes:', request.scopes);
const response = await msalInstance.acquireTokenSilent(request);
console.error('DEBUG: Token acquired successfully:', !!response.accessToken);
return response.accessToken;
} catch (error) {
console.error('ERROR in getAccessToken:', error);
console.error('ERROR details:', error.message);
throw error;
}
};
/**
* Fetches all data (conversations and messages) from the backend API
* @param {PublicClientApplication} msalInstance - MSAL instance for authentication
* @returns {Promise} Promise resolving to the API response containing conversations and messages
*/
export const fetchAllData = async (msalInstance = null) => {
try {
console.error('DEBUG: fetchAllData called with msalInstance:', !!msalInstance);
const headers = {};
// Add authorization header if MSAL instance is provided
if (msalInstance) {
console.error('DEBUG: Attempting to get access token...');
const accessToken = await getAccessToken(msalInstance);
headers.Authorization = `Bearer ${accessToken}`;
console.error('DEBUG: Authorization header set:', !!headers.Authorization);
console.error('DEBUG: Token length:', accessToken ? accessToken.length : 0);
} else {
console.error('DEBUG: No MSAL instance provided - no authorization header will be set');
}
console.error('DEBUG: Making API request to:', `${API_URL}/data`);
console.error('DEBUG: Headers being sent:', Object.keys(headers));
const response = await axios.get(`${API_URL}/data`, { headers });
return response.data;
} catch (error) {
console.error('ERROR fetching data:', error);
console.error('ERROR response status:', error.response?.status);
console.error('ERROR response data:', error.response?.data);
throw error;
}
};

View file

@ -0,0 +1,29 @@
/**
* Mapping of Assistant IDs to friendly display names
*/
export const assistantMapping = {
'asst_Pz7uhnK7aOoYykl7KalyirY9': 'PPC',
'asst_MT0qKXI57m8Y2RVllqwFUqBe': 'Social',
'asst_vlFx0Uud1BKtp7j77Vp0pi8H': 'Internal Banners',
'asst_iiqnQ0w6l5eQAQtLJxDTklwK': 'FreeChat',
'asst_eAsIXFpSGiy7jQzyF8p0IRDA': 'Display Banners',
'asst_l9G2nl9TacmuheBsueRxTznc': 'Email (beta)'
};
/**
* Returns a friendly display name for a given Assistant ID
* @param {string} assistantId - The Assistant ID
* @returns {string|null} - The friendly display name or null if not found
*/
export const getAssistantDisplayName = (assistantId) => {
return assistantMapping[assistantId] || null;
};
/**
* Checks if an Assistant ID has a friendly name mapping
* @param {string} assistantId - The Assistant ID
* @returns {boolean} - True if the ID has a friendly name, false otherwise
*/
export const hasAssistantFriendlyName = (assistantId) => {
return assistantId in assistantMapping;
};

View file

@ -0,0 +1,184 @@
/**
* User mapping utilities for processing user data from email addresses
* Handles organization detection, friendly name extraction, and technical suffix cleanup
*/
import { userDisplayNameMap } from '../data/userDisplayNames.js';
/**
* Extract organization from email address based on domain content
* @param {string} email - The email address to analyze
* @returns {string} - 'Oliver' | 'Barclays' | 'Other'
*/
export const getOrganizationFromEmail = (email) => {
if (!email || typeof email !== 'string') {
return 'Other';
}
const lowerEmail = email.toLowerCase();
if (lowerEmail.includes('barclay')) {
return 'Barclays';
}
if (lowerEmail.includes('@oliver.agency')) {
return 'Oliver';
}
return 'Other';
};
/**
* Strip technical suffixes from email addresses
* Handles external user patterns from Barclays and other external domains
* @param {string} email - The email address to clean
* @returns {string} - Cleaned email address
*/
export const stripTechnicalSuffixes = (email) => {
if (!email || typeof email !== 'string') {
return email;
}
// Remove external user patterns:
// - _barclays.com#ext#@olivermarketing.onmicrosoft.com
// - _barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com
// - Any similar _domain#ext#@... patterns
return email
.replace(/_barclays\.com#ext#@olivermarketing\.onmicrosoft\.com$/, '')
.replace(/_barclaycard\.co\.uk#ext#@olivermarketing\.onmicrosoft\.com$/, '')
.replace(/_[^_]+\.(com|co\.uk)#ext#@olivermarketing\.onmicrosoft\.com$/, '');
};
/**
* Extract friendly name from email prefix
* Converts email prefix to "FirstName LastName" format
* Handles both Oliver agency camelCase and external dot.separated formats
* @param {string} email - The email address to parse
* @returns {string} - Formatted friendly name
*/
export const getFriendlyNameFromEmail = (email) => {
if (!email || typeof email !== 'string') {
return email || 'Unknown User';
}
// First check manual mapping for exact matches
if (userDisplayNameMap[email.toLowerCase()]) {
return userDisplayNameMap[email.toLowerCase()];
}
// Strip technical suffixes first
const cleanEmail = stripTechnicalSuffixes(email);
// Extract the part before @
const prefix = cleanEmail.split('@')[0];
if (!prefix) {
return email;
}
// Handle Oliver agency emails (camelCase format like 'michaelclervi')
if (email.includes('@oliver.agency')) {
// Try to split camelCase into words
const words = prefix.match(/[A-Z][a-z]*|[a-z]+/g) || [prefix];
if (words.length >= 2) {
return words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
} else {
// Single word, just capitalize first letter
return prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase();
}
} else {
// Handle external emails (dot.separated format like 'adam.webb')
const spaced = prefix.replace(/[._-]/g, ' ');
// Title case each word
const titleCased = spaced
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return titleCased;
}
};
/**
* Process raw user list into organized structure for filtering
* @param {string[]} users - Array of raw User_ID values (emails)
* @returns {Object} - Processed user data with organization grouping
*/
export const processUserData = (users) => {
if (!Array.isArray(users)) {
return {
byOrganization: {
'Oliver': [],
'Barclays': [],
'Other': []
},
friendlyNameMap: {},
organizationMap: {}
};
}
const byOrganization = {
'Oliver': [],
'Barclays': [],
'Other': []
};
const friendlyNameMap = {}; // email -> friendly name
const organizationMap = {}; // email -> organization
users.forEach(email => {
if (!email) return;
const organization = getOrganizationFromEmail(email);
const friendlyName = getFriendlyNameFromEmail(email);
byOrganization[organization].push(email);
friendlyNameMap[email] = friendlyName;
organizationMap[email] = organization;
});
// Sort users within each organization by friendly name
Object.keys(byOrganization).forEach(org => {
byOrganization[org].sort((a, b) => {
const nameA = friendlyNameMap[a].toLowerCase();
const nameB = friendlyNameMap[b].toLowerCase();
return nameA.localeCompare(nameB);
});
});
return {
byOrganization,
friendlyNameMap,
organizationMap
};
};
/**
* Get filtered user list based on organization selection
* @param {Object} processedUserData - Result from processUserData()
* @param {string} organizationFilter - 'All Users' | 'Oliver Users' | 'Barclays Users'
* @returns {string[]} - Filtered array of user emails
*/
export const getFilteredUsers = (processedUserData, organizationFilter) => {
if (!processedUserData || !processedUserData.byOrganization) {
return [];
}
const { byOrganization } = processedUserData;
switch (organizationFilter) {
case 'Oliver Users':
return byOrganization['Oliver'] || [];
case 'Barclays Users':
return byOrganization['Barclays'] || [];
case 'All Users':
default:
return [
...(byOrganization['Oliver'] || []),
...(byOrganization['Barclays'] || []),
...(byOrganization['Other'] || [])
];
}
};

View file

@ -0,0 +1,50 @@
/**
* Brand/TOV mapping utility for transforming raw brand values to display-friendly names
*/
// Mapping from raw brand values to display names
export const BRAND_DISPLAY_MAPPING = {
'standard': 'Barclays',
'pep': 'Barclaycard'
};
// Reverse mapping from display names to raw values
export const DISPLAY_TO_BRAND_MAPPING = Object.fromEntries(
Object.entries(BRAND_DISPLAY_MAPPING).map(([key, value]) => [value, key])
);
/**
* Convert raw brand value to display name
* @param {string} rawValue - The raw brand value from the data
* @returns {string} - The display-friendly name or the original value if no mapping exists
*/
export const getBrandDisplayName = (rawValue) => {
return BRAND_DISPLAY_MAPPING[rawValue] || rawValue;
};
/**
* Convert display name back to raw brand value
* @param {string} displayName - The display name shown to users
* @returns {string} - The raw brand value or the original display name if no mapping exists
*/
export const getBrandRawValue = (displayName) => {
return DISPLAY_TO_BRAND_MAPPING[displayName] || displayName;
};
/**
* Transform an array of raw brand values to display names
* @param {string[]} rawValues - Array of raw brand values
* @returns {string[]} - Array of display names
*/
export const transformBrandValuesToDisplay = (rawValues) => {
return rawValues.map(getBrandDisplayName);
};
/**
* Check if a raw brand value has a display mapping
* @param {string} rawValue - The raw brand value to check
* @returns {boolean} - True if a mapping exists
*/
export const hasBrandMapping = (rawValue) => {
return rawValue in BRAND_DISPLAY_MAPPING;
};

View file

@ -0,0 +1,305 @@
import { parseISO, format, startOfDay, differenceInDays, isValid, addDays } from 'date-fns';
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
import { getBrandDisplayName } from './brandMapping';
/**
* Generate aggregated statistical data for conversations and messages
* This utility creates time-series data that can be used for both visualization and CSV export
*
* @param {Array} filteredConversations - Filtered conversation data
* @param {Array} filteredMessages - Filtered message data
* @param {Array} uniqueAssistants - Array of unique assistant IDs
* @param {Object} filters - Current filter state from dashboard
* @returns {Object} Aggregated data with daily/weekly/monthly breakdowns
*/
export const generateAggregatedData = (filteredConversations, filteredMessages, uniqueAssistants, filters) => {
if (!filteredConversations.length && !filteredMessages.length) {
return {
chartData: [],
exportData: [],
aggregationType: 'daily',
dateRange: null
};
}
// Get all timestamps for date range calculation
const allTimestamps = [
...filteredConversations.map(conv => conv.StartTime),
...filteredMessages.map(msg => msg.Timestamp)
].filter(Boolean);
const validDates = allTimestamps
.map(dateStr => {
try { return parseISO(dateStr); } catch { return null; }
})
.filter(dateObj => dateObj && isValid(dateObj));
if (validDates.length === 0) {
return {
chartData: [],
exportData: [],
aggregationType: 'daily',
dateRange: null
};
}
const minDate = startOfDay(new Date(Math.min(...validDates)));
const maxDate = startOfDay(new Date(Math.max(...validDates)));
const daysDiff = differenceInDays(maxDate, minDate) + 1;
// Determine aggregation type based on date range
const useWeekly = daysDiff > 60 && daysDiff <= 180;
const useMonthly = daysDiff > 180;
let aggregationType = 'daily';
if (useMonthly) {
aggregationType = 'monthly';
} else if (useWeekly) {
aggregationType = 'weekly';
}
// Initialize data structures
const dataByDate = {};
const monthlyData = {};
const weeklyData = {};
for (let i = 0; i < daysDiff; i++) {
const currentDate = addDays(minDate, i);
const dateKey = format(currentDate, 'yyyy-MM-dd');
const monthKey = format(currentDate, 'yyyy-MM');
const dayOffsetFromMin = differenceInDays(currentDate, minDate);
const weekNumber = Math.floor(dayOffsetFromMin / 7) + 1;
const weekKey = `Week ${weekNumber}`;
// Daily data
dataByDate[dateKey] = {
date: dateKey,
conversationsTotal: 0,
messagesTotal: 0,
details: [] // Will store breakdown by assistant/user/brand
};
// Initialize assistant counters
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
dataByDate[dateKey][`conversations_${assistantId}`] = 0;
dataByDate[dateKey][`messages_${assistantId}`] = 0;
});
}
// Monthly data
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = {
month: monthKey,
conversationsTotal: 0,
messagesTotal: 0,
details: []
};
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
monthlyData[monthKey][`conversations_${assistantId}`] = 0;
monthlyData[monthKey][`messages_${assistantId}`] = 0;
});
}
}
// Weekly data
if (!weeklyData[weekKey]) {
const weekStartDate = addDays(minDate, (weekNumber - 1) * 7);
weeklyData[weekKey] = {
week: weekKey,
startDateFormatted: format(weekStartDate, 'MMM d, yyyy'),
conversationsTotal: 0,
messagesTotal: 0,
details: []
};
if (uniqueAssistants && uniqueAssistants.length > 0) {
uniqueAssistants.forEach(assistantId => {
weeklyData[weekKey][`conversations_${assistantId}`] = 0;
weeklyData[weekKey][`messages_${assistantId}`] = 0;
});
}
}
}
// Aggregate conversation counts
filteredConversations.forEach(conv => {
if (conv.StartTime) {
const parsedDate = parseISO(conv.StartTime);
if (isValid(parsedDate)) {
const dateStr = format(parsedDate, 'yyyy-MM-dd');
const monthStr = format(parsedDate, 'yyyy-MM');
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
const weekKeyAgg = `Week ${weekNumAgg}`;
// Aggregate totals
if (dataByDate[dateStr]) {
dataByDate[dateStr].conversationsTotal++;
if (conv.Assistant_ID && dataByDate[dateStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
dataByDate[dateStr][`conversations_${conv.Assistant_ID}`]++;
}
}
if (monthlyData[monthStr]) {
monthlyData[monthStr].conversationsTotal++;
if (conv.Assistant_ID && monthlyData[monthStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
monthlyData[monthStr][`conversations_${conv.Assistant_ID}`]++;
}
}
if (weeklyData[weekKeyAgg]) {
weeklyData[weekKeyAgg].conversationsTotal++;
if (conv.Assistant_ID && weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`] !== undefined) {
weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`]++;
}
}
}
}
});
// Aggregate message counts
filteredMessages.forEach(msg => {
if (msg.Timestamp) {
const parsedDate = parseISO(msg.Timestamp);
if (isValid(parsedDate)) {
const dateStr = format(parsedDate, 'yyyy-MM-dd');
const monthStr = format(parsedDate, 'yyyy-MM');
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
const weekKeyAgg = `Week ${weekNumAgg}`;
// Aggregate totals
if (dataByDate[dateStr]) {
dataByDate[dateStr].messagesTotal++;
if (msg.Assistant_ID && dataByDate[dateStr][`messages_${msg.Assistant_ID}`] !== undefined) {
dataByDate[dateStr][`messages_${msg.Assistant_ID}`]++;
}
}
if (monthlyData[monthStr]) {
monthlyData[monthStr].messagesTotal++;
if (msg.Assistant_ID && monthlyData[monthStr][`messages_${msg.Assistant_ID}`] !== undefined) {
monthlyData[monthStr][`messages_${msg.Assistant_ID}`]++;
}
}
if (weeklyData[weekKeyAgg]) {
weeklyData[weekKeyAgg].messagesTotal++;
if (msg.Assistant_ID && weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`] !== undefined) {
weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`]++;
}
}
}
}
});
// Convert to arrays and sort
const dailyDataArray = Object.values(dataByDate).sort((a, b) => a.date.localeCompare(b.date));
const monthlyDataArray = Object.values(monthlyData).sort((a, b) => a.month.localeCompare(b.month));
const weeklyDataArray = Object.values(weeklyData).sort((a,b) => {
const weekNumA = parseInt(a.week.split(' ')[1]);
const weekNumB = parseInt(b.week.split(' ')[1]);
return weekNumA - weekNumB;
});
// Select the appropriate data based on aggregation type
let chartData = dailyDataArray;
if (aggregationType === 'monthly') {
chartData = monthlyDataArray;
} else if (aggregationType === 'weekly') {
chartData = weeklyDataArray;
}
// Generate export data - flatten the aggregated data for CSV export
const exportData = generateExportData(chartData, aggregationType, filters, uniqueAssistants);
return {
chartData,
exportData,
aggregationType,
dateRange: { minDate, maxDate, daysDiff }
};
};
/**
* Generate flattened data for CSV export
* Creates rows with columns: Date, Period_Type, Assistant Name, Brand, User_Organization, Conversation_Count, Message_Count
*/
const generateExportData = (chartData, aggregationType, filters, uniqueAssistants) => {
const exportRows = [];
chartData.forEach(dataPoint => {
// Get the appropriate date field based on aggregation type
let dateValue, periodType;
if (aggregationType === 'monthly') {
dateValue = dataPoint.month;
periodType = 'Monthly';
} else if (aggregationType === 'weekly') {
dateValue = dataPoint.week;
periodType = 'Weekly';
} else {
dateValue = dataPoint.date;
periodType = 'Daily';
}
// If specific assistant is selected, only export data for that assistant
if (filters.assistant && filters.assistant !== 'All Assistants') {
// Get the assistant ID from the display name
const assistantId = window.assistantDisplayToIdMap?.[filters.assistant] || filters.assistant;
// Only export if assistant has a friendly name
if (hasAssistantFriendlyName(assistantId)) {
const assistantDisplayName = getAssistantDisplayName(assistantId);
const conversationCount = dataPoint[`conversations_${assistantId}`] || 0;
const messageCount = dataPoint[`messages_${assistantId}`] || 0;
exportRows.push({
Date: dateValue,
Period_Type: periodType,
'Assistant Name': assistantDisplayName,
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
Conversation_Count: conversationCount,
Message_Count: messageCount
});
}
} else {
// Export data for all assistants or totals
if (uniqueAssistants && uniqueAssistants.length > 0) {
// Create separate rows for each assistant
uniqueAssistants.forEach(assistantId => {
// Only export assistants with friendly names
if (hasAssistantFriendlyName(assistantId)) {
const assistantDisplayName = getAssistantDisplayName(assistantId);
const conversationCount = dataPoint[`conversations_${assistantId}`] || 0;
const messageCount = dataPoint[`messages_${assistantId}`] || 0;
// Only include rows with actual data
if (conversationCount > 0 || messageCount > 0) {
exportRows.push({
Date: dateValue,
Period_Type: periodType,
'Assistant Name': assistantDisplayName,
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
Conversation_Count: conversationCount,
Message_Count: messageCount
});
}
}
});
}
// Also add total row
exportRows.push({
Date: dateValue,
Period_Type: periodType,
'Assistant Name': 'All Assistants',
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
Conversation_Count: dataPoint.conversationsTotal || 0,
Message_Count: dataPoint.messagesTotal || 0
});
}
});
return exportRows;
};

8
frontend/vite.config.js Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/dashboard/',
})

31
run.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/bash
# Start the backend server with Hypercorn
echo "Starting backend server with Hypercorn..."
cd backend
python app.py &
BACKEND_PID=$!
# Wait a bit for the backend to start
sleep 2
# Start the frontend dev server
echo "Starting frontend dev server..."
cd ../frontend
npm run dev &
FRONTEND_PID=$!
# Function to handle script termination
cleanup() {
echo "Shutting down servers..."
kill $FRONTEND_PID
kill $BACKEND_PID
exit 0
}
# Set up trap to handle Ctrl+C and other termination signals
trap cleanup INT TERM
# Keep the script running
echo "Both servers are running. Press Ctrl+C to stop."
wait

69
test_user_mapping.js Normal file
View file

@ -0,0 +1,69 @@
// Test the current userMapping logic with real email data
const stripTechnicalSuffixes = (email) => {
if (!email || typeof email !== 'string') {
return email;
}
return email
.replace(/_barclays\.com#ext#@olivermarketing\.onmicrosoft\.com$/, '')
.replace(/_barclaycard\.co\.uk#ext#@olivermarketing\.onmicrosoft\.com$/, '')
.replace(/_[^_]+\.(com|co\.uk)#ext#@olivermarketing\.onmicrosoft\.com$/, '');
};
const getFriendlyNameFromEmail = (email) => {
if (!email || typeof email !== 'string') {
return email || 'Unknown User';
}
const cleanEmail = stripTechnicalSuffixes(email);
const prefix = cleanEmail.split('@')[0];
if (!prefix) {
return email;
}
// Handle Oliver agency emails (camelCase format like 'michaelclervi')
if (email.includes('@oliver.agency')) {
// Try to split camelCase into words
const words = prefix.match(/[A-Z][a-z]*|[a-z]+/g) || [prefix];
if (words.length >= 2) {
return words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
} else {
// Single word, just capitalize first letter
return prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase();
}
} else {
// Handle external emails (dot.separated format like 'adam.webb')
const spaced = prefix.replace(/[._-]/g, ' ');
// Title case each word
const titleCased = spaced
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return titleCased;
}
};
// Test with actual email patterns
const testEmails = [
'michaelclervi@oliver.agency',
'adam.webb_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com',
'aileen.stevenson_barclays.com#ext#@olivermarketing.onmicrosoft.com',
'christopher.x.perkins_barclays.com#ext#@olivermarketing.onmicrosoft.com',
'jeremycrockerwhite@oliver.agency',
'armanpreetkapoor@oliver.agency',
'daveporter@oliver.agency'
];
console.log('Testing email name extraction:');
testEmails.forEach(email => {
const stripped = stripTechnicalSuffixes(email);
const friendly = getFriendlyNameFromEmail(email);
console.log(`${email}`);
console.log(` Stripped: ${stripped}`);
console.log(` Friendly: ${friendly}`);
console.log('');
});