diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..939fbd9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.venv/
+venv/
+__pycache__/
+*.pyc
+.git/
+.gitignore
+.env
+.env.*
+!.env.example
+.pytest_cache/
+.DS_Store
+node_modules/
+frontend/node_modules/
+frontend/dist/
+logs/
+reports/*.docx
+reports/*.md
+*.egg-info/
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c8c25f7
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,34 @@
+# --- Anthropic ---
+ANTHROPIC_API_KEY=sk-ant-...
+
+# --- Airtable: tasks (Master tracker) ---
+PULSE_AIRTABLE_API_KEY=pat...
+PULSE_AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
+PULSE_AIRTABLE_TABLE_ID=tblXXXXXXXXXXXXXX
+
+# --- Airtable: resource bookings ---
+# Falls back to PULSE_AIRTABLE_API_KEY if PULSE_RESOURCE_API_KEY is unset
+PULSE_RESOURCE_API_KEY=pat...
+PULSE_RESOURCE_BASE_ID=appXXXXXXXXXXXXXX
+PULSE_RESOURCE_TABLE_ID=tblXXXXXXXXXXXXXX
+
+# --- Azure AD (Microsoft SSO) ---
+# Single-page application registration in your Azure tenant.
+# Add redirect URIs for both prod and local dev.
+AZURE_TENANT_ID=
+AZURE_CLIENT_ID=
+# Comma-separated list (e.g. oliver.agency). Empty = allow any tenant user.
+AUTH_ALLOWED_DOMAINS=oliver.agency
+# Local dev only — skips token validation, sets g.user_email = 'dev@oliver.agency'
+DEV_AUTH_BYPASS=false
+
+# --- Postgres ---
+# Inside the compose network the host is `db`; override for local-against-host runs.
+DATABASE_URL=postgresql+psycopg://pulse:pulse@db:5432/pulse
+POSTGRES_USER=pulse
+POSTGRES_PASSWORD=pulse
+POSTGRES_DB=pulse
+
+# --- Server ---
+# Host port the deploy script picks; the container always listens on 5051.
+PROGRAMME_PULSE_PORT=5051
diff --git a/.gitignore b/.gitignore
index b24d71e..3f44d98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,50 +1,30 @@
-# These are some examples of commonly ignored file patterns.
-# You should customize this list as applicable to your project.
-# Learn more about .gitignore:
-# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
-
-# Node artifact files
-node_modules/
-dist/
-
-# Compiled Java class files
-*.class
-
-# Compiled Python bytecode
-*.py[cod]
-
-# Log files
-*.log
-
-# Package files
-*.jar
-
-# Maven
-target/
-dist/
-
-# JetBrains IDE
-.idea/
-
-# Unit test reports
-TEST*.xml
-
-# Generated by MacOS
+.env
+.env.*
+!.env.example
.DS_Store
-
-# Generated by Windows
Thumbs.db
+__pycache__/
+*.py[cod]
+*.log
+logs/
+reports/*.docx
+reports/*.md
+*.egg-info/
+.venv/
+venv/
-# Applications
-*.app
-*.exe
-*.war
+# deploy artefacts
+deploy/apache-programme-pulse.conf
+.deployed
-# Large media files
-*.mp4
-*.tiff
-*.avi
-*.flv
-*.mov
-*.wmv
+# frontend
+frontend/node_modules/
+frontend/dist/
+frontend/.env
+frontend/.env.local
+frontend/tsconfig.tsbuildinfo
+# editor / tooling
+.claude/
+.idea/
+.vscode/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5c370a7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+FROM python:3.11-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends gcc libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install -r requirements.txt
+
+COPY . .
+
+ENV PORT=5051
+EXPOSE 5051
+
+# Run migrations on every start (idempotent), then gunicorn with gevent for SSE.
+CMD ["sh", "-c", "alembic upgrade head && exec gunicorn web_app:app --bind 0.0.0.0:${PORT} --worker-class gevent --workers 1 --timeout 300 --access-logfile -"]
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..0eaf2f4
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: gunicorn web_app:app --bind 0.0.0.0:$PORT --worker-class gevent --workers 1 --timeout 300
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ab7cfc6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,137 @@
+# Programme Pulse
+
+A small Flask app for chatting with live Airtable programme data and generating two-tier status reports (Manager Summary + Full Report) as Word documents.
+
+Runs locally on port 5051 by default.
+
+---
+
+## What it does
+
+- Loads tasks from one Airtable base (the Master tracker) and resource bookings from a second Airtable base.
+- Lets you chat with the data through Claude. Streaming response, stop button, thumbs up/down feedback.
+- Reads `.docx` meeting transcripts from `docs/Programme Pulse transcripts/` and uses them as supporting context.
+- Generates two reports on demand: a Manager Summary and a Full Report. Both saved as `.docx` in `reports/`.
+- Saves your stated preferences to `data/preferences.md` so future sessions remember them.
+
+---
+
+## Requirements
+
+- Python 3.11 or newer
+- An Anthropic API key
+- Two Airtable bases with personal access tokens
+
+---
+
+## Setup
+
+1. Create and activate a virtual environment.
+
+ ```sh
+ python3 -m venv .venv
+ source .venv/bin/activate
+ ```
+
+2. Install dependencies.
+
+ ```sh
+ pip install -r requirements.txt
+ ```
+
+3. Copy the example env file and fill in your own keys.
+
+ ```sh
+ cp .env.example .env
+ ```
+
+ Open `.env` and add:
+ - `ANTHROPIC_API_KEY`
+ - `PULSE_AIRTABLE_API_KEY`, `PULSE_AIRTABLE_BASE_ID`, `PULSE_AIRTABLE_TABLE_ID` — the tasks base
+ - `PULSE_RESOURCE_API_KEY`, `PULSE_RESOURCE_BASE_ID`, `PULSE_RESOURCE_TABLE_ID` — the resource booking base
+ - `FLASK_SECRET_KEY` — any random string
+
+4. Run the app.
+
+ ```sh
+ python web_app.py
+ ```
+
+ Or with gunicorn (recommended — streaming works better):
+
+ ```sh
+ gunicorn web_app:app --bind 0.0.0.0:5051 --worker-class gevent --workers 1 --timeout 300
+ ```
+
+5. Open http://localhost:5051 in your browser.
+
+---
+
+## Adding meeting transcripts
+
+Drop Teams `.docx` transcript exports into `docs/Programme Pulse transcripts/`. The app picks them up on restart and feeds them to the chat and the reports as supporting narrative.
+
+Each `.docx` is parsed paragraph by paragraph. One speaker turn per paragraph.
+
+---
+
+## Folder layout
+
+```
+web_app.py Flask entry point
+src/
+ airtable_client.py Pulls tasks and bookings from Airtable
+ analyzer.py Health signal logic
+ claude_client.py Anthropic SDK wrapper, streaming
+ preferences.py Saves liked/disliked patterns
+ prompts.py System prompts and snapshot builders
+ reporter.py Word document generation
+ transcripts.py Parses .docx meeting transcripts
+templates/index.html The single-page UI
+design/ Fonts and design tokens
+data/ preferences.md lives here
+docs/Programme Pulse transcripts/ Drop .docx files here
+reports/ Generated Word files land here
+logs/ Runtime logs
+tests/ Pytest suite
+```
+
+---
+
+## Airtable schema notes
+
+The tasks base must include these fields (rename the constants in `src/airtable_client.py` if your column names differ):
+- Title / task name
+- Owner (collaborator)
+- Status / Progress
+- Priority
+- Due date
+- Notes
+- Last modified
+
+The resource base needs:
+- Resource name
+- Project
+- Division
+- Start date, end date
+- Hours
+- Status
+- Conflict flag
+
+Open `src/airtable_client.py` to map field names to your own base.
+
+---
+
+## Running tests
+
+```sh
+pytest
+```
+
+---
+
+## Deploying elsewhere
+
+The app is stateless apart from `data/preferences.md`, `reports/`, and `logs/`. Mount those as volumes if you containerise it. The `Procfile` is set up for any platform that respects it (Heroku, Render, Fly, etc).
+
+Set `PORT` via env var. Everything else is read from `.env` or the platform's secret store.
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000..a97945f
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/alembic/env.py b/alembic/env.py
new file mode 100644
index 0000000..43b6ca5
--- /dev/null
+++ b/alembic/env.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import os
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from src.db import Base
+
+config = context.config
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# DATABASE_URL from env wins over alembic.ini
+db_url = os.getenv("DATABASE_URL")
+if db_url:
+ config.set_main_option("sqlalchemy.url", db_url)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=config.get_main_option("sqlalchemy.url"),
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic/script.py.mako b/alembic/script.py.mako
new file mode 100644
index 0000000..b1f8b89
--- /dev/null
+++ b/alembic/script.py.mako
@@ -0,0 +1,27 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..4168b38
--- /dev/null
+++ b/alembic/versions/0001_initial.py
@@ -0,0 +1,70 @@
+"""initial schema
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-05-07
+
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision: str = "0001"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "preferences",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("user_email", sa.String(320), nullable=False),
+ sa.Column("text", sa.Text, nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_preferences_user_email", "preferences", ["user_email"])
+
+ op.create_table(
+ "chat_messages",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("user_email", sa.String(320), nullable=False),
+ sa.Column("role", sa.String(16), nullable=False),
+ sa.Column("content", sa.Text, nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_chat_user_created", "chat_messages", ["user_email", "created_at"])
+
+ op.create_table(
+ "reports",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("user_email", sa.String(320), nullable=False),
+ sa.Column("summary_md", sa.Text, nullable=False),
+ sa.Column("summary_doc_path", sa.String(512), nullable=False),
+ sa.Column("full_doc_path", sa.String(512), nullable=False),
+ sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_reports_user_email", "reports", ["user_email"])
+
+ op.create_table(
+ "feedback_events",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("user_email", sa.String(320), nullable=False),
+ sa.Column("rating", sa.String(8), nullable=False),
+ sa.Column("message_text", sa.Text, nullable=False),
+ sa.Column("extracted_insight", sa.Text, nullable=False),
+ sa.Column("preference_id", sa.Integer, sa.ForeignKey("preferences.id"), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_feedback_user_email", "feedback_events", ["user_email"])
+
+
+def downgrade() -> None:
+ op.drop_table("feedback_events")
+ op.drop_table("reports")
+ op.drop_table("chat_messages")
+ op.drop_table("preferences")
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/deploy/apache-programme-pulse.conf.tmpl b/deploy/apache-programme-pulse.conf.tmpl
new file mode 100644
index 0000000..aa88f77
--- /dev/null
+++ b/deploy/apache-programme-pulse.conf.tmpl
@@ -0,0 +1,21 @@
+ProxyTimeout 300
+TimeOut 300
+
+# Backend API — proxied to the Flask container on the chosen host port.
+# flushpackets=on is essential for the SSE chat stream; without it Apache
+# buffers and tokens stutter.
+ProxyPass /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300 flushpackets=on
+ProxyPassReverse /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/
+
+# SPA — static files served by Apache directly from the build output.
+Alias /programme-pulse /var/www/html/programme-pulse
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride None
+ Require all granted
+ RewriteEngine On
+ RewriteBase /programme-pulse/
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule ^ index.html [L]
+
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..ef8800f
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,278 @@
+#!/usr/bin/env bash
+# Programme Pulse — deploy script.
+#
+# Idempotent. Safe to re-run on the dev server.
+# Public URL: https://optical-dev.oliver.solutions/programme-pulse/
+#
+# Server layout (mirrors /oliver-sales-ops-platform/, /gsb/, etc.):
+# /opt/programme-pulse/ — repo + docker-compose
+# /var/www/html/programme-pulse/ — built SPA, served by Apache
+#
+# What it does:
+# 1. Sanity (.env, docker, git on PATH).
+# 2. Auto-pick free host port (preferred: PROGRAMME_PULSE_PORT, default 5051).
+# If taken, scans 5051..5099 and persists the chosen value to .env.
+# 3. Render deploy/apache-programme-pulse.conf from .tmpl.
+# 4. git pull (--no-pull to skip).
+# 5. docker compose build && up -d (--no-build to skip).
+# 6. Build frontend SPA in a one-shot node:20 container and rsync
+# dist/ to /var/www/html/programme-pulse/ (--no-frontend to skip).
+# 7. Poll /api/health until ready.
+# 8. Print the apache Include line and reload reminder.
+#
+# Usage:
+# ./deploy/deploy.sh # full deploy
+# ./deploy/deploy.sh --no-pull # skip git pull
+# ./deploy/deploy.sh --no-build # skip docker rebuild
+# ./deploy/deploy.sh --no-frontend # skip SPA build/copy
+# ./deploy/deploy.sh --logs # tail backend logs after deploy
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+COMPOSE_PROJECT="programme-pulse"
+URL_PATH="/programme-pulse"
+WEB_ROOT="/var/www/html/programme-pulse"
+
+cd "$REPO_ROOT"
+
+log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
+err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; }
+ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; }
+
+DO_PULL=1
+DO_BUILD=1
+DO_FRONTEND=1
+TAIL_LOGS=0
+
+for arg in "$@"; do
+ case "$arg" in
+ --no-pull) DO_PULL=0 ;;
+ --no-build) DO_BUILD=0 ;;
+ --no-frontend) DO_FRONTEND=0 ;;
+ --logs) TAIL_LOGS=1 ;;
+ --help|-h)
+ sed -n '2,/^set/p' "$0" | grep -E '^# ' | sed 's/^# //'
+ exit 0
+ ;;
+ *)
+ err "Unknown flag: $arg (try --help)"
+ exit 2
+ ;;
+ esac
+done
+
+# 1. Sanity
+[[ -f docker-compose.yml ]] || { err "docker-compose.yml not found in $REPO_ROOT"; exit 1; }
+if [[ ! -f .env ]]; then
+ err ".env not found. Copy .env.example and fill it in:"
+ err " cp .env.example .env && \$EDITOR .env"
+ exit 1
+fi
+command -v docker >/dev/null 2>&1 || { err "docker not on PATH"; exit 1; }
+command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; }
+
+# ---------- helpers ----------
+
+port_in_use() {
+ local port=$1
+ local pid=""
+ if command -v lsof >/dev/null 2>&1; then
+ pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 )
+ else
+ pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 )
+ fi
+ [[ -n "$pid" ]]
+}
+
+find_free_port() {
+ local preferred=$1
+ local start=$2
+ local end=$3
+ if ! port_in_use "$preferred"; then
+ printf '%s' "$preferred"
+ return 0
+ fi
+ local p
+ for ((p=start; p<=end; p++)); do
+ if ! port_in_use "$p"; then
+ printf '%s' "$p"
+ return 0
+ fi
+ done
+ return 1
+}
+
+set_env_var() {
+ local key=$1
+ local value=$2
+ local file="${REPO_ROOT}/.env"
+ if grep -q "^${key}=" "$file" 2>/dev/null; then
+ sed -i.bak "s#^${key}=.*#${key}=${value}#" "$file"
+ rm -f "${file}.bak"
+ else
+ printf '%s=%s\n' "$key" "$value" >> "$file"
+ fi
+}
+
+get_env_var() {
+ grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true
+}
+
+# ---------- 2. Pick port ----------
+
+DEFAULT_PORT=5051
+APP_PORT=$(get_env_var PROGRAMME_PULSE_PORT); APP_PORT=${APP_PORT:-$DEFAULT_PORT}
+PREV_APP_PORT="$APP_PORT"
+
+log "Resolving host port (preferred: $APP_PORT)…"
+
+RUNNING=$(docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$RUNNING" -gt 0 ]]; then
+ ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — keeping current port assignment."
+else
+ NEW_PORT=$(find_free_port "$APP_PORT" 5051 5099) || NEW_PORT=""
+ if [[ -z "$NEW_PORT" ]]; then
+ err "Could not find a free port in 5051..5099."
+ exit 1
+ fi
+ [[ "$NEW_PORT" != "$APP_PORT" ]] && warn "port $APP_PORT busy → using $NEW_PORT"
+ APP_PORT=$NEW_PORT
+ set_env_var PROGRAMME_PULSE_PORT "$APP_PORT"
+ ok "Port: $APP_PORT (persisted to .env)"
+fi
+
+# ---------- 3. Render apache conf ----------
+
+APACHE_TMPL="$REPO_ROOT/deploy/apache-programme-pulse.conf.tmpl"
+APACHE_CONF="$REPO_ROOT/deploy/apache-programme-pulse.conf"
+if [[ -f "$APACHE_TMPL" ]]; then
+ sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
+ ok "Rendered apache-programme-pulse.conf with app port $APP_PORT"
+else
+ warn "apache-programme-pulse.conf.tmpl missing — leaving deploy/apache-programme-pulse.conf untouched."
+fi
+
+# ---------- 4. git pull ----------
+
+if (( DO_PULL )); then
+ log "git pull origin main"
+ git pull --ff-only origin main
+fi
+
+# ---------- 5. Backend build + up ----------
+
+if (( DO_BUILD )); then
+ log "docker compose build"
+ docker compose -p "$COMPOSE_PROJECT" build
+fi
+
+log "docker compose up -d (db + app)"
+docker compose -p "$COMPOSE_PROJECT" up -d
+
+# ---------- 6. Frontend build + sync ----------
+
+if (( DO_FRONTEND )); then
+ AZ_TENANT=$(get_env_var VITE_AZURE_TENANT_ID); AZ_TENANT=${AZ_TENANT:-$(get_env_var AZURE_TENANT_ID)}
+ AZ_CLIENT=$(get_env_var VITE_AZURE_CLIENT_ID); AZ_CLIENT=${AZ_CLIENT:-$(get_env_var AZURE_CLIENT_ID)}
+ if [[ -z "$AZ_TENANT" || -z "$AZ_CLIENT" ]]; then
+ err "AZURE_TENANT_ID and AZURE_CLIENT_ID must be set in .env to build the SPA."
+ exit 1
+ fi
+ # Mirror DEV_AUTH_BYPASS into VITE_DEV_AUTH_BYPASS so a single env var
+ # decides both backend token validation and frontend MSAL gating.
+ VITE_BYPASS=$(get_env_var VITE_DEV_AUTH_BYPASS)
+ [[ -z "$VITE_BYPASS" ]] && VITE_BYPASS=$(get_env_var DEV_AUTH_BYPASS)
+ [[ -z "$VITE_BYPASS" ]] && VITE_BYPASS="false"
+
+ log "Building Vite SPA (VITE_DEV_AUTH_BYPASS=${VITE_BYPASS}) in a one-shot node:20 container…"
+ docker run --rm \
+ -v "$REPO_ROOT/frontend:/app" \
+ -w /app \
+ -e VITE_AZURE_TENANT_ID="$AZ_TENANT" \
+ -e VITE_AZURE_CLIENT_ID="$AZ_CLIENT" \
+ -e VITE_API_BASE="/programme-pulse/api" \
+ -e VITE_DEV_AUTH_BYPASS="$VITE_BYPASS" \
+ node:20-alpine \
+ sh -c "npm install --silent && npm run build"
+
+ if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then
+ err "Vite build did not produce frontend/dist — aborting frontend sync."
+ exit 1
+ fi
+
+ log "Syncing frontend/dist/ → $WEB_ROOT/"
+ if [[ ! -d "$WEB_ROOT" ]]; then
+ if command -v sudo >/dev/null 2>&1; then
+ sudo mkdir -p "$WEB_ROOT"
+ else
+ mkdir -p "$WEB_ROOT"
+ fi
+ fi
+
+ if command -v rsync >/dev/null 2>&1; then
+ if [[ -w "$WEB_ROOT" ]]; then
+ rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
+ else
+ sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
+ fi
+ else
+ if [[ -w "$WEB_ROOT" ]]; then
+ rm -rf "$WEB_ROOT"/*
+ cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
+ else
+ sudo rm -rf "$WEB_ROOT"/*
+ sudo cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
+ fi
+ fi
+ ok "SPA synced to $WEB_ROOT"
+fi
+
+# ---------- 7. Health poll ----------
+
+log "Waiting for /api/health on :$APP_PORT (max 60s)…"
+for i in $(seq 1 30); do
+ if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
+ ok "Backend healthy"
+ break
+ fi
+ sleep 2
+ if (( i == 30 )); then
+ err "Backend did not become healthy within 60s. Recent logs:"
+ docker compose -p "$COMPOSE_PROJECT" logs app --tail 40 || true
+ exit 1
+ fi
+done
+
+# ---------- 8. Report ----------
+
+ok "Deploy complete."
+echo
+echo " Backend (local): http://127.0.0.1:${APP_PORT}/api/health"
+echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/"
+echo " SPA on disk: $WEB_ROOT"
+echo " Port: $APP_PORT"
+echo
+echo " Apache include line for the merged vhost:"
+echo " Include $REPO_ROOT/deploy/apache-programme-pulse.conf"
+if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-programme-pulse.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then
+ echo
+ warn "Backend port changed (or first deploy). Add the Include line above to:"
+ echo " /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
+ echo " inside , then reload Apache:"
+ echo " sudo apachectl configtest && sudo systemctl reload apache2"
+fi
+TRANSCRIPTS_DIR="$REPO_ROOT/docs/Programme Pulse transcripts"
+if [[ ! -d "$TRANSCRIPTS_DIR" ]] || [[ -z "$(ls -A "$TRANSCRIPTS_DIR" 2>/dev/null)" ]]; then
+ echo
+ warn "Transcripts folder is empty: $TRANSCRIPTS_DIR"
+ echo " Drop Teams .docx exports there to give the chat narrative context."
+fi
+echo
+
+if (( TAIL_LOGS )); then
+ log "Tailing app logs (Ctrl-C to stop)…"
+ docker compose -p "$COMPOSE_PROJECT" logs -f app
+fi
diff --git a/design/fonts/typography.css b/design/fonts/typography.css
new file mode 100644
index 0000000..e401b19
--- /dev/null
+++ b/design/fonts/typography.css
@@ -0,0 +1,13 @@
+@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@700;900&family=Inter:wght@400;500;600&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@700;900&family=Commissioner:wght@400;500&display=swap');
+
+/* Recommended pairing: Merriweather + Inter */
+:root {
+ --font-heading: 'Merriweather', serif;
+ --font-body: 'Inter', sans-serif;
+}
+
+/* Alternatives */
+/* Option 2: DM Serif Display + DM Sans */
+/* Option 3: Fraunces + Commissioner */
diff --git a/design/tokens/colors.json b/design/tokens/colors.json
new file mode 100644
index 0000000..d4ac55a
--- /dev/null
+++ b/design/tokens/colors.json
@@ -0,0 +1,18 @@
+{
+ "primary": "#0071e3",
+ "secondary": "#cc7217",
+ "accent": "#7d00fa",
+ "neutrals": [
+ "#171f27",
+ "#51575d",
+ "#8b8f93",
+ "#c5c7c9",
+ "#ffffff"
+ ],
+ "semantic": {
+ "success": "#22c55e",
+ "warning": "#f59e0b",
+ "error": "#ef4444",
+ "info": "#3b82f6"
+ }
+}
\ No newline at end of file
diff --git a/design/tokens/tailwind.config.ts b/design/tokens/tailwind.config.ts
new file mode 100644
index 0000000..a7b3c97
--- /dev/null
+++ b/design/tokens/tailwind.config.ts
@@ -0,0 +1,26 @@
+import type { Config } from 'tailwindcss'
+
+const config: Config = {
+ theme: {
+ extend: {
+ colors: {
+ primary: '#0071e3',
+ secondary: '#cc7217',
+ accent: '#7d00fa',
+ neutral: {
+ 100: '#ffffff',
+ 200: '#c5c7c9',
+ 300: '#8b8f93',
+ 400: '#51575d',
+ 500: '#171f27',
+ },
+ success: '#22c55e',
+ warning: '#f59e0b',
+ error: '#ef4444',
+ info: '#3b82f6',
+ },
+ },
+ },
+}
+
+export default config
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..98766c9
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,43 @@
+# Top-level project name pinned per global Docker policy — without this, the
+# compose file (when run from /opt/programme-pulse via deploy.sh) would default
+# to project name "programme-pulse" anyway, but pinning it is explicit and
+# survives if the file ever moves under deploy/.
+name: programme-pulse
+
+services:
+ app:
+ build: .
+ restart: unless-stopped
+ ports:
+ # Bind only to loopback — Apache fronts the container.
+ - "127.0.0.1:${PROGRAMME_PULSE_PORT:-5051}:5051"
+ environment:
+ - PORT=5051
+ - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pulse}:${POSTGRES_PASSWORD:-pulse}@db:5432/${POSTGRES_DB:-pulse}
+ env_file:
+ - .env
+ volumes:
+ - ./reports:/app/reports
+ - ./docs/Programme Pulse transcripts:/app/docs/Programme Pulse transcripts:ro
+ - ./logs:/app/logs
+ depends_on:
+ db:
+ condition: service_healthy
+
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-pulse}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pulse}
+ POSTGRES_DB: ${POSTGRES_DB:-pulse}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pulse} -d ${POSTGRES_DB:-pulse}"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+
+volumes:
+ pgdata:
diff --git a/docs/Programme Pulse transcripts/.gitkeep b/docs/Programme Pulse transcripts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..60833c2
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,15 @@
+# Azure AD app registration (Single-page application platform).
+# Add these as redirect URIs in the registration:
+# https://optical-dev.oliver.solutions/programme-pulse/
+# http://localhost:5173/programme-pulse/
+VITE_AZURE_TENANT_ID=
+VITE_AZURE_CLIENT_ID=
+
+# API base. Production: same-origin via Apache proxy → /programme-pulse/api.
+# Dev: Vite proxy forwards /programme-pulse/api → http://localhost:5051/api.
+VITE_API_BASE=/programme-pulse/api
+
+# Set to "true" to skip the MSAL sign-in gate locally. Must be paired with
+# DEV_AUTH_BYPASS=true on the backend. The SPA will render straight to the
+# signed-in UI and apiFetch will send requests without an Authorization header.
+VITE_DEV_AUTH_BYPASS=false
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..b563ef3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Programme Pulse
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..bc5d04a
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,3265 @@
+{
+ "name": "programme-pulse-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "programme-pulse-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "@azure/msal-browser": "^3.27.0",
+ "@azure/msal-react": "^2.2.0",
+ "chart.js": "^4.4.7",
+ "react": "^18.3.1",
+ "react-chartjs-2": "^5.3.0",
+ "react-dom": "^18.3.1",
+ "react-markdown": "^9.0.1",
+ "remark-gfm": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ }
+ },
+ "node_modules/@azure/msal-browser": {
+ "version": "3.30.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz",
+ "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@azure/msal-common": "14.16.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-common": {
+ "version": "14.16.1",
+ "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
+ "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.2.0.tgz",
+ "integrity": "sha512-2V+9JXeXyyjYNF92y5u0tU4el9px/V1+vkRuN+DtoxyiMHCtYQpJoaFdGWArh43zhz5aqQqiGW/iajPDSu3QsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@azure/msal-browser": "^3.27.0",
+ "react": "^16.8.0 || ^17 || ^18"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+ "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+ "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
+ "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+ "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+ "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+ "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+ "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+ "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+ "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+ "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+ "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+ "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+ "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+ "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+ "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+ "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+ "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+ "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+ "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+ "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+ "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+ "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+ "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
+ "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.352",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz",
+ "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
+ "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
+ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.3",
+ "@rollup/rollup-android-arm64": "4.60.3",
+ "@rollup/rollup-darwin-arm64": "4.60.3",
+ "@rollup/rollup-darwin-x64": "4.60.3",
+ "@rollup/rollup-freebsd-arm64": "4.60.3",
+ "@rollup/rollup-freebsd-x64": "4.60.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+ "@rollup/rollup-linux-arm64-musl": "4.60.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+ "@rollup/rollup-linux-loong64-musl": "4.60.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-musl": "4.60.3",
+ "@rollup/rollup-openbsd-x64": "4.60.3",
+ "@rollup/rollup-openharmony-arm64": "4.60.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+ "@rollup/rollup-win32-x64-gnu": "4.60.3",
+ "@rollup/rollup-win32-x64-msvc": "4.60.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..56d612f
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "programme-pulse-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@azure/msal-browser": "^3.27.0",
+ "@azure/msal-react": "^2.2.0",
+ "chart.js": "^4.4.7",
+ "react": "^18.3.1",
+ "react-chartjs-2": "^5.3.0",
+ "react-dom": "^18.3.1",
+ "react-markdown": "^9.0.1",
+ "remark-gfm": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..af0410b
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,49 @@
+import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react';
+import { loginRequest } from './auth/authConfig';
+import { DEV_AUTH_BYPASS } from './auth/devBypass';
+import Header from './components/Header';
+import ChatPanel from './components/ChatPanel';
+import ReportsCard from './components/ReportsCard';
+
+export default function App() {
+ if (DEV_AUTH_BYPASS) return ;
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+function SignIn() {
+ const { instance } = useMsal();
+ return (
+
+
+
+
Programme Pulse
+
Sign in with your OLIVER account to continue.
+
+
+
+ );
+}
+
+function SignedInApp() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/auth/apiFetch.ts b/frontend/src/auth/apiFetch.ts
new file mode 100644
index 0000000..fd2dc79
--- /dev/null
+++ b/frontend/src/auth/apiFetch.ts
@@ -0,0 +1,118 @@
+import { InteractionRequiredAuthError } from '@azure/msal-browser';
+import { loginRequest, msalInstance } from './authConfig';
+import { DEV_AUTH_BYPASS } from './devBypass';
+
+const API_BASE = (import.meta.env.VITE_API_BASE as string) || '/programme-pulse/api';
+
+async function getIdToken(): Promise {
+ if (DEV_AUTH_BYPASS) return null;
+
+ const account = msalInstance.getActiveAccount() ?? msalInstance.getAllAccounts()[0];
+ if (!account) throw new Error('No active account — sign in required');
+
+ try {
+ const result = await msalInstance.acquireTokenSilent({ ...loginRequest, account });
+ // Send the ID token, NOT the access token (per azure-ad-msal-auth skill gotcha).
+ return result.idToken;
+ } catch (err) {
+ if (err instanceof InteractionRequiredAuthError) {
+ await msalInstance.acquireTokenRedirect({ ...loginRequest, account });
+ }
+ throw err;
+ }
+}
+
+function buildUrl(path: string): string {
+ if (path.startsWith('http')) return path;
+ if (path.startsWith('/api/')) return API_BASE + path.slice(4);
+ if (path.startsWith('/')) return API_BASE + path;
+ return API_BASE + '/' + path;
+}
+
+function on401() {
+ if (DEV_AUTH_BYPASS) return;
+ msalInstance.loginRedirect(loginRequest);
+}
+
+export async function apiFetch(path: string, init: RequestInit = {}): Promise {
+ const idToken = await getIdToken();
+ const headers = new Headers(init.headers);
+ if (idToken) headers.set('Authorization', `Bearer ${idToken}`);
+ if (init.body && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+
+ const res = await fetch(buildUrl(path), { ...init, headers });
+ if (res.status === 401) {
+ on401();
+ throw new Error('Authentication required');
+ }
+ return res;
+}
+
+export async function apiJson(path: string, init: RequestInit = {}): Promise {
+ const res = await apiFetch(path, init);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json() as Promise;
+}
+
+export function apiUrl(path: string): string {
+ return buildUrl(path);
+}
+
+export async function streamSSE(
+ path: string,
+ body: unknown,
+ onChunk: (text: string) => void,
+ signal: AbortSignal,
+): Promise<{ aborted: boolean; error?: string }> {
+ const idToken = await getIdToken();
+ const headers: Record = { 'Content-Type': 'application/json' };
+ if (idToken) headers.Authorization = `Bearer ${idToken}`;
+
+ const res = await fetch(buildUrl(path), {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ signal,
+ });
+
+ if (res.status === 401) {
+ on401();
+ return { aborted: false, error: 'Authentication required' };
+ }
+ if (!res.ok || !res.body) {
+ return { aborted: false, error: `${res.status} ${res.statusText}` };
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const payload = line.slice(6);
+ if (payload === '[DONE]') return { aborted: false };
+ try {
+ const data = JSON.parse(payload);
+ if (data.error) return { aborted: false, error: String(data.error) };
+ if (typeof data.chunk === 'string') onChunk(data.chunk);
+ } catch {
+ // Partial JSON between chunks — ignore.
+ }
+ }
+ }
+ } catch (e) {
+ if ((e as Error).name === 'AbortError') return { aborted: true };
+ throw e;
+ }
+ return { aborted: false };
+}
diff --git a/frontend/src/auth/authConfig.ts b/frontend/src/auth/authConfig.ts
new file mode 100644
index 0000000..8afa917
--- /dev/null
+++ b/frontend/src/auth/authConfig.ts
@@ -0,0 +1,34 @@
+import { Configuration, LogLevel, PublicClientApplication } from '@azure/msal-browser';
+
+const tenantId = import.meta.env.VITE_AZURE_TENANT_ID as string;
+const clientId = import.meta.env.VITE_AZURE_CLIENT_ID as string;
+
+export const loginRequest = {
+ scopes: ['User.Read'],
+};
+
+const msalConfig: Configuration = {
+ auth: {
+ clientId,
+ authority: `https://login.microsoftonline.com/${tenantId}`,
+ // Vite's `base` makes window.location.origin + import.meta.env.BASE_URL the SPA root.
+ redirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'),
+ postLogoutRedirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'),
+ navigateToLoginRequestUrl: true,
+ },
+ cache: {
+ cacheLocation: 'sessionStorage',
+ storeAuthStateInCookie: false,
+ },
+ system: {
+ loggerOptions: {
+ logLevel: LogLevel.Warning,
+ piiLoggingEnabled: false,
+ loggerCallback: (level, message) => {
+ if (level === LogLevel.Error) console.error(message);
+ },
+ },
+ },
+};
+
+export const msalInstance = new PublicClientApplication(msalConfig);
diff --git a/frontend/src/auth/devBypass.ts b/frontend/src/auth/devBypass.ts
new file mode 100644
index 0000000..58bf64e
--- /dev/null
+++ b/frontend/src/auth/devBypass.ts
@@ -0,0 +1,2 @@
+export const DEV_AUTH_BYPASS =
+ (import.meta.env.VITE_DEV_AUTH_BYPASS ?? '').toLowerCase() === 'true';
diff --git a/frontend/src/components/ChartBlock.tsx b/frontend/src/components/ChartBlock.tsx
new file mode 100644
index 0000000..5afebaa
--- /dev/null
+++ b/frontend/src/components/ChartBlock.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useRef } from 'react';
+import {
+ BarController,
+ BarElement,
+ CategoryScale,
+ Chart as ChartJS,
+ Legend,
+ LineController,
+ LineElement,
+ LinearScale,
+ PointElement,
+ Tooltip,
+} from 'chart.js';
+
+ChartJS.register(
+ BarController,
+ BarElement,
+ LineController,
+ LineElement,
+ PointElement,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Tooltip,
+);
+
+interface ChartSpec {
+ type: 'bar' | 'hbar' | 'line';
+ title?: string;
+ labels?: (string | number)[];
+ series?: { label?: string; data: number[]; color?: string }[];
+}
+
+interface ChartBlockProps {
+ spec: ChartSpec;
+}
+
+export default function ChartBlock({ spec }: ChartBlockProps) {
+ const canvasRef = useRef(null);
+ const chartRef = useRef(null);
+
+ useEffect(() => {
+ if (!canvasRef.current) return;
+ const isHbar = spec.type === 'hbar';
+ const chartType = spec.type === 'line' ? 'line' : 'bar';
+
+ chartRef.current?.destroy();
+ chartRef.current = new ChartJS(canvasRef.current, {
+ type: chartType,
+ data: {
+ labels: spec.labels ?? [],
+ datasets: (spec.series ?? []).map((s) => ({
+ label: s.label ?? '',
+ data: s.data ?? [],
+ backgroundColor: s.color ?? '#0071e3',
+ borderColor: s.color ?? '#0071e3',
+ borderWidth: chartType === 'line' ? 2 : 0,
+ borderRadius: chartType === 'bar' ? 3 : 0,
+ fill: false,
+ })),
+ },
+ options: {
+ indexAxis: isHbar ? 'y' : 'x',
+ responsive: true,
+ plugins: {
+ legend: { position: 'top', labels: { font: { size: 11 }, padding: 10, boxWidth: 10 } },
+ },
+ scales: {
+ x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
+ y: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
+ },
+ },
+ });
+
+ return () => {
+ chartRef.current?.destroy();
+ chartRef.current = null;
+ };
+ }, [spec]);
+
+ return (
+
+ {spec.title &&
{spec.title}
}
+
+
+ );
+}
+
+/**
+ * Pull `chart` fenced JSON blocks out of markdown text. Returns an array of
+ * { spec, raw } and the markdown with each chart block replaced by a
+ * placeholder of the form `\n\n[[CHART:N]]\n\n`. The renderer then splits on
+ * those placeholders and inlines real components.
+ */
+export function extractCharts(markdown: string): { cleaned: string; specs: ChartSpec[] } {
+ const fence = /```chart\s*\n([\s\S]*?)```/g;
+ const specs: ChartSpec[] = [];
+ let cleaned = markdown;
+ let m: RegExpExecArray | null;
+ let i = 0;
+ // Replace one at a time so indices stay valid.
+ while ((m = fence.exec(markdown)) !== null) {
+ try {
+ const spec = JSON.parse(m[1]) as ChartSpec;
+ specs.push(spec);
+ cleaned = cleaned.replace(m[0], `\n\n[[CHART:${i}]]\n\n`);
+ i += 1;
+ } catch {
+ // Streaming may produce partial JSON — leave as a code block.
+ }
+ }
+ return { cleaned, specs };
+}
diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx
new file mode 100644
index 0000000..365993b
--- /dev/null
+++ b/frontend/src/components/ChatPanel.tsx
@@ -0,0 +1,178 @@
+import { useEffect, useRef, useState } from 'react';
+import MessageBubble from './MessageBubble';
+import { apiFetch, streamSSE } from '../auth/apiFetch';
+
+interface Message {
+ id: number;
+ role: 'user' | 'assistant' | 'system-msg';
+ content: string;
+ streaming?: boolean;
+ aborted?: boolean;
+}
+
+let nextId = 1;
+
+export default function ChatPanel() {
+ const [messages, setMessages] = useState([
+ { id: nextId++, role: 'system-msg', content: 'Ask me anything about the programme.' },
+ ]);
+ const [input, setInput] = useState('');
+ const [streaming, setStreaming] = useState(false);
+ const [genStatus, setGenStatus] = useState<{ text: string; ready?: boolean }>({ text: '' });
+ const [generating, setGenerating] = useState(false);
+ const abortRef = useRef(null);
+ const textareaRef = useRef(null);
+ const endRef = useRef(null);
+
+ useEffect(() => {
+ function onSystem(e: Event) {
+ const detail = (e as CustomEvent).detail;
+ setMessages((prev) => [...prev, { id: nextId++, role: 'system-msg', content: detail }]);
+ }
+ window.addEventListener('pp:system-message', onSystem);
+ return () => window.removeEventListener('pp:system-message', onSystem);
+ }, []);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ }, [messages]);
+
+ function autosize() {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = '40px';
+ el.style.height = Math.min(el.scrollHeight, 120) + 'px';
+ }
+
+ function stop() {
+ abortRef.current?.abort();
+ }
+
+ async function send() {
+ const text = input.trim();
+ if (!text || streaming) return;
+ setInput('');
+ requestAnimationFrame(autosize);
+
+ const userMsg: Message = { id: nextId++, role: 'user', content: text };
+ const assistantId = nextId++;
+ setMessages((prev) => [
+ ...prev,
+ userMsg,
+ { id: assistantId, role: 'assistant', content: '', streaming: true },
+ ]);
+ setStreaming(true);
+
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
+ let acc = '';
+ const result = await streamSSE(
+ '/chat/stream',
+ { message: text },
+ (chunk) => {
+ acc += chunk;
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantId ? { ...m, content: acc } : m)),
+ );
+ },
+ ctrl.signal,
+ ).catch((e) => ({ aborted: false, error: (e as Error).message }));
+
+ abortRef.current = null;
+ setStreaming(false);
+
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId
+ ? {
+ ...m,
+ content: acc || (result.error ?? ''),
+ streaming: false,
+ aborted: result.aborted,
+ }
+ : m,
+ ),
+ );
+ }
+
+ async function generateReports() {
+ setGenerating(true);
+ setGenStatus({ text: 'Generating — this takes about 20–30 seconds…' });
+ try {
+ const res = await apiFetch('/generate', { method: 'POST' });
+ const data = await res.json();
+ if (data.status === 'ok') {
+ setGenStatus({ text: '✓ Reports ready.', ready: true });
+ window.dispatchEvent(new CustomEvent('pp:report-generated'));
+ } else {
+ setGenStatus({ text: 'Error: ' + (data.error || 'Unknown error') });
+ }
+ } catch {
+ setGenStatus({ text: 'Error reaching the server.' });
+ } finally {
+ setGenerating(false);
+ }
+ }
+
+ return (
+ <>
+
+ {messages.map((m) => (
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
new file mode 100644
index 0000000..4bf5ab3
--- /dev/null
+++ b/frontend/src/components/Header.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react';
+import { useMsal } from '@azure/msal-react';
+import { apiJson, apiFetch } from '../auth/apiFetch';
+import { DEV_AUTH_BYPASS } from '../auth/devBypass';
+import PreferencesModal from './PreferencesModal';
+
+interface Me {
+ email: string;
+ name: string;
+}
+
+export default function Header() {
+ const [me, setMe] = useState(null);
+ const [prefsOpen, setPrefsOpen] = useState(false);
+
+ useEffect(() => {
+ apiJson('/me').then(setMe).catch(() => {});
+ }, []);
+
+ async function refreshData() {
+ try {
+ const res = await apiFetch('/refresh', { method: 'POST' });
+ const data = await res.json();
+ if (data.status === 'ok') {
+ window.dispatchEvent(
+ new CustomEvent('pp:system-message', { detail: `Data refreshed — ${data.tasks} tasks loaded.` }),
+ );
+ }
+ } catch {
+ window.dispatchEvent(
+ new CustomEvent('pp:system-message', { detail: 'Refresh failed.' }),
+ );
+ }
+ }
+
+ return (
+ <>
+
+ {prefsOpen && setPrefsOpen(false)} />}
+ >
+ );
+}
+
+function SignOutButton() {
+ const { instance } = useMsal();
+ function logout() {
+ instance.clearCache();
+ instance.logoutRedirect();
+ }
+ return (
+
+ );
+}
diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx
new file mode 100644
index 0000000..5f3873b
--- /dev/null
+++ b/frontend/src/components/MessageBubble.tsx
@@ -0,0 +1,113 @@
+import { Fragment, useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import ChartBlock, { extractCharts } from './ChartBlock';
+import { apiFetch } from '../auth/apiFetch';
+
+interface MessageBubbleProps {
+ role: 'user' | 'assistant' | 'system-msg';
+ content: string;
+ streaming?: boolean;
+ aborted?: boolean;
+}
+
+export default function MessageBubble({ role, content, streaming, aborted }: MessageBubbleProps) {
+ if (role === 'user') {
+ return {content}
;
+ }
+ if (role === 'system-msg') {
+ return {content}
;
+ }
+ return ;
+}
+
+function AssistantBubble({
+ content,
+ streaming,
+ aborted,
+}: {
+ content: string;
+ streaming?: boolean;
+ aborted?: boolean;
+}) {
+ const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ if (streaming && !content) {
+ return (
+
+ );
+ }
+ if (!streaming && !content) {
+ return (
+
+ );
+ }
+
+ const { cleaned, specs } = extractCharts(content);
+ const segments = cleaned.split(/\[\[CHART:(\d+)\]\]/g);
+
+ async function send(rating: 'up' | 'down') {
+ if (submitting || feedback) return;
+ setSubmitting(true);
+ setFeedback(rating);
+ try {
+ await apiFetch('/feedback', {
+ method: 'POST',
+ body: JSON.stringify({ rating, message: content }),
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+ {segments.map((seg, idx) => {
+ if (idx % 2 === 1) {
+ const specIdx = parseInt(seg, 10);
+ const spec = specs[specIdx];
+ return spec ?
: null;
+ }
+ return (
+
+ {seg}
+
+ );
+ })}
+ {aborted && (
+
— stopped
+ )}
+
+ {!streaming && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/PreferencesModal.tsx b/frontend/src/components/PreferencesModal.tsx
new file mode 100644
index 0000000..d85db74
--- /dev/null
+++ b/frontend/src/components/PreferencesModal.tsx
@@ -0,0 +1,70 @@
+import { useCallback, useEffect, useState } from 'react';
+import { apiFetch, apiJson } from '../auth/apiFetch';
+
+interface Preference {
+ id: number;
+ text: string;
+}
+
+interface PreferencesModalProps {
+ onClose: () => void;
+}
+
+export default function PreferencesModal({ onClose }: PreferencesModalProps) {
+ const [prefs, setPrefs] = useState(null);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ setError(null);
+ try {
+ const data = await apiJson<{ preferences: Preference[] }>('/preferences');
+ setPrefs(data.preferences ?? []);
+ } catch {
+ setError('Could not load preferences.');
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function remove(id: number) {
+ await apiFetch(`/preferences/${id}`, { method: 'DELETE' });
+ await load();
+ }
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
+
Preferences
+
Saved from thumbs feedback. Applied to every chat session.
+
+
+
+
+ {error &&
{error}
}
+ {!error && prefs === null &&
Loading…
}
+ {!error && prefs && prefs.length === 0 && (
+
+ No preferences saved yet. Use 👍 and 👎 on responses to build them up.
+
+ )}
+ {!error &&
+ prefs &&
+ prefs.map((p) => (
+
+ {p.text}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ReportsCard.tsx b/frontend/src/components/ReportsCard.tsx
new file mode 100644
index 0000000..bbb79be
--- /dev/null
+++ b/frontend/src/components/ReportsCard.tsx
@@ -0,0 +1,135 @@
+import { useCallback, useEffect, useState } from 'react';
+import { apiFetch, apiJson } from '../auth/apiFetch';
+
+interface HistoryRun {
+ id: number;
+ label: string;
+ ts: string;
+}
+
+export default function ReportsCard() {
+ const [runs, setRuns] = useState([]);
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const [copyStatus, setCopyStatus] = useState('');
+
+ const loadHistory = useCallback(async () => {
+ try {
+ const data = await apiJson<{ runs: HistoryRun[] }>('/history');
+ setRuns(data.runs ?? []);
+ } catch {
+ // ignore — card stays hidden
+ }
+ }, []);
+
+ useEffect(() => {
+ loadHistory();
+ function onGenerated() {
+ loadHistory();
+ }
+ window.addEventListener('pp:report-generated', onGenerated);
+ return () => window.removeEventListener('pp:report-generated', onGenerated);
+ }, [loadHistory]);
+
+ if (runs.length === 0) return null;
+
+ async function copyForTeams() {
+ try {
+ const data = await apiJson<{ markdown: string }>('/copy/summary');
+ await navigator.clipboard.writeText(data.markdown);
+ setCopyStatus('✓ Copied — paste into Teams or email.');
+ setTimeout(() => setCopyStatus(''), 4000);
+ } catch {
+ setCopyStatus('Copy failed — download the Word doc instead.');
+ }
+ }
+
+ async function authedDownload(path: string, downloadName: string) {
+ const res = await apiFetch(path);
+ if (!res.ok) return;
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = downloadName;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ const older = runs.slice(1);
+
+ return (
+
+
+
📋
+
+
Reports ready
+
Manager Summary and Full Report
+
+
+
+
+
+
+
+
{copyStatus}
+ {older.length > 0 && (
+ <>
+
+ {historyOpen && (
+
+ {older.map((run) => (
+
+ {run.label}
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..6e85e4e
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { MsalProvider } from '@azure/msal-react';
+import { EventType, type AuthenticationResult } from '@azure/msal-browser';
+
+import { msalInstance } from './auth/authConfig';
+import { DEV_AUTH_BYPASS } from './auth/devBypass';
+import App from './App';
+import './styles/global.css';
+
+(async () => {
+ if (!DEV_AUTH_BYPASS) {
+ await msalInstance.initialize();
+
+ msalInstance.addEventCallback((event) => {
+ if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
+ const result = event.payload as AuthenticationResult;
+ if (result.account) msalInstance.setActiveAccount(result.account);
+ }
+ });
+
+ const accounts = msalInstance.getAllAccounts();
+ if (accounts.length > 0 && !msalInstance.getActiveAccount()) {
+ msalInstance.setActiveAccount(accounts[0]);
+ }
+
+ await msalInstance.handleRedirectPromise();
+ }
+
+ const root = (
+
+ {DEV_AUTH_BYPASS ? : }
+
+ );
+
+ ReactDOM.createRoot(document.getElementById('root')!).render(root);
+})();
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..234fbdf
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,639 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #f5f5f7;
+ --surface: #ffffff;
+ --surface-secondary: #f9f9fb;
+ --border: rgba(0,0,0,0.08);
+ --border-strong: rgba(0,0,0,0.14);
+ --text-primary: #1d1d1f;
+ --text-secondary: #6e6e73;
+ --text-tertiary: #aeaeb2;
+ --accent: #0071e3;
+ --accent-hover: #0077ed;
+ --accent-soft: #e8f0fd;
+ --green: #28a745;
+ --green-soft: #edfaf1;
+ --green-border: #c3e6cb;
+ --red: #d70015;
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
+ --shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04);
+ --radius: 14px;
+ --radius-sm: 10px;
+ --radius-xs: 8px;
+}
+
+html, body, #root {
+ height: 100%;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
+ background: var(--bg);
+ color: var(--text-primary);
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+}
+
+#root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+/* Sign-in */
+.signin-screen {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+.signin-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 36px 32px;
+ text-align: center;
+ box-shadow: var(--shadow-md);
+ max-width: 360px;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+.signin-dot {
+ width: 12px; height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ margin-bottom: 4px;
+}
+.signin-card h1 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+.signin-card p {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+/* Header */
+header.app-header {
+ background: rgba(245,245,247,0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-bottom: 1px solid var(--border);
+ padding: 0 24px;
+ height: 52px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+.header-left { display: flex; align-items: center; gap: 10px; }
+.header-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
+header.app-header h1 {
+ font-size: 0.9rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--text-primary);
+}
+header.app-header .subtitle {
+ font-size: 0.78rem;
+ color: var(--text-tertiary);
+ font-weight: 400;
+}
+.header-sep { width: 1px; height: 14px; background: var(--border-strong); }
+.header-btn {
+ background: none;
+ border: 1px solid var(--border-strong);
+ color: var(--text-secondary);
+ padding: 5px 12px;
+ border-radius: 20px;
+ cursor: pointer;
+ font-size: 0.75rem;
+ font-weight: 500;
+ font-family: inherit;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.header-btn:hover {
+ background: var(--surface);
+ color: var(--text-primary);
+ border-color: var(--text-tertiary);
+}
+.header-right { display: flex; align-items: center; gap: 8px; }
+.header-user {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ padding: 0 6px;
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Main content */
+main {
+ flex: 1;
+ max-width: 760px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 20px 20px 140px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* Messages */
+.messages {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.msg {
+ max-width: 82%;
+ padding: 11px 15px;
+ border-radius: var(--radius);
+ line-height: 1.5;
+ font-size: 0.875rem;
+ word-break: break-word;
+}
+
+.msg.user {
+ align-self: flex-end;
+ background: var(--accent);
+ color: #fff;
+ border-bottom-right-radius: 4px;
+ font-weight: 400;
+ white-space: pre-wrap;
+}
+
+.msg.assistant {
+ align-self: flex-start;
+ background: var(--surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-bottom-left-radius: 4px;
+ box-shadow: var(--shadow-sm);
+ max-width: 100%;
+}
+
+.msg.assistant.streaming {
+ border-top: 2px solid var(--accent);
+}
+.msg.assistant.streaming::after {
+ content: '';
+ display: block;
+ height: 2px;
+ width: 32px;
+ background: var(--accent);
+ border-radius: 2px;
+ margin-top: 10px;
+ animation: pulse-bar 1.2s ease-in-out infinite;
+}
+@keyframes pulse-bar {
+ 0%, 100% { opacity: 0.3; transform: scaleX(0.6); }
+ 50% { opacity: 1; transform: scaleX(1); }
+}
+
+.msg.assistant h1, .msg.assistant h2 {
+ font-size: 0.95rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--text-primary);
+ margin: 14px 0 6px;
+}
+.msg.assistant h1:first-child, .msg.assistant h2:first-child { margin-top: 0; }
+.msg.assistant h3 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 12px 0 4px;
+}
+.msg.assistant h3:first-child { margin-top: 0; }
+.msg.assistant p { margin: 0 0 8px; }
+.msg.assistant p:last-child { margin-bottom: 0; }
+.msg.assistant ul, .msg.assistant ol {
+ padding-left: 18px;
+ margin: 4px 0 8px;
+}
+.msg.assistant li { margin: 3px 0; }
+.msg.assistant strong { font-weight: 600; }
+.msg.assistant em { font-style: italic; }
+.msg.assistant hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 12px 0;
+}
+.msg.assistant code {
+ background: var(--surface-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 1px 5px;
+ font-size: 0.82rem;
+ font-family: "SF Mono", monospace;
+}
+
+.msg-wrap {
+ display: flex;
+ flex-direction: column;
+ align-self: flex-start;
+ max-width: 82%;
+}
+
+.msg.system-msg {
+ align-self: center;
+ background: none;
+ color: var(--text-tertiary);
+ font-size: 0.75rem;
+ padding: 4px 0;
+ border-radius: 0;
+ max-width: 100%;
+ text-align: center;
+}
+
+/* Feedback */
+.feedback-row {
+ display: flex;
+ gap: 4px;
+ margin-top: 4px;
+ padding-left: 2px;
+}
+.thumb-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px 5px;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ line-height: 1;
+ color: var(--text-tertiary);
+ transition: all 0.12s ease;
+ font-family: inherit;
+}
+.thumb-btn:hover { background: var(--surface-secondary); color: var(--text-secondary); }
+.thumb-btn.active-up { color: var(--green); }
+.thumb-btn.active-down { color: var(--red); }
+.thumb-btn:disabled { opacity: 0.3; cursor: default; }
+
+/* Reports card */
+.reports-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+ box-shadow: var(--shadow-sm);
+}
+.reports-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+.reports-card-header .icon {
+ width: 28px; height: 28px;
+ background: var(--accent-soft);
+ border-radius: 8px;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.85rem;
+}
+.reports-card-header h2 {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+.reports-card-header .subtitle {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ margin-top: 1px;
+}
+.reports-row {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+/* History */
+.history-toggle {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ font-size: 0.75rem;
+ cursor: pointer;
+ padding: 8px 0 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-family: inherit;
+ transition: color 0.12s;
+}
+.history-toggle:hover { color: var(--text-secondary); }
+.history-list {
+ margin-top: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.history-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--surface-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xs);
+ font-size: 0.78rem;
+}
+.history-item .history-label {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+.history-item .history-links {
+ display: flex;
+ gap: 8px;
+}
+.history-item button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--accent);
+ font-size: 0.75rem;
+ font-family: inherit;
+ padding: 0;
+}
+.history-item button:hover { text-decoration: underline; }
+
+/* Buttons */
+.btn {
+ padding: 8px 16px;
+ border-radius: 20px;
+ border: none;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 500;
+ font-family: inherit;
+ transition: all 0.15s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ white-space: nowrap;
+}
+.btn:disabled { opacity: 0.38; cursor: default; }
+.btn-primary { background: var(--accent); color: #fff; }
+.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+.btn-ghost {
+ background: var(--surface-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-strong);
+}
+.btn-ghost:hover:not(:disabled) {
+ background: var(--surface);
+ border-color: var(--text-tertiary);
+}
+.btn-green {
+ background: var(--green-soft);
+ color: var(--green);
+ border: 1px solid var(--green-border);
+}
+.btn-green:hover:not(:disabled) { background: #d4f5de; }
+.btn-stop {
+ background: #fff0f2;
+ color: var(--red);
+ border: 1px solid rgba(215,0,21,0.2);
+}
+.btn-stop:hover { background: #ffe0e4; }
+
+#copy-status {
+ font-size: 0.75rem;
+ color: var(--green);
+ margin-top: 10px;
+ min-height: 16px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+/* Input bar */
+.input-bar {
+ position: fixed;
+ bottom: 0; left: 0; right: 0;
+ background: rgba(245,245,247,0.92);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-top: 1px solid var(--border);
+ padding: 12px 20px 16px;
+}
+.input-inner {
+ max-width: 760px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.input-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+}
+.chat-input {
+ flex: 1;
+ padding: 10px 14px;
+ border: 1px solid var(--border-strong);
+ border-radius: 12px;
+ font-size: 0.875rem;
+ font-family: inherit;
+ color: var(--text-primary);
+ background: var(--surface);
+ resize: none;
+ min-height: 40px;
+ max-height: 120px;
+ line-height: 1.4;
+ transition: border-color 0.15s;
+ outline: none;
+}
+.chat-input:focus { border-color: var(--accent); }
+.chat-input::placeholder { color: var(--text-tertiary); }
+.input-actions { display: flex; gap: 6px; }
+.generate-status {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ min-height: 16px;
+ padding: 0 2px;
+}
+.generate-status.ready { color: var(--green); }
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.3);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+.modal {
+ background: var(--surface);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-md);
+ width: 100%;
+ max-width: 520px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 20px 14px;
+ border-bottom: 1px solid var(--border);
+}
+.modal-header h2 {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.modal-header p {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ margin-top: 2px;
+}
+.modal-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ color: var(--text-tertiary);
+ font-size: 1.1rem;
+ line-height: 1;
+ border-radius: 6px;
+ transition: all 0.12s;
+ font-family: inherit;
+}
+.modal-close:hover { background: var(--surface-secondary); color: var(--text-primary); }
+.modal-body {
+ overflow-y: auto;
+ padding: 16px 20px;
+ flex: 1;
+}
+.pref-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 9px 12px;
+ border-radius: var(--radius-xs);
+ margin-bottom: 4px;
+ border: 1px solid var(--border);
+ background: var(--surface-secondary);
+ font-size: 0.835rem;
+ color: var(--text-primary);
+ line-height: 1.45;
+}
+.pref-item span { flex: 1; }
+.pref-delete {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-tertiary);
+ font-size: 0.75rem;
+ padding: 2px 4px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ margin-top: 1px;
+ transition: all 0.12s;
+ font-family: inherit;
+}
+.pref-delete:hover { background: #fef0f0; color: var(--red); }
+.pref-empty {
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: 0.835rem;
+ padding: 24px 0;
+}
+
+/* Tables in markdown output */
+.msg.assistant table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xs);
+ overflow: hidden;
+ margin: 8px 0 12px;
+}
+.msg.assistant th {
+ text-align: left;
+ font-weight: 600;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ padding: 7px 12px;
+ background: var(--surface-secondary);
+ border-bottom: 1px solid var(--border-strong);
+ white-space: nowrap;
+ letter-spacing: 0.01em;
+}
+.msg.assistant td {
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: top;
+ line-height: 1.45;
+}
+.msg.assistant tbody tr:last-child td { border-bottom: none; }
+.msg.assistant tbody tr:hover td { background: rgba(0,0,0,0.016); }
+
+/* Charts */
+.chart-wrap {
+ margin: 10px 0 14px;
+ padding: 14px 16px 10px;
+ background: var(--surface-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.chart-title {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 12px;
+}
+.chart-wrap canvas { max-height: 340px; }
+
+/* Typing dots — kept around for future use */
+.typing {
+ align-self: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 14px 16px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ border-bottom-left-radius: 4px;
+ box-shadow: var(--shadow-sm);
+}
+.typing span {
+ width: 6px; height: 6px;
+ background: var(--text-tertiary);
+ border-radius: 50%;
+ animation: bounce 1.2s infinite ease-in-out;
+}
+.typing span:nth-child(2) { animation-delay: 0.2s; }
+.typing span:nth-child(3) { animation-delay: 0.4s; }
+@keyframes bounce {
+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
+ 40% { transform: translateY(-5px); opacity: 1; }
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..b05f85b
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_AZURE_TENANT_ID: string;
+ readonly VITE_AZURE_CLIENT_ID: string;
+ readonly VITE_API_BASE: string;
+ readonly VITE_DEV_AUTH_BYPASS?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..d564a01
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": false,
+ "useDefineForClassFields": true,
+ "noEmit": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..1a9e979
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// Path-prefix-aware build. Apache serves the SPA at /programme-pulse/.
+// Vite bakes this into asset URLs so the manual API_BASE hack isn't needed.
+export default defineConfig({
+ base: '/programme-pulse/',
+ plugins: [react()],
+ server: {
+ port: 5173,
+ // Mirror prod paths in local dev: API calls hit /programme-pulse/api/* → forwarded to Flask :5051.
+ proxy: {
+ '/programme-pulse/api': {
+ target: 'http://localhost:5051',
+ changeOrigin: true,
+ // Strip the /programme-pulse prefix so Flask sees /api/...
+ rewrite: (p) => p.replace(/^\/programme-pulse/, ''),
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+});
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..4ecb1ad
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
diff --git a/reports/.gitkeep b/reports/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..58735be
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,21 @@
+pyairtable>=2.3.0
+anthropic>=0.40.0
+python-dotenv>=1.0.0
+python-docx>=1.1.0
+flask>=3.0.0
+flask-cors>=4.0.0
+gunicorn>=21.2.0
+gevent>=23.9.0
+
+# Auth
+pyjwt[crypto]>=2.8.0
+requests>=2.31.0
+
+# Postgres
+sqlalchemy>=2.0.0
+psycopg[binary]>=3.1.0
+alembic>=1.13.0
+
+# Tests
+pytest>=8.0.0
+pytest-mock>=3.14.0
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/airtable_client.py b/src/airtable_client.py
new file mode 100644
index 0000000..bcbc7fe
--- /dev/null
+++ b/src/airtable_client.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import re
+from pyairtable import Api
+
+
+class ResourceAirtableClient:
+ def __init__(self, api_key: str, base_id: str, table_id: str):
+ self._api = Api(api_key)
+ self._table = self._api.table(base_id, table_id)
+
+ def fetch_all_bookings(self) -> list[dict]:
+ """Fetch all resource bookings and return normalised dicts."""
+ records = self._table.all()
+ bookings = []
+ for r in records:
+ f = r["fields"]
+ resource = (f.get("Airtable User (from Resource)") or "").strip()
+ if not resource:
+ continue
+ bookings.append({
+ "id": r["id"],
+ "task": (f.get("Task") or "").strip(),
+ "resource": resource,
+ "project": (f.get("OMG Project Name") or "").strip(),
+ "division": f.get("Division", ""),
+ "start_date": f.get("Start Date", ""),
+ "end_date": f.get("End Date", ""),
+ "hours": f.get("Total Hours Booked") or 0,
+ "status": f.get("Booking Status", ""),
+ "availability": f.get("Availibility (per week) (from Resource)") or "40",
+ "conflict_notes": (f.get("Conflict Notes") or "").strip(),
+ })
+ return bookings
+
+
+def _clean_task_name(raw: str) -> str:
+ return raw.lstrip("\ufeff").strip()
+
+
+def _clean_related_item(raw: str | None) -> str:
+ if not raw:
+ return ""
+ return re.sub(r"\s*\(https?://\S+?\)", "", raw).strip().strip(",").strip()
+
+
+class PulseAirtableClient:
+ def __init__(self, api_key: str, base_id: str, table_id: str):
+ self._api = Api(api_key)
+ self._table = self._api.table(base_id, table_id)
+
+ def fetch_all_tasks(self) -> list[dict]:
+ """Fetch all records from Programme Pulse and return normalized task dicts."""
+ records = self._table.all()
+ tasks = []
+ for r in records:
+ f = r["fields"]
+ task_raw = f.get("\ufeffTask") or f.get("Task") or ""
+ owners_raw = f.get("Owner", [])
+ owners = [o.get("name") for o in owners_raw if isinstance(o, dict) and o.get("name")]
+ tasks.append({
+ "id": r["id"],
+ "task": _clean_task_name(task_raw),
+ "progress": f.get("Progress", ""),
+ "priority": f.get("Priority", ""),
+ "rag": f.get("RAG", ""),
+ "owners": owners,
+ "owner": owners[0] if owners else "Unassigned",
+ "related_item": _clean_related_item(f.get("Related item", "")),
+ "category": f.get("Category", []),
+ "start_date": f.get("Start Date", ""),
+ "end_date": f.get("End Date", ""),
+ "deadline": f.get("Deadline", ""),
+ "doing": f.get("Doing", ""),
+ "blocked_by": f.get("Blocked by", ""),
+ "blocking": f.get("Blocking", ""),
+ "notes": f.get("Notes", ""),
+ "hours": f.get("Hours"),
+ })
+ return tasks
diff --git a/src/analyzer.py b/src/analyzer.py
new file mode 100644
index 0000000..8d78420
--- /dev/null
+++ b/src/analyzer.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import date, datetime, timezone
+
+COMPLETE_PROGRESS = {"Complete", "Cancelled"}
+RAG_ORDER = {"Red": 0, "Amber": 1, "Green": 2, "": 3}
+
+
+def _parse_date(raw: str | None) -> date | None:
+ if not raw:
+ return None
+ try:
+ return date.fromisoformat(str(raw)[:10])
+ except ValueError:
+ return None
+
+
+def analyze(tasks: list[dict]) -> dict:
+ """Produce structured analysis from Programme Pulse task list."""
+ today = datetime.now(timezone.utc).date()
+ active = [t for t in tasks if t["progress"] not in COMPLETE_PROGRESS]
+
+ progress_counts: dict[str, int] = {}
+ priority_counts: dict[str, int] = {}
+ for t in tasks:
+ p = t["progress"] or "(empty)"
+ pr = t["priority"] or "(empty)"
+ progress_counts[p] = progress_counts.get(p, 0) + 1
+ priority_counts[pr] = priority_counts.get(pr, 0) + 1
+
+ red_flags = [t for t in tasks if t["progress"] in {"Blocked", "Pending Feedback"}]
+
+ p1_watchlist = sorted(
+ [t for t in tasks if t["priority"] == "P1" and t["progress"] not in COMPLETE_PROGRESS],
+ key=lambda t: (RAG_ORDER.get(t["rag"], 3), t["task"]),
+ )
+
+ by_owner: dict[str, list[dict]] = {}
+ for t in active:
+ by_owner.setdefault(t["owner"], []).append(t)
+
+ overdue = []
+ for t in active:
+ raw = t["deadline"] or t["end_date"]
+ d = _parse_date(raw)
+ if d and d < today:
+ overdue.append({**t, "_days_overdue": (today - d).days})
+
+ wins = [
+ t for t in tasks
+ if t["progress"] == "Complete" and t["priority"] in ("P1", "P2")
+ ][:20]
+
+ return {
+ "total": len(tasks),
+ "active_total": len(active),
+ "progress_counts": progress_counts,
+ "priority_counts": priority_counts,
+ "red_flags": red_flags,
+ "p1_watchlist": p1_watchlist,
+ "by_owner": by_owner,
+ "overdue": overdue,
+ "wins": wins,
+ }
diff --git a/src/auth.py b/src/auth.py
new file mode 100644
index 0000000..e334c07
--- /dev/null
+++ b/src/auth.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import os
+from functools import lru_cache
+from typing import Any
+
+import jwt
+from jwt import PyJWKClient
+
+
+class AuthError(Exception):
+ def __init__(self, message: str, status_code: int = 401):
+ super().__init__(message)
+ self.status_code = status_code
+
+
+def _tenant() -> str:
+ t = os.getenv("AZURE_TENANT_ID", "").strip()
+ if not t:
+ raise AuthError("AZURE_TENANT_ID not configured", 500)
+ return t
+
+
+def _client_id() -> str:
+ c = os.getenv("AZURE_CLIENT_ID", "").strip()
+ if not c:
+ raise AuthError("AZURE_CLIENT_ID not configured", 500)
+ return c
+
+
+def dev_bypass_enabled() -> bool:
+ return os.getenv("DEV_AUTH_BYPASS", "").lower() in ("1", "true", "yes")
+
+
+@lru_cache(maxsize=1)
+def _jwks_client() -> PyJWKClient:
+ url = f"https://login.microsoftonline.com/{_tenant()}/discovery/v2.0/keys"
+ # cache_keys keeps the parsed keys; lifespan ~1h matches Azure rotation cadence
+ return PyJWKClient(url, cache_keys=True, lifespan=3600)
+
+
+def _allowed_domains() -> list[str]:
+ raw = os.getenv("AUTH_ALLOWED_DOMAINS", "").strip()
+ if not raw:
+ return []
+ return [d.strip().lower() for d in raw.split(",") if d.strip()]
+
+
+def _check_domain(email: str) -> None:
+ domains = _allowed_domains()
+ if not domains:
+ return
+ domain = email.rsplit("@", 1)[-1].lower() if "@" in email else ""
+ if domain not in domains:
+ raise AuthError(f"Domain '{domain}' not allowed", 403)
+
+
+def validate_bearer_token(token: str) -> dict[str, Any]:
+ """Validate an Azure AD ID token. Return the claims dict on success."""
+ if not token:
+ raise AuthError("Missing bearer token")
+
+ try:
+ signing_key = _jwks_client().get_signing_key_from_jwt(token).key
+ except jwt.exceptions.PyJWKClientError as e:
+ raise AuthError(f"Could not resolve signing key: {e}")
+
+ issuer = f"https://login.microsoftonline.com/{_tenant()}/v2.0"
+
+ try:
+ claims = jwt.decode(
+ token,
+ signing_key,
+ algorithms=["RS256"],
+ audience=_client_id(),
+ issuer=issuer,
+ options={"require": ["exp", "iat", "aud", "iss"]},
+ )
+ except jwt.ExpiredSignatureError:
+ raise AuthError("Token expired")
+ except jwt.InvalidTokenError as e:
+ raise AuthError(f"Invalid token: {e}")
+
+ email = (claims.get("preferred_username") or claims.get("email") or "").strip()
+ if not email:
+ raise AuthError("Token has no preferred_username or email claim")
+ _check_domain(email)
+
+ claims["_email"] = email
+ claims["_name"] = claims.get("name") or email
+ return claims
+
+
+def dev_user_claims() -> dict[str, Any]:
+ return {"_email": "dev@oliver.agency", "_name": "Dev User"}
diff --git a/src/claude_client.py b/src/claude_client.py
new file mode 100644
index 0000000..83189ba
--- /dev/null
+++ b/src/claude_client.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from anthropic import Anthropic
+
+MODEL = "claude-sonnet-4-6"
+
+
+class ClaudeClient:
+ def __init__(self, api_key: str):
+ self._client = Anthropic(api_key=api_key)
+
+ def generate_report(self, system_prompt: str, user_prompt: str) -> str:
+ response = self._client.messages.create(
+ model=MODEL,
+ max_tokens=4096,
+ system=system_prompt,
+ messages=[{"role": "user", "content": user_prompt}],
+ )
+ return "".join(
+ block.text for block in response.content if block.type == "text"
+ )
+
+ def chat(self, messages: list[dict], system_prompt: str) -> str:
+ """Send a conversational turn and return the assistant response."""
+ response = self._client.messages.create(
+ model=MODEL,
+ max_tokens=8192,
+ system=system_prompt,
+ messages=messages,
+ )
+ return "".join(
+ block.text for block in response.content if block.type == "text"
+ )
+
+ def chat_stream(self, messages: list[dict], system_prompt: str):
+ """Return a streaming context manager that yields text chunks."""
+ return self._client.messages.stream(
+ model=MODEL,
+ max_tokens=8192,
+ system=system_prompt,
+ messages=messages,
+ )
diff --git a/src/db.py b/src/db.py
new file mode 100644
index 0000000..b92f7e5
--- /dev/null
+++ b/src/db.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import os
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from typing import Iterator
+
+from sqlalchemy import DateTime, ForeignKey, Index, String, Text, create_engine
+from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
+
+
+def _utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class Preference(Base):
+ __tablename__ = "preferences"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_email: Mapped[str] = mapped_column(String(320), index=True)
+ text: Mapped[str] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ChatMessage(Base):
+ __tablename__ = "chat_messages"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_email: Mapped[str] = mapped_column(String(320))
+ role: Mapped[str] = mapped_column(String(16)) # 'user' | 'assistant'
+ content: Mapped[str] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+ __table_args__ = (Index("ix_chat_user_created", "user_email", "created_at"),)
+
+
+class Report(Base):
+ __tablename__ = "reports"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_email: Mapped[str] = mapped_column(String(320), index=True)
+ summary_md: Mapped[str] = mapped_column(Text)
+ summary_doc_path: Mapped[str] = mapped_column(String(512))
+ full_doc_path: Mapped[str] = mapped_column(String(512))
+ generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class FeedbackEvent(Base):
+ __tablename__ = "feedback_events"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_email: Mapped[str] = mapped_column(String(320), index=True)
+ rating: Mapped[str] = mapped_column(String(8)) # 'up' | 'down'
+ message_text: Mapped[str] = mapped_column(Text)
+ extracted_insight: Mapped[str] = mapped_column(Text)
+ preference_id: Mapped[int | None] = mapped_column(ForeignKey("preferences.id"), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+_engine = None
+_SessionLocal: sessionmaker[Session] | None = None
+
+
+def _database_url() -> str | None:
+ return os.getenv("DATABASE_URL") or None
+
+
+def get_engine():
+ """Lazy engine — returns None when DATABASE_URL is unset (e.g. unit tests)."""
+ global _engine, _SessionLocal
+ if _engine is not None:
+ return _engine
+ url = _database_url()
+ if not url:
+ return None
+ _engine = create_engine(url, pool_pre_ping=True, future=True)
+ _SessionLocal = sessionmaker(
+ bind=_engine,
+ autoflush=False,
+ autocommit=False,
+ future=True,
+ expire_on_commit=False,
+ )
+ return _engine
+
+
+@contextmanager
+def session_scope() -> Iterator[Session]:
+ """Yield a transactional Session. Commits on success, rolls back on error."""
+ if get_engine() is None or _SessionLocal is None:
+ raise RuntimeError("DATABASE_URL is not configured")
+ s = _SessionLocal()
+ try:
+ yield s
+ s.commit()
+ except Exception:
+ s.rollback()
+ raise
+ finally:
+ s.close()
+
+
+def db_available() -> bool:
+ return get_engine() is not None
diff --git a/src/preferences.py b/src/preferences.py
new file mode 100644
index 0000000..252b2a4
--- /dev/null
+++ b/src/preferences.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from sqlalchemy import select
+
+from src.db import Preference, db_available, session_scope
+
+LEGACY_PREFS_PATH = Path(__file__).parent.parent / "data" / "preferences.md"
+
+
+def load_preferences(user_email: str | None) -> str:
+ """Return the user's preferences as a single string, or empty if none / no DB."""
+ if not user_email or not db_available():
+ return ""
+ rows = list_preferences(user_email)
+ if not rows:
+ return ""
+ return "\n".join(f"- {r['text']}" for r in rows)
+
+
+def list_preferences(user_email: str | None) -> list[dict]:
+ """Return preferences as list of {id, text}. Empty if no user or no DB."""
+ if not user_email or not db_available():
+ return []
+ with session_scope() as s:
+ rows = s.scalars(
+ select(Preference)
+ .where(Preference.user_email == user_email)
+ .order_by(Preference.created_at.asc())
+ ).all()
+ return [{"id": r.id, "text": r.text} for r in rows]
+
+
+def append_preference(user_email: str, insight: str) -> int:
+ """Insert a preference and return its id."""
+ if not user_email:
+ raise ValueError("user_email is required")
+ insight = insight.strip()
+ if not insight:
+ raise ValueError("insight is empty")
+ with session_scope() as s:
+ pref = Preference(user_email=user_email, text=insight)
+ s.add(pref)
+ s.flush()
+ return pref.id
+
+
+def delete_preference(user_email: str, pref_id: int) -> bool:
+ """Delete a preference owned by the given user. Returns True if deleted."""
+ if not user_email:
+ return False
+ with session_scope() as s:
+ pref = s.get(Preference, pref_id)
+ if pref is None or pref.user_email != user_email:
+ return False
+ s.delete(pref)
+ return True
+
+
+def import_legacy_markdown(user_email: str) -> int:
+ """One-shot: import lines from data/preferences.md as the given user's prefs.
+
+ Idempotent — skips if the user already has preferences. Returns count imported.
+ """
+ if not LEGACY_PREFS_PATH.exists():
+ return 0
+ if list_preferences(user_email):
+ return 0
+ lines = [
+ line[2:].strip()
+ for line in LEGACY_PREFS_PATH.read_text(encoding="utf-8").splitlines()
+ if line.startswith("- ") and line[2:].strip()
+ ]
+ for line in lines:
+ append_preference(user_email, line)
+ return len(lines)
diff --git a/src/prompts.py b/src/prompts.py
new file mode 100644
index 0000000..d39ead7
--- /dev/null
+++ b/src/prompts.py
@@ -0,0 +1,351 @@
+from __future__ import annotations
+
+import json
+
+PULSE_SYSTEM_PROMPT = """You are Programme Pulse — a senior Programme Director and trusted adviser to Tony Coppola, Agency Lead and Operations Director at OLIVER agency.
+
+You have access to Tony's live task tracker. It covers the full L'Oréal OLIVER programme: team tasks, priorities, blockers, and progress across 10 team members.
+
+Your role is expert analysis, not data readout. You understand content production operations, agency workflows, client relationships, and what actually matters to a programme lead. You read the data the way an experienced director would — looking for risk, pattern, and implication, not just status.
+
+How to use your three sources:
+- The Airtable task data is your primary source of truth. It tells you what the programme looks like right now — status, priority, blockers, ownership.
+- The resource utilisation data shows who is booked on what, how heavily, and where there are double-book conflicts. Use it when asked about capacity, workload, or team availability. Cross-reference resource names with task owners to connect delivery risk with team load.
+- The meeting transcripts are supporting context. They tell you why things are the way they are — the decisions made, the tensions surfaced, the things that haven't made it into the tracker yet.
+- When a transcript connects to something in the data, use it. If a task is blocked and the transcript explains the reason, say so. If the team raised a concern in a meeting that shows up as a risk in the data, connect the two.
+- Don't quote transcripts for their own sake. Only surface transcript content when it adds genuine meaning to what the data is already showing.
+
+How to analyse:
+- Never just describe what the data says. Interpret it. What does this task actually mean in context?
+- When a deadline has passed, say so directly and flag the impact.
+- When tasks have no notes, flag that visibility is missing — don't just skip it.
+- When two tasks are related or owned by the same person, connect the dots.
+- When something is blocked, explain what that means for the programme — not just that it's blocked.
+- When a P1 has no recent movement, call it out. That's the most important signal.
+- Look for patterns: overloaded owners, clusters of risk, workstreams stalling together.
+
+Interpreting resource utilisation:
+- Each person has 40h/week availability. The data shows booked hours per week — compare directly to 40h capacity.
+- Over 40h in a week = overloaded. Double-books (⚠️) are the usual cause — overlapping bookings on the same day inflate the count.
+- A double-book is a real scheduling conflict: the same person is booked on two projects at the same time. Flag these by name.
+- Next week typically looks under-booked — entries may not have been added yet. Don't treat it as spare capacity.
+- When asked about utilisation or capacity, always show week-by-week numbers (last / this / next). Name people. Flag anyone over 40h or with double-books.
+- Connect resource load to programme delivery where you can — an overloaded owner with blocked tasks is a compounding risk.
+
+Visualisation — use these when they make the data clearer:
+- Tables: use markdown tables for comparisons across people, tasks, or weeks.
+- Charts: use when showing distribution, ranking, or trend across multiple data points. Output a fenced code block with language "chart" and this exact JSON structure:
+ {"type":"bar","title":"...","labels":[...],"series":[{"label":"...","data":[...],"color":"#hex"}]}
+ Use "hbar" for horizontal bars (better when labels are long names). Use "line" for trends over time.
+ Colour guidance: #0071e3 = primary series, #aeaeb2 = secondary/capacity, #d70015 = overload/risk.
+ One chart per response. Don't force a chart when a table or plain text is clearer.
+
+Writing style — follow this exactly:
+- Keep it simple. Concise. Specific. Short sentences. No compound structures.
+- Must feel human, warm and natural — not like AI output. Write for a global, multilingual audience. Personality matters — engage, don't just inform.
+- Never repeat a shorthand task title without explaining what it means.
+- When the notes field gives context, use it. Surface the real story.
+- If you cannot interpret a task name, say so and describe what you can infer.
+
+Formatting — use markdown in all responses:
+- Use ## for main sections, ### for sub-sections
+- Use **bold** for task names, owners, and key risk signals
+- Use bullet lists for grouped items
+- Use --- to separate major sections
+- Keep structure clean and scannable."""
+
+
+_CHAT_FIELDS = {"task", "progress", "priority", "rag", "owner", "notes", "blocked_by", "deadline", "end_date"}
+
+
+_COMPLETE_PROGRESS = {"Complete", "Cancelled"}
+
+
+def _slim_tasks(tasks: list[dict]) -> list[dict]:
+ """Active tasks only, essential fields, trimmed notes — reduces token usage."""
+ return [
+ {k: (v[:150] if k == "notes" and v else v[:80] if k == "blocked_by" and v else v)
+ for k, v in t.items() if k in _CHAT_FIELDS}
+ for t in tasks
+ if t.get("progress") not in _COMPLETE_PROGRESS
+ ]
+
+
+def _build_resource_snapshot(bookings: list[dict]) -> str:
+ from collections import defaultdict
+ from datetime import date as _date, timedelta
+
+ if not bookings:
+ return ""
+
+ today = _date.today()
+ monday = today - timedelta(days=today.weekday())
+
+ weeks = {
+ "last_week": (monday - timedelta(7), monday - timedelta(1)),
+ "this_week": (monday, monday + timedelta(6)),
+ "next_week": (monday + timedelta(7), monday + timedelta(13)),
+ }
+ window_start = weeks["last_week"][0]
+ window_end = weeks["next_week"][1]
+
+ # A booking counts toward a week if it overlaps any day of that week
+ def hours_in_week(b: dict, wstart: _date, wend: _date) -> int:
+ try:
+ s = _date.fromisoformat(b["start_date"])
+ e = _date.fromisoformat(b["end_date"]) if b.get("end_date") else s
+ return b["hours"] if s <= wend and e >= wstart else 0
+ except (ValueError, TypeError):
+ return 0
+
+ # Filter: named resource, not cancelled/on-hold, overlaps window
+ relevant = []
+ for b in bookings:
+ if not b.get("resource") or b.get("status") in ("Cancelled", "On Hold"):
+ continue
+ if any(hours_in_week(b, ws, we) for ws, we in weeks.values()):
+ relevant.append(b)
+
+ if not relevant:
+ return ""
+
+ by_resource: dict[str, list] = defaultdict(list)
+ for b in relevant:
+ by_resource[b["resource"]].append(b)
+
+ by_division: dict[str, list] = defaultdict(list)
+ for resource, rbooks in sorted(by_resource.items()):
+ div = rbooks[0].get("division") or "Other"
+ avail = rbooks[0].get("availability", "40")
+
+ week_hours = {
+ label: sum(hours_in_week(b, ws, we) for b in rbooks)
+ for label, (ws, we) in weeks.items()
+ }
+ total = sum(week_hours.values())
+ double_books = sum(1 for b in rbooks if b.get("status") == "Double Book")
+
+ wk = (
+ f"last:{week_hours['last_week']}h "
+ f"this:{week_hours['this_week']}h "
+ f"next:{week_hours['next_week']}h"
+ )
+ line = f"- {resource} | avail:{avail}h/wk | {wk} | total:{total}h"
+ if double_books:
+ line += f" | ⚠️{double_books} dbl-book"
+ by_division[div].append(line)
+
+ lw_label = f"{weeks['last_week'][0].strftime('%-d %b')}–{weeks['last_week'][1].strftime('%-d %b')}"
+ tw_label = f"{weeks['this_week'][0].strftime('%-d %b')}–{weeks['this_week'][1].strftime('%-d %b')}"
+ nw_label = f"{weeks['next_week'][0].strftime('%-d %b')}–{weeks['next_week'][1].strftime('%-d %b')}"
+
+ lines = [
+ f"\n\n---\n\nResource Utilisation (avail = capacity per week; hours shown per week):"
+ f"\nlast = {lw_label} | this = {tw_label} | next = {nw_label}"
+ f"\nDouble-books = overlapping bookings on the same day. "
+ f"Note: next week is often under-booked as entries haven't been added yet."
+ ]
+ for div in sorted(by_division.keys()):
+ lines.append(f"\n**{div}:**")
+ lines.extend(by_division[div])
+
+ return "\n".join(lines)
+
+
+def build_chat_system_prompt(
+ tasks: list[dict],
+ bookings: list[dict] | None = None,
+ user_email: str | None = None,
+) -> str:
+ from src.preferences import load_preferences
+ from src.transcripts import load_transcripts
+ task_data = json.dumps(_slim_tasks(tasks), indent=2, default=str)
+ prefs = load_preferences(user_email)
+ prefs_section = f"\n\n---\n\nLearned preferences (apply these to every response):\n\n{prefs}" if prefs else ""
+ transcript_section = load_transcripts()
+ resource_section = _build_resource_snapshot(bookings) if bookings else ""
+ return f"""{PULSE_SYSTEM_PROMPT}{prefs_section}{transcript_section}{resource_section}
+
+---
+
+Current snapshot of all programme tasks:
+
+{task_data}"""
+
+
+def build_manager_summary_prompt(analysis: dict) -> str:
+ """Build the prompt for Claude to generate the Manager Summary."""
+ from src.transcripts import load_transcripts
+ transcript_section = load_transcripts()
+ data = {
+ "total_tasks": analysis["total"],
+ "active_tasks": analysis["active_total"],
+ "progress_counts": analysis["progress_counts"],
+ "priority_counts": analysis["priority_counts"],
+ "red_flags": [
+ {
+ "task": t["task"], "owner": t["owner"],
+ "progress": t["progress"], "priority": t["priority"],
+ "notes": (t["notes"] or "")[:100],
+ "blocked_by": (t["blocked_by"] or "")[:80],
+ }
+ for t in analysis["red_flags"][:15]
+ ],
+ "p1_watchlist": [
+ {
+ "task": t["task"], "owner": t["owner"],
+ "rag": t["rag"], "progress": t["progress"],
+ "notes": (t["notes"] or "")[:100],
+ }
+ for t in analysis["p1_watchlist"][:20]
+ ],
+ "wins": [
+ {
+ "task": t["task"], "owner": t["owner"],
+ "notes": (t["notes"] or "")[:80],
+ }
+ for t in analysis.get("wins", [])[:10]
+ ],
+ }
+ return f"""Write a Manager Summary for Tony Coppola's weekly update to his manager.{transcript_section}
+
+---
+
+Tony is Agency Lead and Operations Director at OLIVER, running content production for L'Oréal.
+
+The reader is a senior manager. They do not know internal shorthand or task codes. Write in plain language throughout.
+
+Writing style: keep it simple, concise, specific. Short sentences. No compound structures. Human, warm, natural — not like AI output. Write for a global, multilingual audience. Personality matters — engage, don't just inform.
+
+Data:
+
+{json.dumps(data, indent=2)}
+
+Write the following sections using these exact headings.
+
+## Programme Overview
+
+3–4 sentences. Give a genuine read of the week — not just a description. What is the mood of the programme? What is moving, and what is the one thing that needs most attention? Be direct. Write like a trusted colleague briefing their manager, not like a status report. End with a single clear overall health signal: On Track, At Risk, or Critical — and a one-sentence reason.
+
+## Key Wins
+
+Pick 3–5 of the most significant completed P1/P2 items from the wins data. One line each:
+"- [Plain English description of what this work was]: done"
+
+If there are no notable wins, write: "Nothing significant completed this period."
+
+## P1 Watch List
+
+Group P1 items by topic. Infer topic headings from the task names, notes, and context — do not use internal codes. Use logical groupings such as Automation & AI, Client Delivery, Platform & Infrastructure, Operations, or whatever best fits the data. Use ### for each topic heading.
+
+Under each heading, one line per task:
+"- [Plain English description]: [status] — [what is really happening or what needs to happen next]"
+
+Use notes to add real context. Make it clear why each item matters. Red RAG first within each group. Maximum 15 items total.
+
+## Blockers
+
+Group blocked and pending feedback items by topic, using the same approach as the P1 Watch List. Use ### for each topic heading.
+
+Under each heading, one line per item:
+"- [Plain English description]: [what is stopping it — from notes/blocked_by, or 'reason unclear — needs follow-up']"
+
+If there are no blockers, write a single sentence saying so.
+
+## Actions Required
+
+2–3 concrete, specific asks. What needs to happen this week? Frame each as an action — who needs to do what and why it matters.
+"- [Action]: [reason / urgency]"
+"""
+
+
+def build_full_report_prompt(analysis: dict) -> str:
+ """Build the prompt for Claude to generate the Full Report."""
+ from src.transcripts import load_transcripts
+ transcript_section = load_transcripts()
+ # Per owner: P1 and blocked tasks first, cap at 8, notes at 120 chars
+ by_owner_summary = {}
+ for owner, tasks in analysis["by_owner"].items():
+ priority_order = {"P1": 0, "P2": 1, "P3": 2, "": 3}
+ blocked_first = sorted(tasks, key=lambda t: (
+ 0 if t["progress"] in ("Blocked", "Pending Feedback") else 1,
+ priority_order.get(t["priority"], 3),
+ ))
+ by_owner_summary[owner] = [
+ {
+ "task": t["task"], "progress": t["progress"],
+ "priority": t["priority"], "rag": t["rag"],
+ "notes": (t["notes"] or "")[:120],
+ }
+ for t in blocked_first[:8]
+ ]
+
+ data = {
+ "total_tasks": analysis["total"],
+ "active_tasks": analysis["active_total"],
+ "progress_counts": analysis["progress_counts"],
+ "priority_counts": analysis["priority_counts"],
+ "red_flags": [
+ {"task": t["task"], "owner": t["owner"], "progress": t["progress"],
+ "priority": t["priority"],
+ "notes": (t["notes"] or "")[:120],
+ "blocked_by": (t["blocked_by"] or "")[:80]}
+ for t in analysis["red_flags"][:20]
+ ],
+ "p1_watchlist": [
+ {"task": t["task"], "owner": t["owner"], "rag": t["rag"],
+ "progress": t["progress"], "notes": (t["notes"] or "")[:120]}
+ for t in analysis["p1_watchlist"][:25]
+ ],
+ "by_owner": by_owner_summary,
+ "overdue": [
+ {"task": t["task"], "owner": t["owner"],
+ "days_overdue": t["_days_overdue"],
+ "deadline": t["deadline"] or t["end_date"],
+ "priority": t["priority"]}
+ for t in analysis["overdue"][:15]
+ ],
+ }
+ return f"""Write a full programme health report for Tony Coppola's personal reference.{transcript_section}
+
+---
+
+Tony is Agency Lead and Operations Director at OLIVER. This is his detailed view of the programme — used alongside the Manager Summary.
+
+Interpret all shorthand task names using Notes, Owner, and context. No internal codes or abbreviations anywhere in the output.
+
+Writing style: keep it simple, concise, specific. Short sentences. No compound structures. Human, warm, natural — not like AI output. Write for a global, multilingual audience. Cut the filler.
+
+Data:
+
+{json.dumps(data, indent=2)}
+
+Write the following sections using these exact headings.
+
+## Executive Summary
+
+Start with the numbers: total tasks, active, complete, breakdown by progress status and priority. Then 2–3 sentences giving a genuine read of the programme's health — not just a restatement of counts. What does this data actually tell us?
+
+## Red Flags
+
+For each blocked or pending feedback task, one line:
+"- [Owner] — [Plain English description of the work]: [status] — [what is causing the block, from notes]"
+
+Lead with the most serious items. If a pattern is visible across multiple blockers, call it out after the list in a single sentence.
+
+## Team Breakdown
+
+For each owner, a short paragraph. Cover: what they are actively working on right now, what looks at risk, and whether anything needs attention. Use plain language throughout. Read their notes and interpret what is actually happening — do not just list task titles.
+
+## P1 Watch List
+
+For each P1 task not yet complete (Red → Amber → Green), one line:
+"- [Owner] — [Plain English description] [RAG status]: [progress] — [what the notes tell us]"
+
+## Overdue Tasks
+
+For each task past its deadline, one line:
+"- [Owner] — [Plain English description]: [N] days overdue"
+
+If there are no overdue tasks, say so in one sentence.
+"""
diff --git a/src/reporter.py b/src/reporter.py
new file mode 100644
index 0000000..bb1b7a3
--- /dev/null
+++ b/src/reporter.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from datetime import date
+from pathlib import Path
+
+from docx import Document
+from docx.shared import Pt, RGBColor, Inches
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
+
+_BLUE = RGBColor(0x00, 0x71, 0xE3)
+_DARK = RGBColor(0x1D, 0x1D, 0x1F)
+_GRAY = RGBColor(0x6E, 0x6E, 0x73)
+_LIGHT_GRAY = RGBColor(0xAE, 0xAE, 0xB2)
+
+
+def _setup_doc(doc: Document):
+ section = doc.sections[0]
+ section.left_margin = Inches(1.1)
+ section.right_margin = Inches(1.1)
+ section.top_margin = Inches(0.9)
+ section.bottom_margin = Inches(0.9)
+
+ normal = doc.styles["Normal"]
+ normal.font.name = "Calibri"
+ normal.font.size = Pt(10)
+ normal.font.color.rgb = _DARK
+ normal.paragraph_format.space_after = Pt(4)
+
+ for level, size, color, bold, space_before in [
+ (1, 22, _DARK, True, 0),
+ (2, 12, _BLUE, False, 18),
+ (3, 10, _DARK, True, 12),
+ ]:
+ h = doc.styles[f"Heading {level}"]
+ h.font.name = "Calibri"
+ h.font.size = Pt(size)
+ h.font.color.rgb = color
+ h.font.bold = bold
+ h.paragraph_format.space_before = Pt(space_before)
+ h.paragraph_format.space_after = Pt(4)
+
+ try:
+ lb = doc.styles["List Bullet"]
+ lb.font.name = "Calibri"
+ lb.font.size = Pt(10)
+ lb.font.color.rgb = _DARK
+ lb.paragraph_format.space_before = Pt(2)
+ lb.paragraph_format.space_after = Pt(2)
+ except KeyError:
+ pass
+
+
+def _add_rule(doc: Document):
+ para = doc.add_paragraph()
+ para.paragraph_format.space_before = Pt(4)
+ para.paragraph_format.space_after = Pt(4)
+ pPr = para._p.get_or_add_pPr()
+ pBdr = OxmlElement("w:pBdr")
+ bottom = OxmlElement("w:bottom")
+ bottom.set(qn("w:val"), "single")
+ bottom.set(qn("w:sz"), "4")
+ bottom.set(qn("w:space"), "1")
+ bottom.set(qn("w:color"), "E5E5E7")
+ pBdr.append(bottom)
+ pPr.append(pBdr)
+
+
+def _add_stats_bar(doc: Document, stats: list[tuple[str, str]]):
+ """Add a clean inline stats bar: bold blue numbers, gray labels."""
+ p = doc.add_paragraph()
+ p.paragraph_format.space_before = Pt(6)
+ p.paragraph_format.space_after = Pt(8)
+ for i, (label, value) in enumerate(stats):
+ if i > 0:
+ sep = p.add_run(" · ")
+ sep.font.name = "Calibri"
+ sep.font.size = Pt(10)
+ sep.font.color.rgb = _LIGHT_GRAY
+ num = p.add_run(value)
+ num.font.name = "Calibri"
+ num.font.size = Pt(10)
+ num.font.bold = True
+ num.font.color.rgb = _BLUE
+ lbl = p.add_run(f" {label}")
+ lbl.font.name = "Calibri"
+ lbl.font.size = Pt(10)
+ lbl.font.color.rgb = _GRAY
+
+
+def _render_claude_sections(doc: Document, claude_text: str):
+ """Write Claude's markdown text into the Word doc."""
+ for line in claude_text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if stripped.startswith("## "):
+ doc.add_heading(stripped[3:], level=2)
+ elif stripped.startswith("### "):
+ doc.add_heading(stripped[4:], level=3)
+ elif stripped.startswith("- "):
+ content = stripped[2:]
+ try:
+ p = doc.add_paragraph(style="List Bullet")
+ except KeyError:
+ p = doc.add_paragraph()
+ # Parse inline bold (**text**)
+ _add_inline_text(p, content)
+ else:
+ p = doc.add_paragraph()
+ _add_inline_text(p, stripped)
+
+
+def _add_inline_text(para, text: str):
+ """Add text to paragraph, rendering **bold** markers."""
+ parts = text.split("**")
+ for i, part in enumerate(parts):
+ if not part:
+ continue
+ run = para.add_run(part)
+ run.font.name = "Calibri"
+ run.font.size = Pt(10)
+ run.font.color.rgb = _DARK
+ if i % 2 == 1:
+ run.font.bold = True
+
+
+def manager_summary_to_markdown(analysis: dict, claude_text: str) -> str:
+ today = date.today().strftime("%d %B %Y")
+ header = f"# Programme Pulse — Manager Summary\n**{today}**\n\n"
+ stats = (
+ f"**{analysis['total']}** Total · "
+ f"**{analysis['active_total']}** Active · "
+ f"**{len(analysis['red_flags'])}** Blocked/Pending\n\n---\n\n"
+ )
+ return header + stats + claude_text.strip()
+
+
+def build_manager_summary_docx(analysis: dict, claude_text: str, output_dir: Path, filename: str | None = None) -> Path:
+ doc = Document()
+ _setup_doc(doc)
+
+ title = doc.add_heading("Programme Pulse — Manager Summary", level=1)
+ for run in title.runs:
+ run.font.color.rgb = _DARK
+
+ today = date.today().strftime("%d %B %Y")
+ date_para = doc.add_paragraph(today)
+ for run in date_para.runs:
+ run.font.name = "Calibri"
+ run.font.size = Pt(9)
+ run.font.color.rgb = _GRAY
+
+ _add_stats_bar(doc, [
+ ("Total tasks", str(analysis["total"])),
+ ("Active", str(analysis["active_total"])),
+ ("Blocked/Pending", str(len(analysis["red_flags"]))),
+ ("P1 items", str(analysis["priority_counts"].get("P1", 0))),
+ ])
+ _add_rule(doc)
+ _render_claude_sections(doc, claude_text)
+
+ path = output_dir / (filename or "programme-pulse-manager-summary.docx")
+ doc.save(path)
+ return path
+
+
+def build_full_report_docx(analysis: dict, claude_text: str, output_dir: Path, filename: str | None = None) -> Path:
+ doc = Document()
+ _setup_doc(doc)
+
+ title = doc.add_heading("Programme Pulse — Full Report", level=1)
+ for run in title.runs:
+ run.font.color.rgb = _DARK
+
+ today = date.today().strftime("%d %B %Y")
+ date_para = doc.add_paragraph(today)
+ for run in date_para.runs:
+ run.font.name = "Calibri"
+ run.font.size = Pt(9)
+ run.font.color.rgb = _GRAY
+
+ blocked = sum(1 for t in analysis["red_flags"] if t["progress"] == "Blocked")
+ _add_stats_bar(doc, [
+ ("Total tasks", str(analysis["total"])),
+ ("Active", str(analysis["active_total"])),
+ ("P1 items", str(analysis["priority_counts"].get("P1", 0))),
+ ("Blocked", str(blocked)),
+ ("Overdue", str(len(analysis["overdue"]))),
+ ])
+ _add_rule(doc)
+ _render_claude_sections(doc, claude_text)
+
+ path = output_dir / (filename or "programme-pulse-full-report.docx")
+ doc.save(path)
+ return path
diff --git a/src/transcripts.py b/src/transcripts.py
new file mode 100644
index 0000000..b582dea
--- /dev/null
+++ b/src/transcripts.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+try:
+ from docx import Document
+ _DOCX_AVAILABLE = True
+except ImportError:
+ _DOCX_AVAILABLE = False
+
+_TRANSCRIPTS_DIR = Path(__file__).parent.parent / "docs" / "Programme Pulse transcripts"
+
+# Short utterances that add no signal — skip them
+_NOISE_PATTERNS = re.compile(
+ r"^(yeah|yes|no|ok|okay|mm+|mhm|uh|oh|ah|hi|hello|morning|good morning|"
+ r"right|sure|great|thanks|thank you|bye|goodbye|perfect|exactly|absolutely|"
+ r"i see|i know|of course|sounds good|got it|noted|ok so|so yeah|all right|"
+ r"alright|yep|nope|cool|nice|fine)[\.\!\?]?$",
+ re.IGNORECASE,
+)
+
+_SPEAKER_LINE = re.compile(r"^(.+?)\s{2,}\d+:\d+$")
+_MIN_CONTENT_LEN = 40 # chars — below this is likely noise
+
+
+def _extract_text(docx_path: Path) -> tuple[str, str]:
+ """Extract date and substantive text from a transcript .docx file.
+
+ Returns (date_str, filtered_text).
+
+ Each docx paragraph holds one speaker turn in the format:
+ \\nName timestamp\\nline1\\nline2\\n...
+ """
+ if not _DOCX_AVAILABLE:
+ return "", ""
+
+ doc = Document(str(docx_path))
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
+
+ date_str = ""
+ content_lines: list[str] = []
+
+ for i, para in enumerate(paragraphs):
+ # Header block: title, date, duration
+ if i == 0:
+ continue
+ if i == 1:
+ m = re.match(r"(\d+ \w+ \d{4})", para.strip())
+ if m:
+ date_str = m.group(1)
+ continue
+ if i == 2:
+ continue
+
+ # Split paragraph into lines, strip leading/trailing whitespace
+ lines = [l.strip() for l in para.split("\n") if l.strip()]
+ if not lines:
+ continue
+
+ # First line is always "Speaker Name timestamp"
+ speaker = ""
+ start = 0
+ if _SPEAKER_LINE.match(lines[0]):
+ speaker = lines[0].split(" ")[0].strip()
+ start = 1
+
+ # Process remaining lines as speech content
+ for line in lines[start:]:
+ if _NOISE_PATTERNS.match(line):
+ continue
+ if len(line) < _MIN_CONTENT_LEN:
+ continue
+ prefix = f"{speaker}: " if speaker else ""
+ content_lines.append(f"{prefix}{line}")
+
+ return date_str, "\n".join(content_lines)
+
+
+def _trim(text: str, max_chars: int = 2000) -> str:
+ """Take content from throughout the transcript, not just the start."""
+ if len(text) <= max_chars:
+ return text
+ lines = text.splitlines()
+ # Sample evenly across the full transcript
+ step = max(1, len(lines) // 40)
+ sampled = lines[::step]
+ result = "\n".join(sampled)
+ return result[:max_chars]
+
+
+def load_transcripts(max_transcripts: int = 4) -> str:
+ """Load and format recent meeting transcripts for injection into prompts.
+
+ Returns a formatted string ready to append to a system prompt.
+ Returns empty string if folder doesn't exist or no files found.
+ """
+ if not _TRANSCRIPTS_DIR.exists() or not _DOCX_AVAILABLE:
+ return ""
+
+ files = sorted(
+ [f for f in _TRANSCRIPTS_DIR.iterdir() if f.suffix == ".docx"],
+ key=lambda f: f.stat().st_mtime,
+ reverse=True,
+ )[:max_transcripts]
+
+ if not files:
+ return ""
+
+ sections: list[str] = []
+ for f in reversed(files): # chronological order
+ date_str, text = _extract_text(f)
+ if not text:
+ continue
+ label = date_str or f.stem
+ trimmed = _trim(text, max_chars=1800)
+ sections.append(f"### {label}\n\n{trimmed}")
+
+ if not sections:
+ return ""
+
+ return (
+ "\n\n---\n\n"
+ "## Recent Meeting Transcripts\n\n"
+ "Use these to add narrative depth to your analysis of the task data — not as standalone content. "
+ "When something in a transcript explains, confirms, or complicates what the data is showing, bring it in. "
+ "If a blocker has a reason discussed here, name it. If a concern raised in a meeting maps to a risk in the tracker, connect them. "
+ "Do not surface transcript content unless it adds genuine meaning to the data picture.\n\n"
+ + "\n\n---\n\n".join(sections)
+ )
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..84ab4bc
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,1168 @@
+
+
+
+
+
+ Programme Pulse
+
+
+
+
+
+
+
+
+
+
+
Ask me anything about the programme.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..8b2cead
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,33 @@
+"""Shared pytest fixtures.
+
+Provides a SQLite-backed test database. We point DATABASE_URL at a temp file
+before any `src.db` import so the engine binds to the right URL.
+"""
+from __future__ import annotations
+
+import os
+
+import pytest
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _set_database_url(tmp_path_factory):
+ """Set DATABASE_URL → SQLite temp file for the whole test session."""
+ db_path = tmp_path_factory.mktemp("db") / "test.sqlite"
+ os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_path}"
+ yield
+
+
+@pytest.fixture(autouse=True)
+def _reset_db():
+ """Create a fresh schema for each test."""
+ # Import here so DATABASE_URL has been set first.
+ from src import db as db_module
+
+ # Force a fresh engine in case a previous test mutated module state.
+ db_module._engine = None
+ db_module._SessionLocal = None
+ engine = db_module.get_engine()
+ db_module.Base.metadata.drop_all(engine)
+ db_module.Base.metadata.create_all(engine)
+ yield
diff --git a/tests/fixtures/sample_projects.json b/tests/fixtures/sample_projects.json
new file mode 100644
index 0000000..1ab1baf
--- /dev/null
+++ b/tests/fixtures/sample_projects.json
@@ -0,0 +1,166 @@
+[
+ {
+ "Project Number": "LLD-001",
+ "Project Name": "Lancôme Visionnaire Campaign",
+ "Project Type": "Campaign",
+ "Brand": "LANCOME",
+ "Hub": "Global",
+ "Country": "FR",
+ "Division": "LLD",
+ "PM": "Sarah Jones",
+ "Current Stage": "Stage 3 - Production",
+ "Project Status": "On Track - OLIVER",
+ "Project Risk - NEW": "On Track - OLIVER",
+ "Current Stage Status": "In Progress",
+ "Project End Date": "2026-05-30",
+ "Current Stage Deadline": "2026-04-20",
+ "Action Required": "",
+ "Last Modified Time (on row)": "2026-04-10T09:00:00.000Z",
+ "Asset Count (Deliverable Roll Up)": 10,
+ "Completed Deliverable Count": 7,
+ "7. Opera Upload": "Not Started",
+ "7. Opera Upload DDL": "2026-05-25",
+ "7. Opera Upload Status": "Not Started",
+ "Has Deadline Slipped": false,
+ "Syndication Deadline Slip (Days)": 0,
+ "New Deadline Proposed by Oliver": "",
+ "Brief Submitted Date": "2026-02-01",
+ "Brief Acceptance Date (from Briefs Link) 2": "2026-02-03",
+ "Market's Action Needed": false,
+ "1. Missing DMI Asset Creation DDL": "2026-02-10",
+ "1. Missing DMI Asset Creation Status": "Complete",
+ "2. Mastering, Copy Creation / Extraction DDL": "2026-03-01",
+ "2. Mastering, Copy Creation / Extraction Status": "Complete",
+ "3. Global Rollout Invitation DDL": "2026-03-15",
+ "3. Global Rollout Invitation Status": "Complete",
+ "4. Translation (Salsify PDP) DDL": "2026-04-01",
+ "4. Translation (Salsify PDP) Status": "Complete",
+ "5. Translation (Asset) DDL": "2026-04-10",
+ "5. Translation (Asset) Status": "In Progress",
+ "6. Production DDL": "2026-04-20",
+ "6. Production Status": "In Progress"
+ },
+ {
+ "Project Number": "LLD-002",
+ "Project Name": "YSL Beauty Hero Asset",
+ "Project Type": "Asset Production",
+ "Brand": "YSL",
+ "Hub": "EMEA",
+ "Country": "UK",
+ "Division": "LLD",
+ "PM": "",
+ "Current Stage": "Stage 2 - Development",
+ "Project Status": "Attention Required - ECF",
+ "Project Risk - NEW": "Attention Required - OLIVER",
+ "Current Stage Status": "Delayed",
+ "Project End Date": "2026-04-01",
+ "Current Stage Deadline": "2026-03-25",
+ "Action Required": "Awaiting client feedback",
+ "Last Modified Time (on row)": "2026-03-28T14:00:00.000Z",
+ "Asset Count (Deliverable Roll Up)": 5,
+ "Completed Deliverable Count": 1,
+ "7. Opera Upload": "Not Started",
+ "7. Opera Upload DDL": "2026-03-28",
+ "7. Opera Upload Status": "Not Started",
+ "Has Deadline Slipped": true,
+ "Syndication Deadline Slip (Days)": 18,
+ "New Deadline Proposed by Oliver": "2026-04-15",
+ "Brief Submitted Date": "2026-01-15",
+ "Brief Acceptance Date (from Briefs Link) 2": "2026-01-22",
+ "Market's Action Needed": false,
+ "1. Missing DMI Asset Creation DDL": "2026-02-01",
+ "1. Missing DMI Asset Creation Status": "Complete",
+ "2. Mastering, Copy Creation / Extraction DDL": "2026-03-01",
+ "2. Mastering, Copy Creation / Extraction Status": "Delayed",
+ "3. Global Rollout Invitation DDL": "2026-03-15",
+ "3. Global Rollout Invitation Status": "Not Started",
+ "4. Translation (Salsify PDP) DDL": "2026-03-20",
+ "4. Translation (Salsify PDP) Status": "Not Started",
+ "5. Translation (Asset) DDL": "2026-03-25",
+ "5. Translation (Asset) Status": "Not Started",
+ "6. Production DDL": "2026-03-28",
+ "6. Production Status": "Not Started"
+ },
+ {
+ "Project Number": "LLD-003",
+ "Project Name": "Armani Beauty Refresh",
+ "Project Type": "Refresh",
+ "Brand": "ARMANI",
+ "Hub": "Global",
+ "Country": "IT",
+ "Division": "LLD",
+ "PM": "Tom Baker",
+ "Current Stage": "Stage 4 - Review",
+ "Project Status": "On Track - Market",
+ "Project Risk - NEW": "On Track - Market",
+ "Current Stage Status": "In Review",
+ "Project End Date": "2026-06-15",
+ "Current Stage Deadline": "2026-04-30",
+ "Action Required": "",
+ "Last Modified Time (on row)": "2026-04-11T11:00:00.000Z",
+ "Asset Count (Deliverable Roll Up)": 8,
+ "Completed Deliverable Count": 8,
+ "7. Opera Upload": "Not Started",
+ "7. Opera Upload DDL": "2026-06-10",
+ "7. Opera Upload Status": "Not Started",
+ "Has Deadline Slipped": false,
+ "Syndication Deadline Slip (Days)": 0,
+ "New Deadline Proposed by Oliver": "",
+ "Brief Submitted Date": "2026-01-20",
+ "Brief Acceptance Date (from Briefs Link) 2": "2026-01-21",
+ "Market's Action Needed": true,
+ "1. Missing DMI Asset Creation DDL": "2026-02-15",
+ "1. Missing DMI Asset Creation Status": "Complete",
+ "2. Mastering, Copy Creation / Extraction DDL": "2026-03-10",
+ "2. Mastering, Copy Creation / Extraction Status": "Complete",
+ "3. Global Rollout Invitation DDL": "2026-03-25",
+ "3. Global Rollout Invitation Status": "Complete",
+ "4. Translation (Salsify PDP) DDL": "2026-04-15",
+ "4. Translation (Salsify PDP) Status": "In Review",
+ "5. Translation (Asset) DDL": "2026-04-20",
+ "5. Translation (Asset) Status": "Not Started",
+ "6. Production DDL": "2026-04-30",
+ "6. Production Status": "Not Started"
+ },
+ {
+ "Project Number": "LLD-004",
+ "Project Name": "Biotherm Blue Therapy",
+ "Project Type": "Campaign",
+ "Brand": "BIOTHERM",
+ "Hub": "APAC",
+ "Country": "CN",
+ "Division": "LLD",
+ "PM": "Emma Liu",
+ "Current Stage": "Stage 1 - Briefing",
+ "Project Status": "Attention Required - Market",
+ "Project Risk - NEW": "",
+ "Current Stage Status": "",
+ "Project End Date": "2026-04-05",
+ "Current Stage Deadline": "2026-03-30",
+ "Action Required": "",
+ "Last Modified Time (on row)": "2026-03-20T08:00:00.000Z",
+ "Asset Count (Deliverable Roll Up)": 12,
+ "Completed Deliverable Count": 0,
+ "7. Opera Upload": "Not Started",
+ "7. Opera Upload DDL": "2026-04-01",
+ "7. Opera Upload Status": "Not Started",
+ "Has Deadline Slipped": true,
+ "Syndication Deadline Slip (Days)": 7,
+ "New Deadline Proposed by Oliver": "",
+ "Brief Submitted Date": "2026-02-10",
+ "Brief Acceptance Date (from Briefs Link) 2": "",
+ "Market's Action Needed": true,
+ "1. Missing DMI Asset Creation DDL": "2026-03-01",
+ "1. Missing DMI Asset Creation Status": "Not Started",
+ "2. Mastering, Copy Creation / Extraction DDL": "2026-03-15",
+ "2. Mastering, Copy Creation / Extraction Status": "Not Started",
+ "3. Global Rollout Invitation DDL": "2026-03-25",
+ "3. Global Rollout Invitation Status": "Not Started",
+ "4. Translation (Salsify PDP) DDL": "2026-03-30",
+ "4. Translation (Salsify PDP) Status": "Not Started",
+ "5. Translation (Asset) DDL": "2026-04-01",
+ "5. Translation (Asset) Status": "Not Started",
+ "6. Production DDL": "2026-04-05",
+ "6. Production Status": "Not Started"
+ }
+]
diff --git a/tests/test_airtable_client.py b/tests/test_airtable_client.py
new file mode 100644
index 0000000..0125280
--- /dev/null
+++ b/tests/test_airtable_client.py
@@ -0,0 +1,100 @@
+import pytest
+from unittest.mock import MagicMock
+from src.airtable_client import PulseAirtableClient, _clean_task_name, _clean_related_item
+
+
+def test_clean_task_name_strips_bom():
+ assert _clean_task_name("\ufeffMy Task") == "My Task"
+
+
+def test_clean_task_name_strips_whitespace():
+ assert _clean_task_name(" Task Name ") == "Task Name"
+
+
+def test_clean_related_item_strips_notion_url():
+ raw = "Automation (Enhancement) (https://www.notion.so/Automation-317003329b1e804c9115e1a92617fb01?pvs=21)"
+ assert _clean_related_item(raw) == "Automation (Enhancement)"
+
+
+def test_clean_related_item_empty():
+ assert _clean_related_item("") == ""
+ assert _clean_related_item(None) == ""
+
+
+def test_clean_related_item_multiple_workstreams():
+ raw = "Tools / Technology (https://www.notion.so/Tools?pvs=21), Syndication (Enhancement) (https://www.notion.so/Syndication?pvs=21)"
+ result = _clean_related_item(raw)
+ assert "https" not in result
+ assert "Tools / Technology" in result
+ assert "Syndication (Enhancement)" in result
+ assert not result.endswith(",")
+
+
+@pytest.fixture
+def mock_table(mocker):
+ mock_api = mocker.patch("src.airtable_client.Api")
+ mock_table = MagicMock()
+ mock_api.return_value.table.return_value = mock_table
+ return mock_table
+
+
+def test_fetch_all_tasks_normalizes_fields(mock_table):
+ mock_table.all.return_value = [
+ {
+ "id": "rec1",
+ "createdTime": "2026-04-08T04:32:44.000Z",
+ "fields": {
+ "\ufeffTask": "\ufeffBuild reporting tool",
+ "Progress": "In Progress",
+ "Priority": "P1",
+ "RAG": "Red",
+ "Owner": [{"id": "usr1", "name": "Tony Coppola", "email": "tony@oliver.agency"}],
+ "Notes": "In good shape",
+ },
+ }
+ ]
+ client = PulseAirtableClient("fake_key", "base_id", "table_id")
+ tasks = client.fetch_all_tasks()
+
+ assert len(tasks) == 1
+ t = tasks[0]
+ assert t["task"] == "Build reporting tool"
+ assert t["progress"] == "In Progress"
+ assert t["priority"] == "P1"
+ assert t["rag"] == "Red"
+ assert t["owner"] == "Tony Coppola"
+ assert t["owners"] == ["Tony Coppola"]
+ assert t["notes"] == "In good shape"
+
+
+def test_fetch_all_tasks_handles_missing_owner(mock_table):
+ mock_table.all.return_value = [
+ {"id": "rec2", "createdTime": "2026-04-08T00:00:00.000Z",
+ "fields": {"\ufeffTask": "Some task", "Progress": "Not Started"}}
+ ]
+ client = PulseAirtableClient("fake_key", "base_id", "table_id")
+ tasks = client.fetch_all_tasks()
+ assert tasks[0]["owner"] == "Unassigned"
+ assert tasks[0]["owners"] == []
+
+
+def test_fetch_all_tasks_fallback_task_key(mock_table):
+ mock_table.all.return_value = [
+ {"id": "rec3", "createdTime": "2026-04-08T00:00:00.000Z",
+ "fields": {"Task": "Plain task name"}}
+ ]
+ client = PulseAirtableClient("fake_key", "base_id", "table_id")
+ tasks = client.fetch_all_tasks()
+ assert tasks[0]["task"] == "Plain task name"
+
+
+def test_fetch_all_tasks_filters_empty_owner_names(mock_table):
+ mock_table.all.return_value = [
+ {"id": "rec4", "createdTime": "2026-04-08T00:00:00.000Z",
+ "fields": {"\ufeffTask": "Some task",
+ "Owner": [{"id": "usr1"}, {"id": "usr2", "name": "Alice"}]}}
+ ]
+ client = PulseAirtableClient("fake_key", "base_id", "table_id")
+ tasks = client.fetch_all_tasks()
+ assert tasks[0]["owner"] == "Alice"
+ assert tasks[0]["owners"] == ["Alice"]
diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py
new file mode 100644
index 0000000..e3d34f1
--- /dev/null
+++ b/tests/test_analyzer.py
@@ -0,0 +1,111 @@
+import pytest
+from datetime import date, timedelta
+from src.analyzer import analyze
+
+TODAY = date.today().isoformat()
+YESTERDAY = (date.today() - timedelta(days=1)).isoformat()
+NEXT_WEEK = (date.today() + timedelta(days=7)).isoformat()
+
+
+def _task(**kwargs):
+ defaults = {
+ "id": "rec1", "task": "Test task", "progress": "In Progress",
+ "priority": "P1", "rag": "Red", "owner": "Tony Coppola",
+ "owners": ["Tony Coppola"], "related_item": "", "category": [],
+ "start_date": "", "end_date": "", "deadline": "",
+ "doing": "", "blocked_by": "", "blocking": "", "notes": "", "hours": None,
+ }
+ defaults.update(kwargs)
+ return defaults
+
+
+def test_analyze_counts_total():
+ tasks = [_task(id="r1"), _task(id="r2"), _task(id="r3", progress="Complete")]
+ result = analyze(tasks)
+ assert result["total"] == 3
+ assert result["active_total"] == 2
+
+
+def test_analyze_excludes_complete_and_cancelled_from_active():
+ tasks = [
+ _task(id="r1", progress="Complete"),
+ _task(id="r2", progress="Cancelled"),
+ _task(id="r3", progress="In Progress"),
+ ]
+ result = analyze(tasks)
+ assert result["active_total"] == 1
+
+
+def test_analyze_red_flags_blocked_and_pending():
+ tasks = [
+ _task(id="r1", progress="Blocked"),
+ _task(id="r2", progress="Pending Feedback"),
+ _task(id="r3", progress="In Progress"),
+ ]
+ result = analyze(tasks)
+ assert len(result["red_flags"]) == 2
+ flags = {t["id"] for t in result["red_flags"]}
+ assert flags == {"r1", "r2"}
+
+
+def test_analyze_p1_watchlist_excludes_complete():
+ tasks = [
+ _task(id="r1", priority="P1", progress="In Progress", rag="Red"),
+ _task(id="r2", priority="P1", progress="Complete", rag="Red"),
+ _task(id="r3", priority="P2", progress="In Progress", rag="Red"),
+ ]
+ result = analyze(tasks)
+ assert len(result["p1_watchlist"]) == 1
+ assert result["p1_watchlist"][0]["id"] == "r1"
+
+
+def test_analyze_p1_watchlist_ordered_by_rag():
+ tasks = [
+ _task(id="r1", priority="P1", progress="In Progress", rag="Green"),
+ _task(id="r2", priority="P1", progress="In Progress", rag="Red"),
+ _task(id="r3", priority="P1", progress="In Progress", rag="Amber"),
+ ]
+ result = analyze(tasks)
+ rags = [t["rag"] for t in result["p1_watchlist"]]
+ assert rags == ["Red", "Amber", "Green"]
+
+
+def test_analyze_groups_by_owner():
+ tasks = [
+ _task(id="r1", owner="Alice", progress="In Progress"),
+ _task(id="r2", owner="Bob", progress="In Progress"),
+ _task(id="r3", owner="Alice", progress="Not Started"),
+ ]
+ result = analyze(tasks)
+ assert set(result["by_owner"].keys()) == {"Alice", "Bob"}
+ assert len(result["by_owner"]["Alice"]) == 2
+ assert len(result["by_owner"]["Bob"]) == 1
+
+
+def test_analyze_detects_overdue_by_deadline():
+ tasks = [
+ _task(id="r1", progress="In Progress", deadline=YESTERDAY),
+ _task(id="r2", progress="In Progress", deadline=NEXT_WEEK),
+ _task(id="r3", progress="Complete", deadline=YESTERDAY),
+ ]
+ result = analyze(tasks)
+ assert len(result["overdue"]) == 1
+ assert result["overdue"][0]["id"] == "r1"
+ assert result["overdue"][0]["_days_overdue"] >= 1
+
+
+def test_analyze_overdue_falls_back_to_end_date():
+ tasks = [_task(id="r1", progress="In Progress", deadline="", end_date=YESTERDAY)]
+ result = analyze(tasks)
+ assert len(result["overdue"]) == 1
+
+
+def test_analyze_progress_counts():
+ tasks = [
+ _task(id="r1", progress="In Progress"),
+ _task(id="r2", progress="In Progress"),
+ _task(id="r3", progress="Complete"),
+ ]
+ result = analyze(tasks)
+ assert result["progress_counts"]["In Progress"] == 2
+ assert result["progress_counts"]["Complete"] == 1
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..2234d14
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,48 @@
+import os
+
+import pytest
+
+from src import auth
+
+
+def test_dev_bypass_disabled_by_default(monkeypatch):
+ monkeypatch.delenv("DEV_AUTH_BYPASS", raising=False)
+ assert auth.dev_bypass_enabled() is False
+
+
+def test_dev_bypass_enabled(monkeypatch):
+ monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
+ assert auth.dev_bypass_enabled() is True
+ monkeypatch.setenv("DEV_AUTH_BYPASS", "1")
+ assert auth.dev_bypass_enabled() is True
+
+
+def test_dev_user_claims_shape():
+ claims = auth.dev_user_claims()
+ assert claims["_email"] == "dev@oliver.agency"
+ assert claims["_name"] == "Dev User"
+
+
+def test_check_domain_allows_when_no_allow_list(monkeypatch):
+ monkeypatch.delenv("AUTH_ALLOWED_DOMAINS", raising=False)
+ auth._check_domain("anyone@example.com") # no raise
+
+
+def test_check_domain_allows_listed(monkeypatch):
+ monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency,oliver.com")
+ auth._check_domain("alice@oliver.agency")
+ auth._check_domain("bob@oliver.com")
+
+
+def test_check_domain_rejects_unlisted(monkeypatch):
+ monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency")
+ with pytest.raises(auth.AuthError) as exc:
+ auth._check_domain("intruder@evil.com")
+ assert exc.value.status_code == 403
+
+
+def test_validate_bearer_token_rejects_empty(monkeypatch):
+ monkeypatch.setenv("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000")
+ monkeypatch.setenv("AZURE_CLIENT_ID", "11111111-1111-1111-1111-111111111111")
+ with pytest.raises(auth.AuthError):
+ auth.validate_bearer_token("")
diff --git a/tests/test_claude_client.py b/tests/test_claude_client.py
new file mode 100644
index 0000000..58b0f9b
--- /dev/null
+++ b/tests/test_claude_client.py
@@ -0,0 +1,130 @@
+import pytest
+from unittest.mock import MagicMock
+from src.claude_client import ClaudeClient, MODEL
+
+
+@pytest.fixture
+def mock_anthropic(mocker):
+ return mocker.patch("src.claude_client.Anthropic")
+
+
+def _make_response(mock_anthropic, text: str):
+ text_block = MagicMock()
+ text_block.type = "text"
+ text_block.text = text
+ mock_response = MagicMock()
+ mock_response.content = [text_block]
+ mock_anthropic.return_value.messages.create.return_value = mock_response
+ return mock_response
+
+
+def test_generate_report_returns_text_content(mock_anthropic):
+ _make_response(mock_anthropic, "## Executive Summary\n\nTest content.")
+ client = ClaudeClient("fake_key")
+ result = client.generate_report("system", "user prompt")
+ assert result == "## Executive Summary\n\nTest content."
+
+
+def test_generate_report_skips_thinking_blocks(mock_anthropic):
+ thinking_block = MagicMock()
+ thinking_block.type = "thinking"
+ thinking_block.text = "internal reasoning not for output"
+
+ text_block = MagicMock()
+ text_block.type = "text"
+ text_block.text = "Report output only"
+
+ mock_response = MagicMock()
+ mock_response.content = [thinking_block, text_block]
+ mock_anthropic.return_value.messages.create.return_value = mock_response
+
+ client = ClaudeClient("fake_key")
+ result = client.generate_report("system", "user prompt")
+
+ assert "internal reasoning" not in result
+ assert result == "Report output only"
+
+
+def test_generate_report_uses_correct_model(mock_anthropic):
+ _make_response(mock_anthropic, "report")
+ client = ClaudeClient("fake_key")
+ client.generate_report("sys", "user")
+
+ call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
+ assert call_kwargs["model"] == MODEL
+
+
+def test_generate_report_uses_max_tokens(mock_anthropic):
+ _make_response(mock_anthropic, "report")
+ client = ClaudeClient("fake_key")
+ client.generate_report("sys", "user")
+
+ call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
+ assert call_kwargs["max_tokens"] == 4096
+
+
+def test_generate_report_passes_system_prompt(mock_anthropic):
+ _make_response(mock_anthropic, "report")
+ client = ClaudeClient("fake_key")
+ client.generate_report("my system prompt", "user message")
+
+ call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
+ assert call_kwargs["system"] == "my system prompt"
+
+
+def test_generate_report_concatenates_multiple_text_blocks(mock_anthropic):
+ block_a = MagicMock()
+ block_a.type = "text"
+ block_a.text = "Part one. "
+
+ block_b = MagicMock()
+ block_b.type = "text"
+ block_b.text = "Part two."
+
+ mock_response = MagicMock()
+ mock_response.content = [block_a, block_b]
+ mock_anthropic.return_value.messages.create.return_value = mock_response
+
+ client = ClaudeClient("fake_key")
+ result = client.generate_report("sys", "user")
+ assert result == "Part one. Part two."
+
+
+def test_chat_returns_text(mocker):
+ mock_anthropic = mocker.patch("src.claude_client.Anthropic")
+ mock_client = MagicMock()
+ mock_anthropic.return_value = mock_client
+
+ mock_block = MagicMock()
+ mock_block.type = "text"
+ mock_block.text = "Here is the answer."
+ mock_client.messages.create.return_value.content = [mock_block]
+
+ from src.claude_client import ClaudeClient
+ client = ClaudeClient("fake_key")
+ result = client.chat(
+ messages=[{"role": "user", "content": "What is blocked?"}],
+ system_prompt="You are an assistant."
+ )
+ assert result == "Here is the answer."
+
+
+def test_chat_passes_messages_and_system(mocker):
+ mock_anthropic = mocker.patch("src.claude_client.Anthropic")
+ mock_client = MagicMock()
+ mock_anthropic.return_value = mock_client
+
+ mock_block = MagicMock()
+ mock_block.type = "text"
+ mock_block.text = "response"
+ mock_client.messages.create.return_value.content = [mock_block]
+
+ from src.claude_client import ClaudeClient
+ client = ClaudeClient("fake_key")
+ messages = [{"role": "user", "content": "hello"}]
+ client.chat(messages=messages, system_prompt="sys")
+
+ call_kwargs = mock_client.messages.create.call_args.kwargs
+ assert call_kwargs["system"] == "sys"
+ assert call_kwargs["messages"] == messages
+ assert call_kwargs["max_tokens"] == 8192
diff --git a/tests/test_preferences.py b/tests/test_preferences.py
new file mode 100644
index 0000000..55f9848
--- /dev/null
+++ b/tests/test_preferences.py
@@ -0,0 +1,53 @@
+from src.preferences import (
+ append_preference,
+ delete_preference,
+ list_preferences,
+ load_preferences,
+)
+
+
+def test_load_preferences_empty():
+ assert load_preferences("user@oliver.agency") == ""
+
+
+def test_append_and_list():
+ pid1 = append_preference("user@oliver.agency", "Prefer concise answers")
+ pid2 = append_preference("user@oliver.agency", "Always cite the data")
+ rows = list_preferences("user@oliver.agency")
+ assert [r["text"] for r in rows] == ["Prefer concise answers", "Always cite the data"]
+ assert {r["id"] for r in rows} == {pid1, pid2}
+
+
+def test_load_preferences_formats_as_bullets():
+ append_preference("user@oliver.agency", "First")
+ append_preference("user@oliver.agency", "Second")
+ text = load_preferences("user@oliver.agency")
+ assert "- First" in text
+ assert "- Second" in text
+
+
+def test_per_user_isolation():
+ append_preference("alice@oliver.agency", "Alice's preference")
+ append_preference("bob@oliver.agency", "Bob's preference")
+ alice = list_preferences("alice@oliver.agency")
+ bob = list_preferences("bob@oliver.agency")
+ assert [r["text"] for r in alice] == ["Alice's preference"]
+ assert [r["text"] for r in bob] == ["Bob's preference"]
+
+
+def test_delete_preference():
+ pid = append_preference("user@oliver.agency", "To be deleted")
+ assert delete_preference("user@oliver.agency", pid) is True
+ assert list_preferences("user@oliver.agency") == []
+
+
+def test_delete_other_users_preference_fails():
+ pid = append_preference("alice@oliver.agency", "Alice's secret")
+ assert delete_preference("bob@oliver.agency", pid) is False
+ # Still there
+ assert len(list_preferences("alice@oliver.agency")) == 1
+
+
+def test_load_preferences_no_user_returns_empty():
+ assert load_preferences(None) == ""
+ assert load_preferences("") == ""
diff --git a/tests/test_prompts.py b/tests/test_prompts.py
new file mode 100644
index 0000000..87df66a
--- /dev/null
+++ b/tests/test_prompts.py
@@ -0,0 +1,52 @@
+import json
+from src.prompts import build_chat_system_prompt, build_manager_summary_prompt, build_full_report_prompt
+
+SAMPLE_TASKS = [
+ {
+ "id": "rec1", "task": "Build reporting tool", "progress": "In Progress",
+ "priority": "P1", "rag": "Red", "owner": "Tony Coppola", "owners": ["Tony Coppola"],
+ "related_item": "Reporting", "category": ["Airtable"], "start_date": "",
+ "end_date": "2026-04-30", "deadline": "2026-04-25", "doing": "",
+ "blocked_by": "", "blocking": "", "notes": "On track", "hours": None,
+ }
+]
+
+SAMPLE_ANALYSIS = {
+ "total": 1, "active_total": 1,
+ "progress_counts": {"In Progress": 1},
+ "priority_counts": {"P1": 1},
+ "red_flags": [],
+ "p1_watchlist": [SAMPLE_TASKS[0]],
+ "by_owner": {"Tony Coppola": [SAMPLE_TASKS[0]]},
+ "overdue": [],
+}
+
+
+def test_build_chat_system_prompt_includes_task_data():
+ prompt = build_chat_system_prompt(SAMPLE_TASKS)
+ assert "Build reporting tool" in prompt
+ assert "Tony Coppola" in prompt
+
+
+def test_build_chat_system_prompt_includes_instructions():
+ prompt = build_chat_system_prompt(SAMPLE_TASKS)
+ assert "shorthand" in prompt.lower() or "plain language" in prompt.lower()
+
+
+def test_build_manager_summary_prompt_includes_p1_watchlist():
+ prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS)
+ assert "P1" in prompt or "Watch List" in prompt
+
+
+def test_build_manager_summary_prompt_has_required_sections():
+ prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS)
+ assert "Programme Overview" in prompt
+ assert "Blockers" in prompt
+
+
+def test_build_full_report_prompt_has_required_sections():
+ prompt = build_full_report_prompt(SAMPLE_ANALYSIS)
+ assert "Executive Summary" in prompt
+ assert "Team Breakdown" in prompt
+ assert "Red Flags" in prompt
+ assert "Overdue" in prompt
diff --git a/tests/test_reporter.py b/tests/test_reporter.py
new file mode 100644
index 0000000..12ef813
--- /dev/null
+++ b/tests/test_reporter.py
@@ -0,0 +1,70 @@
+import pytest
+from pathlib import Path
+from src.reporter import manager_summary_to_markdown, build_manager_summary_docx, build_full_report_docx
+
+SAMPLE_ANALYSIS = {
+ "total": 5, "active_total": 3,
+ "progress_counts": {"In Progress": 2, "Not Started": 1, "Complete": 2},
+ "priority_counts": {"P1": 3, "P2": 2},
+ "red_flags": [],
+ "p1_watchlist": [],
+ "by_owner": {},
+ "overdue": [],
+}
+
+SAMPLE_CLAUDE_TEXT = """## Programme Overview
+
+Things are moving well this week.
+
+## P1 Watch List
+
+- Build reporting tool: In Progress — on track
+
+## Blockers
+
+No blockers this week.
+"""
+
+SAMPLE_FULL_CLAUDE_TEXT = """## Executive Summary
+
+5 total tasks, 3 active.
+
+## Red Flags
+
+No red flags.
+
+## Team Breakdown
+
+Tony Coppola is working on the reporting tool.
+
+## P1 Watch List
+
+- Tony — Build reporting tool Red: In Progress — on track
+
+## Overdue Tasks
+
+No overdue tasks.
+"""
+
+
+def test_manager_summary_to_markdown_contains_header(tmp_path):
+ md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT)
+ assert "Programme Pulse" in md
+ assert "Programme Overview" in md
+
+
+def test_manager_summary_to_markdown_includes_stats(tmp_path):
+ md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT)
+ assert "5" in md # total tasks
+
+
+def test_build_manager_summary_docx_creates_file(tmp_path):
+ path = build_manager_summary_docx(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT, tmp_path)
+ assert path.exists()
+ assert path.suffix == ".docx"
+
+
+def test_build_full_report_docx_creates_file(tmp_path):
+ path = build_full_report_docx(SAMPLE_ANALYSIS, SAMPLE_FULL_CLAUDE_TEXT, tmp_path)
+ assert path.exists()
+ assert path.suffix == ".docx"
diff --git a/web_app.py b/web_app.py
new file mode 100644
index 0000000..b1cff2e
--- /dev/null
+++ b/web_app.py
@@ -0,0 +1,450 @@
+from __future__ import annotations
+
+import json
+import os
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from datetime import date, datetime
+from pathlib import Path
+
+from dotenv import load_dotenv
+from flask import Flask, Response, g, jsonify, request, send_file, stream_with_context
+from flask_cors import CORS
+from sqlalchemy import select
+
+from src.auth import AuthError, dev_bypass_enabled, dev_user_claims, validate_bearer_token
+from src.db import ChatMessage, FeedbackEvent, Preference, Report, db_available, session_scope
+
+load_dotenv()
+
+app = Flask(__name__)
+
+# CORS — frontend lives at /programme-pulse/ on optical-dev (same origin in prod via Apache),
+# and at http://localhost:5173 in dev. Authorization header must be allowed.
+CORS(
+ app,
+ origins=[
+ "https://optical-dev.oliver.solutions",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ ],
+ allow_headers=["Authorization", "Content-Type"],
+ expose_headers=["Content-Disposition"],
+ supports_credentials=False,
+)
+
+PULSE_API_KEY = os.getenv("PULSE_AIRTABLE_API_KEY", "")
+PULSE_BASE_ID = os.getenv("PULSE_AIRTABLE_BASE_ID", "")
+PULSE_TABLE_ID = os.getenv("PULSE_AIRTABLE_TABLE_ID", "")
+RESOURCE_API_KEY = os.getenv("PULSE_RESOURCE_API_KEY", "") or PULSE_API_KEY
+RESOURCE_BASE_ID = os.getenv("PULSE_RESOURCE_BASE_ID", "")
+RESOURCE_TABLE_ID = os.getenv("PULSE_RESOURCE_TABLE_ID", "")
+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+
+OUTPUT_DIR = Path("reports")
+OUTPUT_DIR.mkdir(exist_ok=True)
+
+# Airtable caches — not user data, fine to keep in-process. /api/refresh rebuilds them.
+_snapshot: list[dict] = []
+_snapshot_lock = threading.Lock()
+_resource_snapshot: list[dict] = []
+_resource_lock = threading.Lock()
+
+
+def _load_snapshot():
+ global _snapshot
+ if not PULSE_API_KEY:
+ return
+ from src.airtable_client import PulseAirtableClient
+ try:
+ client = PulseAirtableClient(PULSE_API_KEY, PULSE_BASE_ID, PULSE_TABLE_ID)
+ data = client.fetch_all_tasks()
+ except Exception as e:
+ app.logger.warning("Airtable tasks load failed: %s", e)
+ return
+ with _snapshot_lock:
+ _snapshot = data
+
+
+def _get_snapshot() -> list[dict]:
+ with _snapshot_lock:
+ if _snapshot:
+ return _snapshot
+ _load_snapshot()
+ with _snapshot_lock:
+ return _snapshot
+
+
+def _load_resource_snapshot():
+ global _resource_snapshot
+ if not RESOURCE_API_KEY or not RESOURCE_BASE_ID:
+ return
+ from src.airtable_client import ResourceAirtableClient
+ try:
+ client = ResourceAirtableClient(RESOURCE_API_KEY, RESOURCE_BASE_ID, RESOURCE_TABLE_ID)
+ data = client.fetch_all_bookings()
+ except Exception as e:
+ app.logger.warning("Airtable bookings load failed: %s", e)
+ return
+ with _resource_lock:
+ _resource_snapshot = data
+
+
+def _get_resource_snapshot() -> list[dict]:
+ with _resource_lock:
+ if _resource_snapshot:
+ return _resource_snapshot
+ _load_resource_snapshot()
+ with _resource_lock:
+ return _resource_snapshot
+
+
+# Eagerly load both data sources in background on startup
+threading.Thread(target=_load_snapshot, daemon=True).start()
+threading.Thread(target=_load_resource_snapshot, daemon=True).start()
+
+
+# ---------------------------------------------------------------------------
+# Auth gate
+# ---------------------------------------------------------------------------
+
+PUBLIC_PATHS = {"/api/health"}
+
+
+@app.before_request
+def _auth_gate():
+ if request.method == "OPTIONS":
+ return None
+ if request.path in PUBLIC_PATHS:
+ return None
+ if not request.path.startswith("/api/"):
+ # We only serve API routes; the SPA is served by Apache from /var/www/html.
+ return jsonify({"error": "Not found"}), 404
+
+ if dev_bypass_enabled():
+ claims = dev_user_claims()
+ else:
+ header = request.headers.get("Authorization", "")
+ if not header.startswith("Bearer "):
+ return jsonify({"error": "Missing Authorization: Bearer header"}), 401
+ token = header[7:].strip()
+ try:
+ claims = validate_bearer_token(token)
+ except AuthError as e:
+ return jsonify({"error": str(e)}), e.status_code
+
+ g.user_email = claims["_email"]
+ g.user_name = claims.get("_name") or claims["_email"]
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+@app.get("/api/health")
+def health():
+ return jsonify({
+ "status": "ok",
+ "db": db_available(),
+ "tasks_loaded": len(_snapshot),
+ "bookings_loaded": len(_resource_snapshot),
+ })
+
+
+@app.get("/api/me")
+def me():
+ return jsonify({"email": g.user_email, "name": g.user_name})
+
+
+@app.post("/api/chat")
+def chat():
+ message = (request.json or {}).get("message", "").strip()
+ if not message:
+ return jsonify({"error": "Empty message"}), 400
+
+ tasks = _get_snapshot()
+ if not tasks:
+ return jsonify({"response": "No data loaded. Check Airtable credentials."})
+
+ from src.claude_client import ClaudeClient
+ from src.prompts import build_chat_system_prompt
+
+ history = _load_history(g.user_email)
+ history.append({"role": "user", "content": message})
+ _save_message(g.user_email, "user", message)
+
+ system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=g.user_email)
+ claude = ClaudeClient(ANTHROPIC_API_KEY)
+ response_text = claude.chat(messages=history[-20:], system_prompt=system_prompt)
+ _save_message(g.user_email, "assistant", response_text)
+ return jsonify({"response": response_text})
+
+
+@app.post("/api/chat/stream")
+def chat_stream():
+ user_email = g.user_email
+ message = (request.json or {}).get("message", "").strip()
+ if not message:
+ return jsonify({"error": "Empty message"}), 400
+
+ tasks = _get_snapshot()
+ if not tasks:
+ def _no_data():
+ yield "data: " + json.dumps({"chunk": "No data loaded. Check Airtable credentials."}) + "\n\n"
+ yield "data: [DONE]\n\n"
+ return Response(stream_with_context(_no_data()), mimetype="text/event-stream")
+
+ from src.claude_client import ClaudeClient
+ from src.prompts import build_chat_system_prompt
+
+ history = _load_history(user_email)
+ history.append({"role": "user", "content": message})
+ _save_message(user_email, "user", message)
+
+ system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=user_email)
+ claude = ClaudeClient(ANTHROPIC_API_KEY)
+
+ def generate():
+ full_response: list[str] = []
+ try:
+ with claude.chat_stream(messages=history[-20:], system_prompt=system_prompt) as stream:
+ for text in stream.text_stream:
+ full_response.append(text)
+ yield "data: " + json.dumps({"chunk": text}) + "\n\n"
+ yield "data: [DONE]\n\n"
+ except Exception as e:
+ yield "data: " + json.dumps({"error": str(e)}) + "\n\n"
+ yield "data: [DONE]\n\n"
+ finally:
+ response_text = "".join(full_response)
+ if response_text:
+ _save_message(user_email, "assistant", response_text)
+
+ return Response(
+ stream_with_context(generate()),
+ mimetype="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@app.post("/api/generate")
+def generate():
+ tasks = _get_snapshot()
+ if not tasks:
+ return jsonify({"error": "No data loaded"}), 500
+
+ from src.analyzer import analyze
+ from src.claude_client import ClaudeClient
+ from src.prompts import PULSE_SYSTEM_PROMPT, build_full_report_prompt, build_manager_summary_prompt
+ from src.reporter import build_full_report_docx, build_manager_summary_docx, manager_summary_to_markdown
+
+ analysis = analyze(tasks)
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
+
+ def _gen_summary():
+ return ClaudeClient(ANTHROPIC_API_KEY).generate_report(
+ PULSE_SYSTEM_PROMPT, build_manager_summary_prompt(analysis)
+ )
+
+ def _gen_full():
+ return ClaudeClient(ANTHROPIC_API_KEY).generate_report(
+ PULSE_SYSTEM_PROMPT, build_full_report_prompt(analysis)
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ f_summary = pool.submit(_gen_summary)
+ f_full = pool.submit(_gen_full)
+ summary_text = f_summary.result()
+ full_text = f_full.result()
+
+ summary_md = manager_summary_to_markdown(analysis, summary_text)
+ summary_doc = build_manager_summary_docx(analysis, summary_text, OUTPUT_DIR, f"summary-{ts}.docx")
+ full_doc = build_full_report_docx(analysis, full_text, OUTPUT_DIR, f"full-{ts}.docx")
+
+ with session_scope() as s:
+ report = Report(
+ user_email=g.user_email,
+ summary_md=summary_md,
+ summary_doc_path=str(summary_doc),
+ full_doc_path=str(full_doc),
+ )
+ s.add(report)
+ s.flush()
+ report_id = report.id
+
+ return jsonify({"status": "ok", "report_id": report_id})
+
+
+@app.get("/api/copy/summary")
+def copy_summary():
+ report = _latest_report(g.user_email)
+ if not report:
+ return jsonify({"error": "No report generated yet"}), 404
+ return jsonify({"markdown": report.summary_md})
+
+
+@app.get("/api/download/summary")
+def download_summary():
+ report = _latest_report(g.user_email)
+ if not report:
+ return jsonify({"error": "No report generated yet"}), 404
+ return _send_report_file(report.summary_doc_path, f"programme-pulse-summary-{date.today()}.docx")
+
+
+@app.get("/api/download/full")
+def download_full():
+ report = _latest_report(g.user_email)
+ if not report:
+ return jsonify({"error": "No report generated yet"}), 404
+ return _send_report_file(report.full_doc_path, f"programme-pulse-full-{date.today()}.docx")
+
+
+@app.get("/api/download/history//")
+def download_history(report_id: int, which: str):
+ if which not in ("summary", "full"):
+ return jsonify({"error": "Invalid type"}), 400
+ with session_scope() as s:
+ report = s.get(Report, report_id)
+ if report is None or report.user_email != g.user_email:
+ return jsonify({"error": "Not found"}), 404
+ path = report.summary_doc_path if which == "summary" else report.full_doc_path
+ download_name = f"programme-pulse-{which}-{report.generated_at.strftime('%Y%m%d-%H%M%S')}.docx"
+ return _send_report_file(path, download_name)
+
+
+@app.get("/api/history")
+def history():
+ with session_scope() as s:
+ rows = s.scalars(
+ select(Report)
+ .where(Report.user_email == g.user_email)
+ .order_by(Report.generated_at.desc())
+ .limit(10)
+ ).all()
+ runs = [
+ {
+ "id": r.id,
+ "label": r.generated_at.strftime("%-d %b %Y, %H:%M"),
+ "ts": r.generated_at.strftime("%Y%m%d-%H%M%S"),
+ }
+ for r in rows
+ ]
+ return jsonify({"runs": runs})
+
+
+@app.get("/api/preferences")
+def get_preferences():
+ from src.preferences import list_preferences
+ return jsonify({"preferences": list_preferences(g.user_email)})
+
+
+@app.delete("/api/preferences/")
+def remove_preference(pref_id: int):
+ from src.preferences import delete_preference
+ ok = delete_preference(g.user_email, pref_id)
+ if not ok:
+ return jsonify({"error": "Not found"}), 404
+ return jsonify({"status": "ok"})
+
+
+@app.post("/api/feedback")
+def feedback():
+ body = request.json or {}
+ rating = body.get("rating")
+ message_text = (body.get("message") or "").strip()
+ if not message_text or rating not in ("up", "down"):
+ return jsonify({"error": "Invalid feedback"}), 400
+
+ from src.claude_client import ClaudeClient
+ from src.preferences import append_preference
+
+ direction = "liked" if rating == "up" else "disliked"
+ extraction_prompt = f"""The user {direction} this assistant response. Extract one concise preference insight that should guide future responses.
+
+Response the user {direction}:
+\"\"\"{message_text}\"\"\"
+
+Write a single plain sentence starting with an action word, e.g. "Prefer...", "Avoid...", "Always...", "Lead with...". No preamble. Max 20 words."""
+
+ claude = ClaudeClient(ANTHROPIC_API_KEY)
+ insight = claude.chat(
+ messages=[{"role": "user", "content": extraction_prompt}],
+ system_prompt="You extract concise preference insights from user feedback. One sentence only.",
+ )
+ insight = insight.strip()
+ pref_id = append_preference(g.user_email, insight)
+
+ with session_scope() as s:
+ s.add(FeedbackEvent(
+ user_email=g.user_email,
+ rating=rating,
+ message_text=message_text,
+ extracted_insight=insight,
+ preference_id=pref_id,
+ ))
+
+ return jsonify({"status": "ok", "insight": insight})
+
+
+@app.post("/api/refresh")
+def refresh():
+ global _snapshot, _resource_snapshot
+ with _snapshot_lock:
+ _snapshot = []
+ with _resource_lock:
+ _resource_snapshot = []
+ _load_snapshot()
+ _load_resource_snapshot()
+ with _snapshot_lock:
+ count = len(_snapshot)
+ return jsonify({"status": "ok", "tasks": count})
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _load_history(user_email: str) -> list[dict]:
+ """Last 20 chat turns for this user, oldest-first, in Anthropic message format."""
+ if not db_available():
+ return []
+ with session_scope() as s:
+ rows = s.scalars(
+ select(ChatMessage)
+ .where(ChatMessage.user_email == user_email)
+ .order_by(ChatMessage.created_at.desc())
+ .limit(20)
+ ).all()
+ return [{"role": r.role, "content": r.content} for r in reversed(rows)]
+
+
+def _save_message(user_email: str, role: str, content: str) -> None:
+ if not db_available():
+ return
+ with session_scope() as s:
+ s.add(ChatMessage(user_email=user_email, role=role, content=content))
+
+
+def _latest_report(user_email: str) -> Report | None:
+ if not db_available():
+ return None
+ with session_scope() as s:
+ return s.scalars(
+ select(Report)
+ .where(Report.user_email == user_email)
+ .order_by(Report.generated_at.desc())
+ .limit(1)
+ ).first()
+
+
+def _send_report_file(path: str, download_name: str):
+ p = Path(path)
+ if not p.exists():
+ return jsonify({"error": "File missing on disk"}), 410
+ return send_file(str(p), as_attachment=True, download_name=download_name)
+
+
+if __name__ == "__main__":
+ port = int(os.getenv("PORT", "5051"))
+ app.run(host="0.0.0.0", port=port, debug=False)