diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..cdeb70f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Database
+DB_PASSWORD=change_me_strong_password
+
+# Full DSN (auto-constructed from above in config.py, override if needed)
+# DATABASE_URL=postgresql+asyncpg://cc_app:change_me@db:5432/cc_dashboard
+
+# JWT
+SECRET_KEY=change_me_at_least_32_chars_long_random_string
+ACCESS_TOKEN_EXPIRE_MINUTES=30
+REFRESH_TOKEN_EXPIRE_DAYS=7
+
+# App
+BASE_PATH=/cc-dashboard
+APP_TITLE=CC Dashboard
+DEBUG=false
diff --git a/.gitignore b/.gitignore
index b24d71e..815c299 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,50 +1,25 @@
-# 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
+.env
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+.coverage
+dist/
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
-.DS_Store
-
-# Generated by Windows
-Thumbs.db
-
-# Applications
+*.class
+*.log
*.app
*.exe
*.war
-
-# Large media files
+.DS_Store
+Thumbs.db
+.idea/
+TEST*.xml
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
-
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0052e3b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+FROM python:3.12-slim
+
+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 --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Run migrations then start server
+CMD ["sh", "-c", "alembic upgrade head && uvicorn src.main:app --host 0.0.0.0 --port 8800 --workers 1"]
diff --git a/alembic/alembic.ini b/alembic/alembic.ini
new file mode 100644
index 0000000..27e668c
--- /dev/null
+++ b/alembic/alembic.ini
@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+[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..c7d0352
--- /dev/null
+++ b/alembic/env.py
@@ -0,0 +1,54 @@
+import asyncio
+import os
+import sys
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy.ext.asyncio import create_async_engine
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from src.config import settings
+from src.database import Base
+import src.models # noqa — register all models
+
+config = context.config
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline():
+ context.configure(
+ url=settings.DATABASE_URL,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations():
+ engine = create_async_engine(settings.DATABASE_URL)
+ async with engine.begin() as conn:
+ await conn.run_sync(
+ lambda conn: context.configure(
+ connection=conn,
+ target_metadata=target_metadata,
+ compare_type=True,
+ )
+ )
+ await conn.run_sync(lambda conn: context.run_migrations())
+ await engine.dispose()
+
+
+def run_migrations_online():
+ asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..7a42e67
--- /dev/null
+++ b/alembic/versions/0001_initial.py
@@ -0,0 +1,107 @@
+"""initial schema
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-03-26
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+revision = "0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("CREATE TYPE user_role AS ENUM ('admin', 'user')")
+
+ op.create_table(
+ "users",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("email", sa.String(255), nullable=False, unique=True),
+ sa.Column("username", sa.String(100), nullable=False, unique=True),
+ sa.Column("password_hash", sa.String(255), nullable=False),
+ sa.Column("role", sa.Enum("admin", "user", name="user_role"), nullable=False, server_default="user"),
+ sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
+ sa.Column("daily_overhead_hours", sa.Float, nullable=False, server_default="2.0"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+ op.create_index("ix_users_email", "users", ["email"])
+
+ op.create_table(
+ "api_keys",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("key_hash", sa.String(255), nullable=False),
+ sa.Column("key_prefix", sa.String(12), nullable=False),
+ sa.Column("label", sa.String(100), server_default="My Machine"),
+ sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
+ sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+ op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
+ op.create_index("ix_api_keys_prefix", "api_keys", ["key_prefix"])
+
+ op.create_table(
+ "projects",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("slug", sa.String(255), nullable=False),
+ sa.Column("display_name", sa.String(255), nullable=False),
+ sa.Column("root_path", sa.String(500), server_default=""),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("user_id", "slug", name="uq_project_user_slug"),
+ )
+ op.create_index("ix_projects_user_id", "projects", ["user_id"])
+
+ op.create_table(
+ "sessions",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("project_id", UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("session_id", sa.String(100), nullable=False),
+ sa.Column("date", sa.Date, nullable=False),
+ sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("active_hours", sa.Float, server_default="0"),
+ sa.Column("message_count", sa.Integer, server_default="0"),
+ sa.Column("work_summary", sa.Text, server_default=""),
+ sa.Column("commits", JSONB, server_default="[]"),
+ sa.Column("tools_used", JSONB, server_default="{}"),
+ sa.Column("files_changed", JSONB, server_default="[]"),
+ sa.Column("raw_stats", JSONB, server_default="{}"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("user_id", "session_id", "date", name="uq_session_user_date"),
+ )
+ op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
+ op.create_index("ix_sessions_project_id", "sessions", ["project_id"])
+ op.create_index("ix_sessions_date", "sessions", ["date"])
+
+ op.create_table(
+ "daily_stats",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("project_id", UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("date", sa.Date, nullable=False),
+ sa.Column("total_hours", sa.Float, server_default="0"),
+ sa.Column("session_count", sa.Integer, server_default="0"),
+ sa.Column("message_count", sa.Integer, server_default="0"),
+ sa.Column("commit_count", sa.Integer, server_default="0"),
+ sa.Column("files_changed_count", sa.Integer, server_default="0"),
+ sa.Column("top_tools", JSONB, server_default="{}"),
+ sa.UniqueConstraint("user_id", "project_id", "date", name="uq_daily_stat"),
+ )
+ op.create_index("ix_daily_stats_user_id", "daily_stats", ["user_id"])
+ op.create_index("ix_daily_stats_date", "daily_stats", ["date"])
+ op.create_index("ix_daily_stats_project_id", "daily_stats", ["project_id"])
+
+
+def downgrade():
+ op.drop_table("daily_stats")
+ op.drop_table("sessions")
+ op.drop_table("projects")
+ op.drop_table("api_keys")
+ op.drop_table("users")
+ op.execute("DROP TYPE user_role")
diff --git a/apache.conf b/apache.conf
new file mode 100644
index 0000000..9f02adb
--- /dev/null
+++ b/apache.conf
@@ -0,0 +1,24 @@
+# CC Dashboard — Apache reverse proxy config
+# Include this in your VirtualHost for optical-dev.oliver.solutions
+# Requires: mod_proxy mod_proxy_http mod_headers
+
+# Enable required modules if not already:
+# a2enmod proxy proxy_http headers
+
+
+ ProxyPreserveHost On
+ ProxyPass http://127.0.0.1:8800/cc-dashboard/
+ ProxyPassReverse http://127.0.0.1:8800/cc-dashboard/
+
+ # Disable response buffering — critical for SSE
+ Header set X-Accel-Buffering "no"
+ SetEnv proxy-nokeepalive 1
+ SetEnv proxy-sendchunked 1
+
+ # Allow enough time for SSE connections (heartbeat every 25s, so 60s is fine)
+ ProxyTimeout 60
+
+ # Forward real IP
+ RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
+ RequestHeader set X-Forwarded-Proto "https"
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..00a7725
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+services:
+ app:
+ build: .
+ ports:
+ - "8800:8800"
+ env_file: .env
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: unless-stopped
+ volumes:
+ - ./src/static:/app/src/static:ro # hot-reload static files without rebuild
+
+ db:
+ image: postgres:16-alpine
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ environment:
+ POSTGRES_DB: cc_dashboard
+ POSTGRES_USER: cc_app
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U cc_app -d cc_dashboard"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ restart: unless-stopped
+
+volumes:
+ pgdata:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a05e866
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,11 @@
+fastapi==0.115.5
+uvicorn[standard]==0.32.1
+sqlalchemy[asyncio]==2.0.36
+asyncpg==0.30.0
+alembic==1.14.0
+pydantic[email]==2.10.3
+pydantic-settings==2.6.1
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.20
+httpx==0.28.1
diff --git a/scripts/create_admin.py b/scripts/create_admin.py
new file mode 100644
index 0000000..c64216e
--- /dev/null
+++ b/scripts/create_admin.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""Run inside Docker to create the first admin user.
+ docker compose exec app python scripts/create_admin.py
+"""
+import asyncio
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from src.auth import hash_password
+from src.database import AsyncSessionLocal
+from src.models import User
+from sqlalchemy import select
+
+
+async def main():
+ email = input("Admin email: ").strip()
+ username = input("Username: ").strip()
+ password = input("Password (min 8 chars): ").strip()
+
+ if len(password) < 8:
+ print("Password too short")
+ sys.exit(1)
+
+ async with AsyncSessionLocal() as db:
+ existing = await db.execute(select(User).where(User.email == email))
+ if existing.scalar_one_or_none():
+ print(f"User {email} already exists")
+ sys.exit(1)
+
+ user = User(
+ email=email,
+ username=username,
+ password_hash=hash_password(password),
+ role="admin",
+ )
+ db.add(user)
+ await db.commit()
+ print(f"Admin created: {email}")
+
+
+asyncio.run(main())
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/auth.py b/src/auth.py
new file mode 100644
index 0000000..c6d50c2
--- /dev/null
+++ b/src/auth.py
@@ -0,0 +1,109 @@
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, Security, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.config import settings
+from src.database import get_db
+from src.models import ApiKey, User
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+bearer_scheme = HTTPBearer(auto_error=False)
+
+ALGORITHM = "HS256"
+
+
+# ── Password ──────────────────────────────────────────────────────────────────
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
+
+
+# ── JWT ───────────────────────────────────────────────────────────────────────
+
+def create_access_token(user_id: str, role: str) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+ return jwt.encode(
+ {"sub": user_id, "role": role, "exp": expire, "type": "access"},
+ settings.SECRET_KEY, algorithm=ALGORITHM,
+ )
+
+
+def create_refresh_token(user_id: str) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
+ return jwt.encode(
+ {"sub": user_id, "exp": expire, "type": "refresh"},
+ settings.SECRET_KEY, algorithm=ALGORITHM,
+ )
+
+
+def decode_token(token: str) -> dict:
+ try:
+ return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
+ except JWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+
+# ── API Key ───────────────────────────────────────────────────────────────────
+
+def generate_api_key() -> tuple[str, str, str]:
+ """Returns (raw_key, prefix, hash). raw_key shown once to user."""
+ raw = "cc_" + secrets.token_urlsafe(32)
+ prefix = raw[:11] # "cc_" + 8 chars
+ return raw, prefix, pwd_context.hash(raw)
+
+
+async def verify_api_key(raw_key: str, db: AsyncSession) -> User | None:
+ """Find user by API key. Updates last_used_at."""
+ if not raw_key or not raw_key.startswith("cc_"):
+ return None
+ prefix = raw_key[:11]
+ result = await db.execute(
+ select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active == True)
+ .join(ApiKey.user)
+ .where(User.is_active == True)
+ )
+ keys = result.scalars().all()
+ for key in keys:
+ if pwd_context.verify(raw_key, key.key_hash):
+ key.last_used_at = datetime.now(timezone.utc)
+ await db.commit()
+ return key.user
+ return None
+
+
+# ── FastAPI dependencies ──────────────────────────────────────────────────────
+
+async def get_current_user(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
+ db: AsyncSession = Depends(get_db),
+) -> User:
+ if not credentials:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+ payload = decode_token(credentials.credentials)
+ if payload.get("type") != "access":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
+ user = await db.get(User, payload["sub"])
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+ return user
+
+
+async def get_admin_user(user: User = Depends(get_current_user)) -> User:
+ if user.role != "admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
+ return user
+
+
+CurrentUser = Annotated[User, Depends(get_current_user)]
+AdminUser = Annotated[User, Depends(get_admin_user)]
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..f42cef6
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,30 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+
+ # DB
+ DB_PASSWORD: str = "postgres"
+ DATABASE_URL: str = ""
+
+ # JWT
+ SECRET_KEY: str = "dev_secret_key_change_in_production"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
+
+ # App
+ BASE_PATH: str = "/cc-dashboard"
+ APP_TITLE: str = "CC Dashboard"
+ DEBUG: bool = False
+
+ def model_post_init(self, __context):
+ if not self.DATABASE_URL:
+ object.__setattr__(
+ self,
+ "DATABASE_URL",
+ f"postgresql+asyncpg://cc_app:{self.DB_PASSWORD}@db:5432/cc_dashboard",
+ )
+
+
+settings = Settings()
diff --git a/src/database.py b/src/database.py
new file mode 100644
index 0000000..96759b3
--- /dev/null
+++ b/src/database.py
@@ -0,0 +1,22 @@
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from src.config import settings
+
+engine = create_async_engine(
+ settings.DATABASE_URL,
+ pool_size=10,
+ max_overflow=20,
+ echo=settings.DEBUG,
+)
+
+AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+async def get_db() -> AsyncSession:
+ async with AsyncSessionLocal() as session:
+ yield session
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..f475a96
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,49 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+
+from src.config import settings
+from src.routers import admin, auth, dashboard, events, ingest, keys, projects
+
+BASE = settings.BASE_PATH # "/cc-dashboard"
+
+app = FastAPI(
+ title=settings.APP_TITLE,
+ docs_url=f"{BASE}/docs" if settings.DEBUG else None,
+ redoc_url=None,
+ root_path=BASE,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# API routers
+for router in [auth.router, keys.router, admin.router, ingest.router,
+ dashboard.router, events.router, projects.router]:
+ app.include_router(router)
+
+# Static files — served at /cc-dashboard/static/
+app.mount(f"{BASE}/static", StaticFiles(directory="src/static"), name="static")
+
+# SPA fallback — serve index.html for all non-API routes
+from fastapi.responses import FileResponse
+from fastapi import Request
+
+@app.get(f"{BASE}/", include_in_schema=False)
+@app.get(f"{BASE}", include_in_schema=False)
+async def spa_root():
+ return FileResponse("src/static/index.html")
+
+
+@app.get(f"{BASE}/{{path:path}}", include_in_schema=False)
+async def spa_fallback(path: str, request: Request):
+ # Don't catch API routes
+ if path.startswith("api/") or path.startswith("static/"):
+ from fastapi import HTTPException
+ raise HTTPException(status_code=404)
+ return FileResponse("src/static/index.html")
diff --git a/src/models.py b/src/models.py
new file mode 100644
index 0000000..bf438dd
--- /dev/null
+++ b/src/models.py
@@ -0,0 +1,106 @@
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import (
+ Boolean, Date, DateTime, Enum, Float, ForeignKey,
+ Integer, String, Text, UniqueConstraint,
+)
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from src.database import Base
+
+
+def new_uuid() -> str:
+ return str(uuid.uuid4())
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ role: Mapped[str] = mapped_column(Enum("admin", "user", name="user_role"), default="user", nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ daily_overhead_hours: Mapped[float] = mapped_column(Float, default=2.0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="user", cascade="all, delete-orphan")
+ projects: Mapped[list["Project"]] = relationship(back_populates="user", cascade="all, delete-orphan")
+ sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan")
+
+
+class ApiKey(Base):
+ __tablename__ = "api_keys"
+
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
+ user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # "cc_XXXXXXXX" for display
+ label: Mapped[str] = mapped_column(String(100), default="My Machine")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ user: Mapped["User"] = relationship(back_populates="api_keys")
+
+
+class Project(Base):
+ __tablename__ = "projects"
+ __table_args__ = (UniqueConstraint("user_id", "slug", name="uq_project_user_slug"),)
+
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
+ user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ slug: Mapped[str] = mapped_column(String(255), nullable=False)
+ display_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ root_path: Mapped[str] = mapped_column(String(500), default="")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ user: Mapped["User"] = relationship(back_populates="projects")
+ sessions: Mapped[list["Session"]] = relationship(back_populates="project", cascade="all, delete-orphan")
+ daily_stats: Mapped[list["DailyStat"]] = relationship(back_populates="project", cascade="all, delete-orphan")
+
+
+class Session(Base):
+ __tablename__ = "sessions"
+ __table_args__ = (UniqueConstraint("user_id", "session_id", "date", name="uq_session_user_date"),)
+
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
+ user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ project_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
+ session_id: Mapped[str] = mapped_column(String(100), nullable=False)
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
+ start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ active_hours: Mapped[float] = mapped_column(Float, default=0.0)
+ message_count: Mapped[int] = mapped_column(Integer, default=0)
+ work_summary: Mapped[str] = mapped_column(Text, default="")
+ commits: Mapped[list] = mapped_column(JSONB, default=list)
+ tools_used: Mapped[dict] = mapped_column(JSONB, default=dict)
+ files_changed: Mapped[list] = mapped_column(JSONB, default=list)
+ raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ user: Mapped["User"] = relationship(back_populates="sessions")
+ project: Mapped["Project"] = relationship(back_populates="sessions")
+
+
+class DailyStat(Base):
+ __tablename__ = "daily_stats"
+ __table_args__ = (UniqueConstraint("user_id", "project_id", "date", name="uq_daily_stat"),)
+
+ id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
+ user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ project_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
+ total_hours: Mapped[float] = mapped_column(Float, default=0.0)
+ session_count: Mapped[int] = mapped_column(Integer, default=0)
+ message_count: Mapped[int] = mapped_column(Integer, default=0)
+ commit_count: Mapped[int] = mapped_column(Integer, default=0)
+ files_changed_count: Mapped[int] = mapped_column(Integer, default=0)
+ top_tools: Mapped[dict] = mapped_column(JSONB, default=dict)
+
+ project: Mapped["Project"] = relationship(back_populates="daily_stats")
diff --git a/src/routers/__init__.py b/src/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/routers/admin.py b/src/routers/admin.py
new file mode 100644
index 0000000..b73c5c6
--- /dev/null
+++ b/src/routers/admin.py
@@ -0,0 +1,82 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import AdminUser, hash_password
+from src.database import get_db
+from src.models import Session, User
+from src.schemas import AdminStats, UserCreate, UserOut, UserUpdate
+
+router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+
+@router.get("/users", response_model=list[UserOut])
+async def list_users(admin: AdminUser, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(User).order_by(User.created_at.desc()))
+ return result.scalars().all()
+
+
+@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
+async def create_user(body: UserCreate, admin: AdminUser, db: AsyncSession = Depends(get_db)):
+ exists = await db.execute(select(User).where(User.email == body.email))
+ if exists.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Email already registered")
+ user = User(
+ email=body.email,
+ username=body.username,
+ password_hash=hash_password(body.password),
+ role=body.role,
+ )
+ db.add(user)
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+@router.put("/users/{user_id}", response_model=UserOut)
+async def update_user(
+ user_id: str, body: UserUpdate, admin: AdminUser, db: AsyncSession = Depends(get_db)
+):
+ user = await db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if body.username is not None:
+ user.username = body.username
+ if body.role is not None:
+ user.role = body.role
+ if body.is_active is not None:
+ user.is_active = body.is_active
+ if body.daily_overhead_hours is not None:
+ user.daily_overhead_hours = body.daily_overhead_hours
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_user(user_id: str, admin: AdminUser, db: AsyncSession = Depends(get_db)):
+ user = await db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.id == admin.id:
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
+ user.is_active = False
+ await db.commit()
+
+
+@router.get("/stats", response_model=AdminStats)
+async def platform_stats(admin: AdminUser, db: AsyncSession = Depends(get_db)):
+ users_result = await db.execute(select(User).order_by(User.created_at.desc()))
+ users = users_result.scalars().all()
+ active_count = sum(1 for u in users if u.is_active)
+
+ sessions_count = await db.scalar(select(func.count(Session.id)))
+ total_hours = await db.scalar(select(func.coalesce(func.sum(Session.active_hours), 0)))
+
+ return AdminStats(
+ total_users=len(users),
+ active_users=active_count,
+ total_sessions=sessions_count or 0,
+ total_hours=round(float(total_hours or 0), 2),
+ users=[UserOut.model_validate(u) for u in users],
+ )
diff --git a/src/routers/auth.py b/src/routers/auth.py
new file mode 100644
index 0000000..73495cc
--- /dev/null
+++ b/src/routers/auth.py
@@ -0,0 +1,60 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import (
+ CurrentUser, create_access_token, create_refresh_token,
+ decode_token, hash_password, verify_password,
+)
+from src.database import get_db
+from src.models import User
+from src.schemas import (
+ ChangePasswordRequest, LoginRequest, RefreshRequest,
+ TokenResponse, UserOut,
+)
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+@router.post("/login", response_model=TokenResponse)
+async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(User).where(User.email == body.email))
+ user = result.scalar_one_or_none()
+ if not user or not user.is_active or not verify_password(body.password, user.password_hash):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ return TokenResponse(
+ access_token=create_access_token(user.id, user.role),
+ refresh_token=create_refresh_token(user.id),
+ )
+
+
+@router.post("/refresh", response_model=TokenResponse)
+async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
+ payload = decode_token(body.refresh_token)
+ if payload.get("type") != "refresh":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
+ user = await db.get(User, payload["sub"])
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+ return TokenResponse(
+ access_token=create_access_token(user.id, user.role),
+ refresh_token=create_refresh_token(user.id),
+ )
+
+
+@router.post("/change-password")
+async def change_password(
+ body: ChangePasswordRequest,
+ user: CurrentUser,
+ db: AsyncSession = Depends(get_db),
+):
+ if not verify_password(body.current_password, user.password_hash):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Wrong current password")
+ user.password_hash = hash_password(body.new_password)
+ await db.commit()
+ return {"detail": "Password changed"}
+
+
+@router.get("/me", response_model=UserOut)
+async def me(user: CurrentUser):
+ return user
diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py
new file mode 100644
index 0000000..56cc385
--- /dev/null
+++ b/src/routers/dashboard.py
@@ -0,0 +1,370 @@
+"""Dashboard aggregation endpoints."""
+from collections import defaultdict
+from datetime import date, timedelta
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import CurrentUser
+from src.database import get_db
+from src.models import DailyStat, Project, Session
+from src.schemas import (
+ DailyPoint, DowPoint, KpiSummary, MonthlyPoint,
+ ProjectDetail, ProjectHours, ProjectOut, SessionOut, ToolUsage,
+)
+
+router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
+
+DOW_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+
+
+def _date_range(from_date: date | None, to_date: date | None):
+ if not to_date:
+ to_date = date.today()
+ if not from_date:
+ from_date = to_date - timedelta(days=29)
+ return from_date, to_date
+
+
+@router.get("/summary", response_model=KpiSummary)
+async def summary(
+ user: CurrentUser,
+ from_date: date | None = Query(default=None, alias="from"),
+ to_date: date | None = Query(default=None, alias="to"),
+ db: AsyncSession = Depends(get_db),
+):
+ from_date, to_date = _date_range(from_date, to_date)
+
+ stats_result = await db.execute(
+ select(DailyStat, Project.display_name)
+ .join(Project, DailyStat.project_id == Project.id)
+ .where(
+ DailyStat.user_id == user.id,
+ DailyStat.date >= from_date,
+ DailyStat.date <= to_date,
+ )
+ )
+ rows = stats_result.all()
+
+ if not rows:
+ return KpiSummary(
+ total_hours=0, total_projects=0, working_days=0,
+ total_sessions=0, avg_hours_per_day=0, top_project="—",
+ total_commits=0, total_files_changed=0,
+ period_from=from_date, period_to=to_date,
+ )
+
+ # Apply overhead proportionally per day
+ day_totals: dict[date, float] = defaultdict(float)
+ for stat, _ in rows:
+ day_totals[stat.date] += stat.total_hours
+
+ proj_hours: dict[str, float] = defaultdict(float)
+ total_hours = total_sessions = total_commits = total_files = 0
+ working_days: set = set()
+
+ for stat, proj_name in rows:
+ day_total = day_totals[stat.date]
+ share = stat.total_hours / day_total if day_total > 0 else 0
+ overhead = user.daily_overhead_hours * share
+ adjusted = stat.total_hours + overhead
+
+ proj_hours[proj_name] += adjusted
+ total_hours += adjusted
+ total_sessions += stat.session_count
+ total_commits += stat.commit_count
+ total_files += stat.files_changed_count
+ working_days.add(stat.date)
+
+ top_project = max(proj_hours, key=proj_hours.get) if proj_hours else "—"
+ n_days = len(working_days)
+
+ return KpiSummary(
+ total_hours=round(total_hours, 2),
+ total_projects=len(proj_hours),
+ working_days=n_days,
+ total_sessions=total_sessions,
+ avg_hours_per_day=round(total_hours / n_days, 2) if n_days else 0,
+ top_project=top_project,
+ total_commits=total_commits,
+ total_files_changed=total_files,
+ period_from=from_date,
+ period_to=to_date,
+ )
+
+
+@router.get("/projects", response_model=list[ProjectHours])
+async def projects_overview(
+ user: CurrentUser,
+ from_date: date | None = Query(default=None, alias="from"),
+ to_date: date | None = Query(default=None, alias="to"),
+ db: AsyncSession = Depends(get_db),
+):
+ from_date, to_date = _date_range(from_date, to_date)
+
+ result = await db.execute(
+ select(DailyStat, Project)
+ .join(Project, DailyStat.project_id == Project.id)
+ .where(
+ DailyStat.user_id == user.id,
+ DailyStat.date >= from_date,
+ DailyStat.date <= to_date,
+ )
+ )
+ rows = result.all()
+
+ day_totals: dict[date, float] = defaultdict(float)
+ for stat, _ in rows:
+ day_totals[stat.date] += stat.total_hours
+
+ proj_data: dict[str, dict] = {}
+ for stat, project in rows:
+ pid = project.id
+ if pid not in proj_data:
+ proj_data[pid] = {
+ "project_id": pid,
+ "display_name": project.display_name,
+ "total_hours": 0.0,
+ "session_count": 0,
+ "days": set(),
+ "last_active": None,
+ }
+ day_total = day_totals[stat.date]
+ share = stat.total_hours / day_total if day_total > 0 else 0
+ proj_data[pid]["total_hours"] += stat.total_hours + user.daily_overhead_hours * share
+ proj_data[pid]["session_count"] += stat.session_count
+ proj_data[pid]["days"].add(stat.date)
+ if not proj_data[pid]["last_active"] or stat.date > proj_data[pid]["last_active"]:
+ proj_data[pid]["last_active"] = stat.date
+
+ return sorted(
+ [
+ ProjectHours(
+ project_id=v["project_id"],
+ display_name=v["display_name"],
+ total_hours=round(v["total_hours"], 2),
+ session_count=v["session_count"],
+ working_days=len(v["days"]),
+ last_active=v["last_active"],
+ )
+ for v in proj_data.values()
+ ],
+ key=lambda x: -x.total_hours,
+ )
+
+
+@router.get("/timeline", response_model=list[DailyPoint])
+async def timeline(
+ user: CurrentUser,
+ days: int = Query(default=30, ge=7, le=365),
+ db: AsyncSession = Depends(get_db),
+):
+ to_date = date.today()
+ from_date = to_date - timedelta(days=days - 1)
+
+ result = await db.execute(
+ select(DailyStat.date, func.sum(DailyStat.total_hours), func.sum(DailyStat.session_count))
+ .where(
+ DailyStat.user_id == user.id,
+ DailyStat.date >= from_date,
+ DailyStat.date <= to_date,
+ )
+ .group_by(DailyStat.date)
+ .order_by(DailyStat.date)
+ )
+ rows = result.all()
+
+ # Build day map with overhead
+ day_map = {r[0]: (float(r[1] or 0), int(r[2] or 0)) for r in rows}
+
+ # Get project counts per day for overhead distribution (simplified: add flat overhead per working day)
+ points = []
+ for i in range(days):
+ d = from_date + timedelta(days=i)
+ raw_h, sess = day_map.get(d, (0.0, 0))
+ if raw_h > 0:
+ # Get distinct project count for this day to distribute overhead
+ proj_count_result = await db.scalar(
+ select(func.count(func.distinct(DailyStat.project_id)))
+ .where(DailyStat.user_id == user.id, DailyStat.date == d)
+ )
+ adjusted = raw_h + user.daily_overhead_hours if proj_count_result else raw_h
+ else:
+ adjusted = 0.0
+ points.append(DailyPoint(date=d, hours=round(adjusted, 2), sessions=sess))
+
+ return points
+
+
+@router.get("/monthly", response_model=list[MonthlyPoint])
+async def monthly(user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(
+ func.to_char(DailyStat.date, "YYYY-MM").label("month"),
+ func.sum(DailyStat.total_hours),
+ )
+ .where(DailyStat.user_id == user.id)
+ .group_by("month")
+ .order_by("month")
+ )
+ rows = result.all()
+
+ # Apply daily overhead (rough: count distinct working days per month)
+ month_days_result = await db.execute(
+ select(
+ func.to_char(DailyStat.date, "YYYY-MM").label("month"),
+ func.count(func.distinct(DailyStat.date)),
+ )
+ .where(DailyStat.user_id == user.id)
+ .group_by("month")
+ )
+ month_days = {r[0]: r[1] for r in month_days_result.all()}
+
+ return [
+ MonthlyPoint(
+ month=r[0],
+ hours=round(float(r[1] or 0) + user.daily_overhead_hours * month_days.get(r[0], 0), 2),
+ )
+ for r in rows
+ ]
+
+
+@router.get("/dow", response_model=list[DowPoint])
+async def day_of_week(user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(
+ func.extract("dow", DailyStat.date).label("dow_pg"), # 0=Sun in PG
+ func.sum(DailyStat.total_hours),
+ )
+ .where(DailyStat.user_id == user.id)
+ .group_by("dow_pg")
+ .order_by("dow_pg")
+ )
+ rows = result.all()
+ # PG dow: 0=Sun, convert to Mon-based
+ pg_to_mon = {0: 6, 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5}
+ dow_map: dict[int, float] = defaultdict(float)
+ for pg_dow, hours in rows:
+ dow_map[pg_to_mon[int(pg_dow)]] += float(hours or 0)
+ return [DowPoint(dow=i, label=DOW_LABELS[i], hours=round(dow_map[i], 2)) for i in range(7)]
+
+
+@router.get("/tools", response_model=list[ToolUsage])
+async def tools_usage(
+ user: CurrentUser,
+ from_date: date | None = Query(default=None, alias="from"),
+ to_date: date | None = Query(default=None, alias="to"),
+ db: AsyncSession = Depends(get_db),
+):
+ from_date, to_date = _date_range(from_date, to_date)
+ result = await db.execute(
+ select(DailyStat.top_tools)
+ .where(
+ DailyStat.user_id == user.id,
+ DailyStat.date >= from_date,
+ DailyStat.date <= to_date,
+ )
+ )
+ combined: dict[str, int] = defaultdict(int)
+ for (tools,) in result.all():
+ for tool, cnt in (tools or {}).items():
+ combined[tool] += cnt
+ return sorted(
+ [ToolUsage(tool=t, count=c) for t, c in combined.items()],
+ key=lambda x: -x.count,
+ )[:15]
+
+
+@router.get("/activity", response_model=list[SessionOut])
+async def activity(
+ user: CurrentUser,
+ activity_date: date | None = Query(default=None, alias="date"),
+ db: AsyncSession = Depends(get_db),
+):
+ if not activity_date:
+ activity_date = date.today()
+ result = await db.execute(
+ select(Session, Project.display_name)
+ .join(Project, Session.project_id == Project.id)
+ .where(Session.user_id == user.id, Session.date == activity_date)
+ .order_by(Session.start_at)
+ )
+ return [
+ SessionOut(
+ id=s.id, session_id=s.session_id, project_id=s.project_id,
+ project_name=name, date=s.date, start_at=s.start_at, end_at=s.end_at,
+ active_hours=s.active_hours, message_count=s.message_count,
+ work_summary=s.work_summary, commits=s.commits or [],
+ tools_used=s.tools_used or {}, files_changed=s.files_changed or [],
+ )
+ for s, name in result.all()
+ ]
+
+
+@router.get("/project/{project_id}", response_model=ProjectDetail)
+async def project_detail(
+ project_id: str,
+ user: CurrentUser,
+ from_date: date | None = Query(default=None, alias="from"),
+ to_date: date | None = Query(default=None, alias="to"),
+ db: AsyncSession = Depends(get_db),
+):
+ project = await db.get(Project, project_id)
+ if not project or project.user_id != user.id:
+ from fastapi import HTTPException
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ from_date, to_date = _date_range(from_date, to_date)
+
+ stats_result = await db.execute(
+ select(DailyStat)
+ .where(
+ DailyStat.user_id == user.id,
+ DailyStat.project_id == project_id,
+ DailyStat.date >= from_date,
+ DailyStat.date <= to_date,
+ )
+ .order_by(DailyStat.date)
+ )
+ stats = stats_result.scalars().all()
+
+ sessions_result = await db.execute(
+ select(Session)
+ .where(
+ Session.user_id == user.id,
+ Session.project_id == project_id,
+ Session.date >= from_date,
+ Session.date <= to_date,
+ )
+ .order_by(Session.start_at.desc())
+ .limit(50)
+ )
+ sessions = sessions_result.scalars().all()
+
+ # Top files
+ files_count: dict[str, int] = defaultdict(int)
+ tools_count: dict[str, int] = defaultdict(int)
+ for s in sessions:
+ for f in (s.files_changed or []):
+ files_count[f] += 1
+ for t, c in (s.tools_used or {}).items():
+ tools_count[t] += c
+
+ return ProjectDetail(
+ project=ProjectOut.model_validate(project),
+ daily=[DailyPoint(date=s.date, hours=round(s.total_hours, 2), sessions=s.session_count) for s in stats],
+ sessions=[
+ SessionOut(
+ id=s.id, session_id=s.session_id, project_id=s.project_id,
+ project_name=project.display_name, date=s.date,
+ start_at=s.start_at, end_at=s.end_at, active_hours=s.active_hours,
+ message_count=s.message_count, work_summary=s.work_summary,
+ commits=s.commits or [], tools_used=s.tools_used or {},
+ files_changed=s.files_changed or [],
+ )
+ for s in sessions
+ ],
+ top_files=sorted([{"file": f, "count": c} for f, c in files_count.items()], key=lambda x: -x["count"])[:10],
+ top_tools=sorted([ToolUsage(tool=t, count=c) for t, c in tools_count.items()], key=lambda x: -x.count)[:10],
+ )
diff --git a/src/routers/events.py b/src/routers/events.py
new file mode 100644
index 0000000..c92f69a
--- /dev/null
+++ b/src/routers/events.py
@@ -0,0 +1,62 @@
+"""SSE endpoint — heartbeat every 25s to survive 30s LB timeout."""
+import asyncio
+import json
+
+from fastapi import APIRouter, Depends, Query, HTTPException, status
+from fastapi.responses import StreamingResponse
+from jose import JWTError, jwt
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.config import settings
+from src.database import get_db
+from src.models import User
+from src.auth import CurrentUser, ALGORITHM
+from src.services.event_bus import bus
+
+router = APIRouter(prefix="/api", tags=["events"])
+
+
+async def _get_user_from_token(
+ token: str = Query(...),
+ db: AsyncSession = Depends(get_db),
+) -> User:
+ """Accept JWT via query param (EventSource can't set headers)."""
+ try:
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
+ if payload.get("type") != "access":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
+ user = await db.get(User, payload["sub"])
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
+ return user
+ except JWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
+
+
+@router.get("/events")
+async def sse_stream(user: User = Depends(_get_user_from_token)):
+ q = bus.subscribe(user.id)
+
+ async def generator():
+ try:
+ while True:
+ try:
+ event = await asyncio.wait_for(q.get(), timeout=25.0)
+ yield f"data: {json.dumps(event)}\n\n"
+ except asyncio.TimeoutError:
+ # Heartbeat — SSE comment, ignored by EventSource
+ yield ": heartbeat\n\n"
+ except asyncio.CancelledError:
+ pass
+ finally:
+ bus.unsubscribe(user.id, q)
+
+ return StreamingResponse(
+ generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no", # disable nginx/Apache buffering
+ "Connection": "keep-alive",
+ },
+ )
diff --git a/src/routers/ingest.py b/src/routers/ingest.py
new file mode 100644
index 0000000..77711ad
--- /dev/null
+++ b/src/routers/ingest.py
@@ -0,0 +1,98 @@
+"""POST /api/ingest — receives session data from cc-collector.py hook."""
+from fastapi import APIRouter, Header, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import verify_api_key
+from src.database import AsyncSessionLocal
+from src.models import Project, Session
+from src.schemas import IngestPayload, IngestResponse
+from src.services.aggregator import recompute_daily_stat
+from src.services.event_bus import bus
+
+router = APIRouter(prefix="/api/ingest", tags=["ingest"])
+
+
+def _slug_to_name(slug: str) -> str:
+ return slug.replace("-", " ").replace("_", " ").title()
+
+
+@router.post("", response_model=IngestResponse)
+async def ingest(
+ body: IngestPayload,
+ x_api_key: str = Header(..., alias="X-API-Key"),
+):
+ async with AsyncSessionLocal() as db:
+ user = await verify_api_key(x_api_key, db)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
+
+ accepted = skipped = 0
+
+ for s in body.sessions:
+ # Get or create project
+ proj_result = await db.execute(
+ select(Project).where(Project.user_id == user.id, Project.slug == s.project_slug)
+ )
+ project = proj_result.scalar_one_or_none()
+ if not project:
+ project = Project(
+ user_id=user.id,
+ slug=s.project_slug,
+ display_name=_slug_to_name(s.project_slug),
+ root_path=body.root_path,
+ )
+ db.add(project)
+ await db.flush()
+
+ # Upsert session (dedup by user_id + session_id + date)
+ stmt = insert(Session).values(
+ user_id=user.id,
+ project_id=project.id,
+ session_id=s.session_id,
+ date=s.date,
+ start_at=s.start_at,
+ end_at=s.end_at,
+ active_hours=s.active_hours,
+ message_count=s.message_count,
+ work_summary=s.work_summary,
+ commits=s.commits,
+ tools_used=s.tools_used,
+ files_changed=s.files_changed,
+ raw_stats=s.raw_stats,
+ ).on_conflict_do_update(
+ constraint="uq_session_user_date",
+ set_=dict(
+ end_at=s.end_at,
+ active_hours=s.active_hours,
+ message_count=s.message_count,
+ work_summary=s.work_summary,
+ commits=s.commits,
+ tools_used=s.tools_used,
+ files_changed=s.files_changed,
+ raw_stats=s.raw_stats,
+ ),
+ )
+ result = await db.execute(stmt)
+ if result.rowcount:
+ accepted += 1
+ else:
+ skipped += 1
+
+ await db.commit()
+
+ # Recompute daily stats and push SSE for each affected (project, date)
+ affected = {(s.project_slug, s.date) for s in body.sessions}
+ for slug, stat_date in affected:
+ proj_result = await db.execute(
+ select(Project).where(Project.user_id == user.id, Project.slug == slug)
+ )
+ project = proj_result.scalar_one_or_none()
+ if project:
+ await recompute_daily_stat(db, user.id, project.id, stat_date, user.daily_overhead_hours)
+
+ # Notify SSE clients
+ await bus.publish(user.id, {"type": "session_update", "accepted": accepted})
+
+ return IngestResponse(accepted=accepted, skipped=skipped)
diff --git a/src/routers/keys.py b/src/routers/keys.py
new file mode 100644
index 0000000..4bc86fa
--- /dev/null
+++ b/src/routers/keys.py
@@ -0,0 +1,41 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import CurrentUser, generate_api_key
+from src.database import get_db
+from src.models import ApiKey
+from src.schemas import ApiKeyCreate, ApiKeyCreated, ApiKeyOut
+
+router = APIRouter(prefix="/api/keys", tags=["keys"])
+
+
+@router.get("", response_model=list[ApiKeyOut])
+async def list_keys(user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(ApiKey).where(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc())
+ )
+ return result.scalars().all()
+
+
+@router.post("", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED)
+async def create_key(body: ApiKeyCreate, user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ raw_key, prefix, key_hash = generate_api_key()
+ key = ApiKey(user_id=user.id, key_hash=key_hash, key_prefix=prefix, label=body.label)
+ db.add(key)
+ await db.commit()
+ await db.refresh(key)
+ return ApiKeyCreated(
+ id=key.id, label=key.label, key_prefix=key.key_prefix,
+ is_active=key.is_active, last_used_at=key.last_used_at,
+ created_at=key.created_at, raw_key=raw_key,
+ )
+
+
+@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def revoke_key(key_id: str, user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ key = await db.get(ApiKey, key_id)
+ if not key or key.user_id != user.id:
+ raise HTTPException(status_code=404, detail="Key not found")
+ key.is_active = False
+ await db.commit()
diff --git a/src/routers/projects.py b/src/routers/projects.py
new file mode 100644
index 0000000..305b62e
--- /dev/null
+++ b/src/routers/projects.py
@@ -0,0 +1,35 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth import CurrentUser
+from src.database import get_db
+from src.models import Project
+from src.schemas import ProjectOut
+
+router = APIRouter(prefix="/api/projects", tags=["projects"])
+
+
+@router.get("", response_model=list[ProjectOut])
+async def list_projects(user: CurrentUser, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(Project).where(Project.user_id == user.id).order_by(Project.display_name)
+ )
+ return result.scalars().all()
+
+
+@router.patch("/{project_id}", response_model=ProjectOut)
+async def update_project(
+ project_id: str,
+ body: dict,
+ user: CurrentUser,
+ db: AsyncSession = Depends(get_db),
+):
+ project = await db.get(Project, project_id)
+ if not project or project.user_id != user.id:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if "display_name" in body:
+ project.display_name = str(body["display_name"])[:255]
+ await db.commit()
+ await db.refresh(project)
+ return project
diff --git a/src/schemas.py b/src/schemas.py
new file mode 100644
index 0000000..edbb1f1
--- /dev/null
+++ b/src/schemas.py
@@ -0,0 +1,194 @@
+from datetime import date, datetime
+from typing import Any
+
+from pydantic import BaseModel, EmailStr, Field
+
+
+# ── Auth ──────────────────────────────────────────────────────────────────────
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+
+
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str
+ new_password: str = Field(min_length=8)
+
+
+# ── Users ─────────────────────────────────────────────────────────────────────
+
+class UserOut(BaseModel):
+ id: str
+ email: str
+ username: str
+ role: str
+ is_active: bool
+ daily_overhead_hours: float
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ username: str = Field(min_length=2, max_length=100)
+ password: str = Field(min_length=8)
+ role: str = Field(default="user", pattern="^(admin|user)$")
+
+
+class UserUpdate(BaseModel):
+ username: str | None = None
+ role: str | None = Field(default=None, pattern="^(admin|user)$")
+ is_active: bool | None = None
+ daily_overhead_hours: float | None = Field(default=None, ge=0, le=12)
+
+
+# ── API Keys ──────────────────────────────────────────────────────────────────
+
+class ApiKeyOut(BaseModel):
+ id: str
+ label: str
+ key_prefix: str
+ is_active: bool
+ last_used_at: datetime | None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class ApiKeyCreate(BaseModel):
+ label: str = Field(default="My Machine", max_length=100)
+
+
+class ApiKeyCreated(ApiKeyOut):
+ raw_key: str # shown once
+
+
+# ── Ingestion ─────────────────────────────────────────────────────────────────
+
+class SessionPayload(BaseModel):
+ session_id: str
+ project_slug: str
+ date: date
+ start_at: datetime
+ end_at: datetime
+ message_count: int = 0
+ active_hours: float = 0.0
+ work_summary: str = ""
+ commits: list[str] = []
+ tools_used: dict[str, int] = {}
+ files_changed: list[str] = []
+ raw_stats: dict[str, Any] = {}
+
+
+class IngestPayload(BaseModel):
+ root_path: str = ""
+ sessions: list[SessionPayload]
+
+
+class IngestResponse(BaseModel):
+ accepted: int
+ skipped: int
+
+
+# ── Dashboard ─────────────────────────────────────────────────────────────────
+
+class KpiSummary(BaseModel):
+ total_hours: float
+ total_projects: int
+ working_days: int
+ total_sessions: int
+ avg_hours_per_day: float
+ top_project: str
+ total_commits: int
+ total_files_changed: int
+ period_from: date | None
+ period_to: date | None
+
+
+class ProjectHours(BaseModel):
+ project_id: str
+ display_name: str
+ total_hours: float
+ session_count: int
+ working_days: int
+ last_active: date | None
+
+
+class DailyPoint(BaseModel):
+ date: date
+ hours: float
+ sessions: int
+
+
+class MonthlyPoint(BaseModel):
+ month: str # "2026-03"
+ hours: float
+
+
+class DowPoint(BaseModel):
+ dow: int # 0=Mon … 6=Sun
+ label: str
+ hours: float
+
+
+class ToolUsage(BaseModel):
+ tool: str
+ count: int
+
+
+class SessionOut(BaseModel):
+ id: str
+ session_id: str
+ project_id: str
+ project_name: str
+ date: date
+ start_at: datetime
+ end_at: datetime
+ active_hours: float
+ message_count: int
+ work_summary: str
+ commits: list[str]
+ tools_used: dict[str, int]
+ files_changed: list[str]
+
+ model_config = {"from_attributes": True}
+
+
+class ProjectDetail(BaseModel):
+ project: "ProjectOut"
+ daily: list[DailyPoint]
+ sessions: list[SessionOut]
+ top_files: list[dict]
+ top_tools: list[ToolUsage]
+
+
+class ProjectOut(BaseModel):
+ id: str
+ slug: str
+ display_name: str
+ root_path: str
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+# ── Admin ─────────────────────────────────────────────────────────────────────
+
+class AdminStats(BaseModel):
+ total_users: int
+ active_users: int
+ total_sessions: int
+ total_hours: float
+ users: list[UserOut]
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/services/aggregator.py b/src/services/aggregator.py
new file mode 100644
index 0000000..f7caeab
--- /dev/null
+++ b/src/services/aggregator.py
@@ -0,0 +1,67 @@
+"""Upsert daily_stats after session ingest."""
+from collections import defaultdict
+from datetime import date
+
+from sqlalchemy import select
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.models import DailyStat, Session
+
+
+async def recompute_daily_stat(
+ db: AsyncSession, user_id: str, project_id: str, stat_date: date,
+ overhead_hours: float = 2.0,
+):
+ """Recalculate daily_stats for (user, project, date) from raw sessions."""
+ result = await db.execute(
+ select(Session).where(
+ Session.user_id == user_id,
+ Session.project_id == project_id,
+ Session.date == stat_date,
+ )
+ )
+ sessions = result.scalars().all()
+
+ if not sessions:
+ return
+
+ total_raw_hours = sum(s.active_hours for s in sessions)
+ session_count = len(sessions)
+ message_count = sum(s.message_count for s in sessions)
+ commit_count = sum(len(s.commits) for s in sessions)
+
+ files: set = set()
+ for s in sessions:
+ files.update(s.files_changed or [])
+
+ tools: dict[str, int] = defaultdict(int)
+ for s in sessions:
+ for tool, cnt in (s.tools_used or {}).items():
+ tools[tool] += cnt
+
+ # Overhead is distributed proportionally at query time (not stored per-project)
+ # Store raw hours here; overhead added in dashboard queries
+ stmt = insert(DailyStat).values(
+ user_id=user_id,
+ project_id=project_id,
+ date=stat_date,
+ total_hours=round(total_raw_hours, 4),
+ session_count=session_count,
+ message_count=message_count,
+ commit_count=commit_count,
+ files_changed_count=len(files),
+ top_tools=dict(tools),
+ ).on_conflict_do_update(
+ constraint="uq_daily_stat",
+ set_=dict(
+ total_hours=round(total_raw_hours, 4),
+ session_count=session_count,
+ message_count=message_count,
+ commit_count=commit_count,
+ files_changed_count=len(files),
+ top_tools=dict(tools),
+ ),
+ )
+ await db.execute(stmt)
+ await db.commit()
diff --git a/src/services/event_bus.py b/src/services/event_bus.py
new file mode 100644
index 0000000..283dd78
--- /dev/null
+++ b/src/services/event_bus.py
@@ -0,0 +1,30 @@
+"""In-memory pub/sub for SSE. One asyncio.Queue per connected client."""
+import asyncio
+from collections import defaultdict
+
+
+class EventBus:
+ def __init__(self):
+ self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list)
+
+ def subscribe(self, user_id: str) -> asyncio.Queue:
+ q: asyncio.Queue = asyncio.Queue(maxsize=50)
+ self._queues[user_id].append(q)
+ return q
+
+ def unsubscribe(self, user_id: str, q: asyncio.Queue):
+ try:
+ self._queues[user_id].remove(q)
+ except ValueError:
+ pass
+
+ async def publish(self, user_id: str, event: dict):
+ """Push event to all queues for a user. Drop if queue full (non-blocking)."""
+ for q in list(self._queues.get(user_id, [])):
+ try:
+ q.put_nowait(event)
+ except asyncio.QueueFull:
+ pass
+
+
+bus = EventBus()
diff --git a/src/static/collector/cc-collector.py b/src/static/collector/cc-collector.py
new file mode 100644
index 0000000..4076782
--- /dev/null
+++ b/src/static/collector/cc-collector.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+"""
+CC Dashboard Collector
+======================
+Reads recent Claude Code session logs from ~/.claude/projects/ and POSTs them
+to the CC Dashboard API. Designed to run as a Claude Code Stop hook.
+
+Setup:
+ 1. Save this file to ~/.claude/cc-collector.py
+ 2. In your Claude Code settings.json, add:
+ "hooks": {
+ "Stop": [{"hooks": [{"type": "command",
+ "command": "CC_API_KEY=cc_YOUR_KEY CC_SERVER=https://your-server/cc-dashboard python3 ~/.claude/cc-collector.py 2>/dev/null || true",
+ "async": true, "statusMessage": "Syncing to CC Dashboard…"}]}]
+ }
+
+Environment variables:
+ CC_API_KEY — your API key from CC Dashboard
+ CC_SERVER — full base URL, e.g. https://optical-dev.oliver.solutions/cc-dashboard
+ CC_ROOT_PATH — (optional) label for your projects root dir
+ CC_LOOKBACK — how many hours back to scan (default: 2)
+"""
+import json
+import os
+import re
+import subprocess
+from collections import defaultdict
+from datetime import datetime, timezone, timedelta
+from pathlib import Path
+
+
+# ── Config ────────────────────────────────────────────────────────────────────
+API_KEY = os.environ.get("CC_API_KEY", "")
+SERVER = os.environ.get("CC_SERVER", "https://optical-dev.oliver.solutions/cc-dashboard").rstrip("/")
+ROOT_PATH = os.environ.get("CC_ROOT_PATH", str(Path.home()))
+LOOKBACK_HOURS = int(os.environ.get("CC_LOOKBACK", "2"))
+
+CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
+STATE_FILE = Path.home() / ".claude" / ".cc-collector-state.json"
+
+# Tools that indicate meaningful work (skip internal/meta tools)
+SKIP_TOOLS = {"ExitPlanMode", "EnterPlanMode", "TodoWrite", "TodoRead",
+ "TaskCreate", "TaskUpdate", "TaskGet", "TaskList"}
+
+
+# ── State ─────────────────────────────────────────────────────────────────────
+def _load_state() -> dict:
+ if STATE_FILE.exists():
+ try:
+ return json.loads(STATE_FILE.read_text())
+ except Exception:
+ pass
+ return {}
+
+
+def _save_state(state: dict):
+ STATE_FILE.write_text(json.dumps(state, default=str))
+
+
+# ── Session parsing ───────────────────────────────────────────────────────────
+def _active_hours(timestamps: list, gap_minutes: int = 60) -> float:
+ if not timestamps:
+ return 0.25
+ if len(timestamps) < 2:
+ return 0.5
+ total = 0.0
+ gap_sec = gap_minutes * 60
+ for i in range(1, len(timestamps)):
+ diff = (timestamps[i] - timestamps[i - 1]).total_seconds()
+ if diff <= gap_sec:
+ total += diff
+ total += 15 * 60 # 15min overhead per session
+ return max(total / 3600, 0.25)
+
+
+def _extract_work(messages: list) -> dict:
+ agent_tasks, files_changed, bash_ops = [], set(), []
+ seen_agents = set()
+
+ for obj in messages:
+ msg = obj.get("message", {})
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
+ continue
+ content = msg.get("content", [])
+ if not isinstance(content, list):
+ continue
+ for block in content:
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
+ continue
+ name = block.get("name", "")
+ inp = block.get("input", {})
+ if name in SKIP_TOOLS:
+ continue
+ if name == "Agent":
+ desc = inp.get("description", "").strip()
+ if desc and desc not in seen_agents:
+ agent_tasks.append(desc)
+ seen_agents.add(desc)
+ elif name in ("Write", "Edit", "NotebookEdit"):
+ fp = inp.get("file_path", "")
+ if fp and not fp.endswith(".md"):
+ files_changed.add(fp.split("/")[-1])
+ elif name == "Bash":
+ cmd = inp.get("command", "").strip()
+ if any(cmd.startswith(p) for p in
+ ("git commit", "git push", "npm run", "npm install",
+ "docker", "python", "pytest", "uv ")):
+ short = cmd.split("\n")[0][:80]
+ if short not in bash_ops:
+ bash_ops.append(short)
+
+ return {
+ "agent_tasks": agent_tasks[:6],
+ "files_changed": sorted(files_changed)[:10],
+ "bash_ops": bash_ops[:4],
+ }
+
+
+def _tool_counts(messages: list) -> dict:
+ counts = defaultdict(int)
+ for obj in messages:
+ msg = obj.get("message", {})
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
+ continue
+ for block in (msg.get("content") or []):
+ if isinstance(block, dict) and block.get("type") == "tool_use":
+ name = block.get("name", "")
+ if name and name not in SKIP_TOOLS:
+ counts[name] += 1
+ return dict(counts)
+
+
+def _git_commits(repo_path: Path, start: datetime, end: datetime) -> list:
+ try:
+ since = start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ until = end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ result = subprocess.run(
+ ["git", "-C", str(repo_path), "log",
+ f"--after={since}", f"--before={until}",
+ "--pretty=format:%s", "--no-merges"],
+ capture_output=True, text=True, timeout=5,
+ )
+ return [l.strip() for l in result.stdout.splitlines() if l.strip()]
+ except Exception:
+ return []
+
+
+def _build_summary(commits: list, work: dict) -> str:
+ if commits:
+ return "\n".join(f"• {c}" for c in commits[:8])
+ parts = [f"• {t}" for t in work["agent_tasks"]]
+ if work["files_changed"]:
+ parts.append("Files: " + ", ".join(work["files_changed"]))
+ return "\n".join(parts) if parts else "—"
+
+
+def _slug_to_name(folder_name: str) -> str:
+ # Strip common path prefixes like "-Volumes-SSD-Projects-Oliver-"
+ # Keep only the last segment
+ parts = folder_name.split("-")
+ # Find where the actual project slug starts (heuristic: last non-empty segment after common prefix)
+ return folder_name.replace("-", " ").replace("_", " ").title().strip()
+
+
+# ── Main scan ─────────────────────────────────────────────────────────────────
+def collect() -> list:
+ if not CLAUDE_PROJECTS.exists():
+ return []
+
+ state = _load_state()
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=LOOKBACK_HOURS)
+ sessions_to_send = []
+
+ for folder in sorted(CLAUDE_PROJECTS.iterdir()):
+ if not folder.is_dir():
+ continue
+ folder_key = folder.name
+
+ # Infer project slug: strip machine path prefix, use last component
+ slug = _infer_slug(folder_key)
+
+ # Raw sessions grouped by session_id
+ raw_sessions: dict = defaultdict(lambda: {"timestamps": [], "messages": []})
+
+ for jf in sorted(folder.glob("*.jsonl")):
+ # Skip if file hasn't been modified since last run + cutoff
+ mtime = datetime.fromtimestamp(jf.stat().st_mtime, tz=timezone.utc)
+ last_sync = state.get(str(jf))
+ if last_sync and mtime <= datetime.fromisoformat(last_sync):
+ continue
+
+ try:
+ with open(jf, encoding="utf-8", errors="ignore") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ obj = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ ts = obj.get("timestamp")
+ sid = obj.get("sessionId")
+ if not ts or not sid:
+ continue
+ try:
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
+ except ValueError:
+ continue
+ if dt < cutoff:
+ continue
+ raw_sessions[sid]["timestamps"].append(dt)
+ raw_sessions[sid]["messages"].append(obj)
+ except Exception:
+ continue
+
+ # Update state
+ state[str(jf)] = datetime.now(timezone.utc).isoformat()
+
+ # Try to find repo root
+ # Derive repo path from folder name by replacing dashes with slashes after common prefix
+ repo_path = _infer_repo_path(folder_key)
+
+ for sid, data in raw_sessions.items():
+ if not data["timestamps"]:
+ continue
+
+ paired = sorted(zip(data["timestamps"], data["messages"]), key=lambda x: x[0])
+
+ # Split by calendar day
+ day_buckets: dict = defaultdict(lambda: {"timestamps": [], "messages": []})
+ for dt, obj in paired:
+ day_buckets[dt.strftime("%Y-%m-%d")]["timestamps"].append(dt)
+ day_buckets[dt.strftime("%Y-%m-%d")]["messages"].append(obj)
+
+ for date_str, bucket in day_buckets.items():
+ ts_sorted = bucket["timestamps"]
+ start = ts_sorted[0]
+ end = ts_sorted[-1]
+ hours = _active_hours(ts_sorted)
+ work = _extract_work(bucket["messages"])
+ tools = _tool_counts(bucket["messages"])
+ commits = _git_commits(repo_path, start, end) if (repo_path and repo_path.exists()) else []
+ summary = _build_summary(commits, work)
+
+ sessions_to_send.append({
+ "session_id": sid,
+ "project_slug": slug,
+ "date": date_str,
+ "start_at": start.isoformat(),
+ "end_at": end.isoformat(),
+ "message_count": len(ts_sorted),
+ "active_hours": round(hours, 4),
+ "work_summary": summary,
+ "commits": commits,
+ "tools_used": tools,
+ "files_changed": work["files_changed"],
+ "raw_stats": {},
+ })
+
+ _save_state(state)
+ return sessions_to_send
+
+
+def _infer_slug(folder_name: str) -> str:
+ """Convert folder name like '-Volumes-SSD-Projects-Oliver-semblance' → 'semblance'."""
+ # Remove leading dashes
+ name = folder_name.lstrip("-")
+ # Split on dashes, take last meaningful segment
+ parts = name.split("-")
+ if len(parts) >= 1:
+ return parts[-1] or name
+ return name
+
+
+def _infer_repo_path(folder_name: str) -> Path | None:
+ """Convert folder name to filesystem path by replacing '-' with '/' (heuristic)."""
+ try:
+ path_str = "/" + folder_name.lstrip("-").replace("-", "/")
+ # Walk up from the inferred path until we find a directory that exists
+ p = Path(path_str)
+ while p != p.parent:
+ if p.exists() and p.is_dir():
+ return p
+ p = p.parent
+ except Exception:
+ pass
+ return None
+
+
+# ── Send ──────────────────────────────────────────────────────────────────────
+def send(sessions: list):
+ if not sessions:
+ return
+ payload = json.dumps({
+ "root_path": ROOT_PATH,
+ "sessions": sessions,
+ })
+
+ import urllib.request
+ req = urllib.request.Request(
+ f"{SERVER}/api/ingest",
+ data=payload.encode(),
+ headers={
+ "Content-Type": "application/json",
+ "X-API-Key": API_KEY,
+ },
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ result = json.loads(resp.read())
+ except Exception as e:
+ pass # silent fail — hook must not block Claude
+
+
+if __name__ == "__main__":
+ if not API_KEY:
+ raise SystemExit("CC_API_KEY not set")
+ sessions = collect()
+ if sessions:
+ send(sessions)
diff --git a/src/static/css/app.css b/src/static/css/app.css
new file mode 100644
index 0000000..d5f4288
--- /dev/null
+++ b/src/static/css/app.css
@@ -0,0 +1,448 @@
+@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap');
+
+:root {
+ --accent: #FFC407;
+ --accent-dark: #e6af00;
+ --bg: #0f0f0f;
+ --surface: #1a1a1a;
+ --surface2: #242424;
+ --border: #2e2e2e;
+ --text: #f0f0f0;
+ --text-muted: #888;
+ --danger: #ef4444;
+ --success: #22c55e;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: 'Montserrat', sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+}
+
+/* ── Layout ────────────────────────────────────────── */
+#app { display: flex; height: 100vh; overflow: hidden; }
+
+.sidebar {
+ width: 220px;
+ min-width: 220px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+}
+
+.sidebar-logo {
+ padding: 24px 20px 20px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-logo span {
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.5px;
+}
+
+.sidebar-logo .accent { color: var(--accent); }
+
+.sidebar-nav {
+ flex: 1;
+ padding: 12px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-muted);
+ transition: all .15s;
+ text-decoration: none;
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+}
+
+.nav-item:hover { background: var(--surface2); color: var(--text); }
+.nav-item.active { background: var(--accent); color: #000; font-weight: 700; }
+.nav-item .icon { font-size: 16px; width: 20px; text-align: center; }
+
+.sidebar-bottom {
+ padding: 12px 8px;
+ border-top: 1px solid var(--border);
+}
+
+.sidebar-user {
+ padding: 10px 12px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.sidebar-user strong { display: block; color: var(--text); font-size: 13px; margin-bottom: 2px; }
+
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.topbar {
+ height: 56px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 24px;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.topbar h1 { font-size: 16px; font-weight: 700; flex: 1; }
+
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+/* ── Date range picker ─────────────────────────────── */
+.date-range {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.date-range input[type="date"] {
+ background: none;
+ border: none;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ outline: none;
+}
+
+.date-range input[type="date"]::-webkit-calendar-picker-indicator {
+ filter: invert(0.7);
+}
+
+/* ── KPI Cards ─────────────────────────────────────── */
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.kpi-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+ transition: border-color .15s;
+}
+
+.kpi-card:hover { border-color: var(--accent); }
+.kpi-card .label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 8px; }
+.kpi-card .value { font-size: 28px; font-weight: 800; color: var(--text); line-height: 1; }
+.kpi-card .value.accent { color: var(--accent); }
+.kpi-card .sub { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
+
+/* ── Chart grid ────────────────────────────────────── */
+.chart-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.chart-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+}
+
+.chart-card.wide { grid-column: 1 / -1; }
+
+.chart-card h3 {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ margin-bottom: 16px;
+}
+
+.chart-wrap { position: relative; height: 260px; }
+.chart-wrap.tall { height: 320px; }
+
+/* ── Tables ────────────────────────────────────────── */
+.table-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+ margin-bottom: 24px;
+}
+
+.table-card h3 {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+}
+
+table { width: 100%; border-collapse: collapse; font-size: 13px; }
+
+th {
+ padding: 10px 16px;
+ text-align: left;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ background: var(--surface2);
+ border-bottom: 1px solid var(--border);
+}
+
+td {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text);
+}
+
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: var(--surface2); }
+
+td .badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.badge-accent { background: var(--accent); color: #000; }
+.badge-muted { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); }
+.badge-success { background: rgba(34,197,94,.15); color: var(--success); }
+.badge-danger { background: rgba(239,68,68,.15); color: var(--danger); }
+
+/* ── Buttons ───────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all .15s;
+ border: none;
+ text-decoration: none;
+}
+
+.btn-primary { background: var(--accent); color: #000; }
+.btn-primary:hover { background: var(--accent-dark); }
+.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
+.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
+.btn-danger { background: rgba(239,68,68,.15); color: var(--danger); border: 1px solid rgba(239,68,68,.3); }
+.btn-danger:hover { background: var(--danger); color: #fff; }
+.btn-sm { padding: 5px 10px; font-size: 12px; }
+
+/* ── Forms ─────────────────────────────────────────── */
+.form-group { margin-bottom: 16px; }
+.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; }
+
+input[type="text"],
+input[type="email"],
+input[type="password"],
+select {
+ width: 100%;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 14px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 13px;
+ outline: none;
+ transition: border-color .15s;
+}
+
+input:focus, select:focus { border-color: var(--accent); }
+
+/* ── Login ─────────────────────────────────────────── */
+#login-page {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background: var(--bg);
+}
+
+.login-box {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 40px;
+ width: 360px;
+}
+
+.login-box .logo {
+ font-size: 22px;
+ font-weight: 800;
+ text-align: center;
+ margin-bottom: 32px;
+}
+
+.login-box .logo .accent { color: var(--accent); }
+
+.error-msg {
+ background: rgba(239,68,68,.12);
+ border: 1px solid rgba(239,68,68,.3);
+ color: var(--danger);
+ border-radius: 8px;
+ padding: 10px 14px;
+ font-size: 13px;
+ margin-bottom: 16px;
+ display: none;
+}
+
+/* ── Live feed ─────────────────────────────────────── */
+.feed-item {
+ display: flex;
+ gap: 12px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+ animation: fadeIn .3s ease;
+}
+
+@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
+
+.feed-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ margin-top: 5px;
+ flex-shrink: 0;
+}
+
+.feed-content { flex: 1; }
+.feed-content .project { font-size: 13px; font-weight: 600; }
+.feed-content .summary { font-size: 12px; color: var(--text-muted); margin-top: 2px; white-space: pre-wrap; }
+.feed-time { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
+
+/* ── Modals ────────────────────────────────────────── */
+.modal-overlay {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,.7);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 1000;
+ display: none;
+}
+
+.modal-overlay.open { display: flex; }
+
+.modal {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 32px;
+ width: 460px;
+ max-width: 95vw;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.modal h2 { font-size: 16px; font-weight: 700; margin-bottom: 24px; }
+
+/* ── SSE indicator ─────────────────────────────────── */
+.sse-dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ display: inline-block;
+ transition: background .3s;
+}
+
+.sse-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); }
+.sse-dot.error { background: var(--danger); }
+
+/* ── Code block (for hook setup) ──────────────────── */
+.code-block {
+ background: #0a0a0a;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 14px 16px;
+ font-family: 'Courier New', monospace;
+ font-size: 12px;
+ color: #aef;
+ white-space: pre-wrap;
+ word-break: break-all;
+ position: relative;
+}
+
+.copy-btn {
+ position: absolute;
+ top: 8px; right: 8px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 11px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.copy-btn:hover { color: var(--accent); border-color: var(--accent); }
+
+/* ── Scrollbar ─────────────────────────────────────── */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
+
+/* ── Misc ──────────────────────────────────────────── */
+.loading { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; }
+.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
+.empty .big { font-size: 40px; margin-bottom: 12px; }
+.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
+.section-header h2 { font-size: 15px; font-weight: 700; }
+.tag { display: inline-block; background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 7px; font-size: 11px; color: var(--text-muted); margin: 2px; }
+
+/* ── Progress bar ──────────────────────────────────── */
+.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; }
+.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .3s; }
+
+/* ── Page transitions ──────────────────────────────── */
+.page { animation: pageIn .2s ease; }
+@keyframes pageIn { from { opacity: 0; } to { opacity: 1; } }
diff --git a/src/static/index.html b/src/static/index.html
new file mode 100644
index 0000000..67c5018
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ CC Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/static/js/api.js b/src/static/js/api.js
new file mode 100644
index 0000000..73aaa53
--- /dev/null
+++ b/src/static/js/api.js
@@ -0,0 +1,131 @@
+/**
+ * Fetch wrapper with JWT auth + auto-refresh.
+ */
+const BASE = window.CC_BASE || '';
+
+const Api = (() => {
+ let _accessToken = localStorage.getItem('cc_access') || '';
+ let _refreshToken = localStorage.getItem('cc_refresh') || '';
+ let _refreshing = null;
+
+ function setTokens(access, refresh) {
+ _accessToken = access;
+ _refreshToken = refresh;
+ localStorage.setItem('cc_access', access);
+ localStorage.setItem('cc_refresh', refresh);
+ }
+
+ function clearTokens() {
+ _accessToken = _refreshToken = '';
+ localStorage.removeItem('cc_access');
+ localStorage.removeItem('cc_refresh');
+ }
+
+ async function _doRefresh() {
+ const res = await fetch(`${BASE}/api/auth/refresh`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: _refreshToken }),
+ });
+ if (!res.ok) throw new Error('Refresh failed');
+ const data = await res.json();
+ setTokens(data.access_token, data.refresh_token);
+ }
+
+ async function request(path, opts = {}) {
+ if (!opts.headers) opts.headers = {};
+ if (_accessToken) opts.headers['Authorization'] = `Bearer ${_accessToken}`;
+
+ let res = await fetch(`${BASE}${path}`, opts);
+
+ if (res.status === 401 && _refreshToken) {
+ if (!_refreshing) _refreshing = _doRefresh().finally(() => (_refreshing = null));
+ try {
+ await _refreshing;
+ opts.headers['Authorization'] = `Bearer ${_accessToken}`;
+ res = await fetch(`${BASE}${path}`, opts);
+ } catch {
+ clearTokens();
+ window.location.reload();
+ return;
+ }
+ }
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
+ throw new Error(err.detail || 'Request failed');
+ }
+
+ if (res.status === 204) return null;
+ return res.json();
+ }
+
+ function get(path, params) {
+ let url = path;
+ if (params) {
+ const q = new URLSearchParams(
+ Object.entries(params).filter(([, v]) => v !== null && v !== undefined)
+ ).toString();
+ if (q) url += '?' + q;
+ }
+ return request(url);
+ }
+
+ function post(path, body) {
+ return request(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ }
+
+ function put(path, body) {
+ return request(path, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ }
+
+ function patch(path, body) {
+ return request(path, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ }
+
+ function del(path) {
+ return request(path, { method: 'DELETE' });
+ }
+
+ async function login(email, password) {
+ const res = await fetch(`${BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: 'Login failed' }));
+ throw new Error(err.detail);
+ }
+ const data = await res.json();
+ setTokens(data.access_token, data.refresh_token);
+ return data;
+ }
+
+ function logout() {
+ clearTokens();
+ window.location.reload();
+ }
+
+ function isLoggedIn() {
+ return !!_accessToken;
+ }
+
+ function getAccessToken() {
+ return _accessToken;
+ }
+
+ return { get, post, put, patch, del, login, logout, isLoggedIn, getAccessToken, setTokens };
+})();
diff --git a/src/static/js/app.js b/src/static/js/app.js
new file mode 100644
index 0000000..b2c7fc6
--- /dev/null
+++ b/src/static/js/app.js
@@ -0,0 +1,159 @@
+/**
+ * SPA Router & app init.
+ */
+window.CC_BASE = '/cc-dashboard';
+
+const App = (() => {
+ let _currentUser = null;
+
+ const NAV = [
+ { id: 'dashboard', label: 'Dashboard', icon: '◈', page: DashboardPage },
+ { id: 'projects', label: 'Projects', icon: '▤', page: ProjectsPage },
+ { id: 'live', label: 'Live Feed', icon: '⚡', page: LivePage },
+ { id: 'keys', label: 'API Keys', icon: '⌘', page: KeysPage },
+ { id: 'settings', label: 'Settings', icon: '⚙', page: SettingsPage },
+ ];
+
+ async function init() {
+ if (!Api.isLoggedIn()) {
+ renderLogin();
+ return;
+ }
+
+ try {
+ _currentUser = await Api.get('/api/auth/me');
+ } catch {
+ renderLogin();
+ return;
+ }
+
+ renderShell();
+ SSE.connect();
+
+ // Route from hash
+ const hash = location.hash.replace('#', '') || 'dashboard';
+ const [page, param] = hash.split('/');
+ navigate(page, param);
+ }
+
+ function renderLogin() {
+ document.getElementById('app').innerHTML = `
+
+
+
CC.Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const doLogin = async () => {
+ const email = document.getElementById('login-email').value.trim();
+ const password = document.getElementById('login-password').value;
+ const errEl = document.getElementById('login-error');
+ errEl.style.display = 'none';
+ try {
+ await Api.login(email, password);
+ init();
+ } catch (e) {
+ errEl.textContent = e.message;
+ errEl.style.display = 'block';
+ }
+ };
+
+ document.getElementById('btn-login').onclick = doLogin;
+ document.getElementById('login-password').addEventListener('keydown', e => {
+ if (e.key === 'Enter') doLogin();
+ });
+ }
+
+ function renderShell() {
+ const isAdmin = _currentUser?.role === 'admin';
+ const navItems = [...NAV, ...(isAdmin ? [{ id: 'admin', label: 'Admin', icon: '⚀', page: AdminPage }] : [])];
+
+ document.getElementById('app').innerHTML = `
+
+
+ `;
+
+ SSE.setDot(document.getElementById('sse-dot'));
+ }
+
+ function navigate(pageId, param) {
+ const isAdmin = _currentUser?.role === 'admin';
+ const allPages = { dashboard: DashboardPage, projects: ProjectsPage, live: LivePage, keys: KeysPage, settings: SettingsPage };
+ if (isAdmin) allPages.admin = AdminPage;
+ // Special: project detail
+ if (pageId === 'project' && param) {
+ location.hash = `project/${param}`;
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ document.getElementById('page-title').textContent = 'Project Detail';
+ _setActiveNav(null);
+ ProjectDetailPage.render(content, param);
+ return;
+ }
+
+ const page = allPages[pageId] || DashboardPage;
+ const navDef = [...NAV, { id: 'admin' }].find(n => n.id === pageId) || NAV[0];
+
+ location.hash = pageId;
+ const content = document.getElementById('page-content');
+ if (!content) return;
+
+ document.getElementById('page-title').textContent =
+ NAV.find(n => n.id === pageId)?.label || (pageId === 'admin' ? 'Admin' : 'Dashboard');
+
+ _setActiveNav(pageId);
+ page.render(content, param);
+ }
+
+ function _setActiveNav(pageId) {
+ document.querySelectorAll('.nav-item').forEach(el => {
+ el.classList.toggle('active', el.dataset.page === pageId);
+ });
+ }
+
+ return { init, navigate };
+})();
+
+document.addEventListener('DOMContentLoaded', () => App.init());
+window.addEventListener('hashchange', () => {
+ const hash = location.hash.replace('#', '') || 'dashboard';
+ const [page, param] = hash.split('/');
+ App.navigate(page, param);
+});
diff --git a/src/static/js/charts.js b/src/static/js/charts.js
new file mode 100644
index 0000000..c3fb24f
--- /dev/null
+++ b/src/static/js/charts.js
@@ -0,0 +1,234 @@
+/**
+ * Chart.js configuration & factory.
+ */
+const ACCENT = '#FFC407';
+const GRID_COLOR = 'rgba(255,255,255,0.06)';
+const TEXT_COLOR = '#888';
+const FONT = 'Montserrat';
+
+Chart.defaults.color = TEXT_COLOR;
+Chart.defaults.font.family = FONT;
+Chart.defaults.font.size = 11;
+
+const ChartDefs = {
+ base() {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: { duration: 400 },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: '#1a1a1a',
+ borderColor: '#2e2e2e',
+ borderWidth: 1,
+ titleColor: '#f0f0f0',
+ bodyColor: '#888',
+ padding: 10,
+ titleFont: { family: FONT, weight: '700', size: 12 },
+ bodyFont: { family: FONT, size: 11 },
+ },
+ },
+ };
+ },
+
+ hoursBar(labels, data) {
+ return {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ backgroundColor: ACCENT,
+ borderRadius: 6,
+ barThickness: 18,
+ }],
+ },
+ options: {
+ ...this.base(),
+ indexAxis: 'y',
+ scales: {
+ x: {
+ grid: { color: GRID_COLOR },
+ ticks: { callback: v => v + 'h' },
+ },
+ y: { grid: { display: false } },
+ },
+ plugins: {
+ ...this.base().plugins,
+ tooltip: {
+ ...this.base().plugins.tooltip,
+ callbacks: { label: ctx => ` ${ctx.parsed.x.toFixed(1)}h` },
+ },
+ },
+ },
+ };
+ },
+
+ donut(labels, data) {
+ const colors = [ACCENT, '#fff176', '#e6af00', '#ffd740', '#fff9c4', '#ffb300', '#ffe082', '#ffca28'];
+ return {
+ type: 'doughnut',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ backgroundColor: colors.slice(0, data.length),
+ borderWidth: 0,
+ hoverOffset: 8,
+ }],
+ },
+ options: {
+ ...this.base(),
+ cutout: '68%',
+ plugins: {
+ ...this.base().plugins,
+ legend: {
+ display: true,
+ position: 'right',
+ labels: {
+ color: TEXT_COLOR,
+ font: { family: FONT, size: 11 },
+ boxWidth: 10,
+ padding: 12,
+ },
+ },
+ tooltip: {
+ ...this.base().plugins.tooltip,
+ callbacks: {
+ label: ctx => {
+ const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
+ const pct = total ? ((ctx.parsed / total) * 100).toFixed(1) : 0;
+ return ` ${ctx.parsed.toFixed(1)}h (${pct}%)`;
+ },
+ },
+ },
+ },
+ },
+ };
+ },
+
+ lineArea(labels, data, label = 'Hours') {
+ return {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [{
+ label,
+ data,
+ borderColor: ACCENT,
+ backgroundColor: 'rgba(255,196,7,0.12)',
+ fill: true,
+ tension: 0.35,
+ pointRadius: 3,
+ pointBackgroundColor: ACCENT,
+ borderWidth: 2,
+ }],
+ },
+ options: {
+ ...this.base(),
+ scales: {
+ x: { grid: { color: GRID_COLOR } },
+ y: {
+ grid: { color: GRID_COLOR },
+ ticks: { callback: v => v + 'h' },
+ },
+ },
+ plugins: {
+ ...this.base().plugins,
+ tooltip: {
+ ...this.base().plugins.tooltip,
+ callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` },
+ },
+ },
+ },
+ };
+ },
+
+ columnBar(labels, data) {
+ return {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ backgroundColor: ACCENT,
+ borderRadius: 6,
+ }],
+ },
+ options: {
+ ...this.base(),
+ scales: {
+ x: { grid: { display: false } },
+ y: {
+ grid: { color: GRID_COLOR },
+ ticks: { callback: v => v + 'h' },
+ },
+ },
+ plugins: {
+ ...this.base().plugins,
+ tooltip: {
+ ...this.base().plugins.tooltip,
+ callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` },
+ },
+ },
+ },
+ };
+ },
+
+ timeline(sessions) {
+ // Gantt-like bars: each session = one horizontal bar
+ const labels = sessions.map(s => s.project_name.substring(0, 20));
+ const starts = sessions.map(s => new Date(s.start_at).getTime());
+ const ends = sessions.map(s => new Date(s.end_at).getTime());
+ const data = sessions.map((s, i) => [starts[i], ends[i]]);
+
+ return {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ backgroundColor: ACCENT + 'cc',
+ borderSkipped: false,
+ borderRadius: 4,
+ barThickness: 16,
+ }],
+ },
+ options: {
+ ...this.base(),
+ indexAxis: 'y',
+ scales: {
+ x: {
+ type: 'time',
+ time: { unit: 'hour', displayFormats: { hour: 'HH:mm' } },
+ grid: { color: GRID_COLOR },
+ },
+ y: { grid: { display: false } },
+ },
+ plugins: {
+ ...this.base().plugins,
+ tooltip: {
+ ...this.base().plugins.tooltip,
+ callbacks: {
+ label: ctx => {
+ const s = sessions[ctx.dataIndex];
+ const from = new Date(s.start_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
+ const to = new Date(s.end_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
+ return ` ${from} – ${to} (${s.active_hours.toFixed(1)}h)`;
+ },
+ },
+ },
+ },
+ },
+ };
+ },
+};
+
+function makeChart(canvasId, config) {
+ const canvas = document.getElementById(canvasId);
+ if (!canvas) return null;
+ const existing = Chart.getChart(canvas);
+ if (existing) existing.destroy();
+ return new Chart(canvas, config);
+}
diff --git a/src/static/js/pages/admin.js b/src/static/js/pages/admin.js
new file mode 100644
index 0000000..077ddda
--- /dev/null
+++ b/src/static/js/pages/admin.js
@@ -0,0 +1,110 @@
+const AdminPage = (() => {
+ async function render(container) {
+ container.innerHTML = ``;
+ try {
+ const stats = await Api.get('/api/admin/stats');
+ container.innerHTML = `
+
+
+
+
+
Total Users
${stats.total_users}
+
Active Users
${stats.active_users}
+
Total Sessions
${stats.total_sessions}
+
Total Hours
${stats.total_hours.toFixed(1)}h
+
+
+
+
Users
+
+
+ | User | Email | Role | Status | Joined | |
+
+
+ ${stats.users.map(u => _userRow(u)).join('')}
+
+
+
+
+
+
+
+
+
Create User
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ document.getElementById('btn-new-user').onclick = () =>
+ document.getElementById('new-user-modal').classList.add('open');
+
+ document.getElementById('btn-cancel-user').onclick = () =>
+ document.getElementById('new-user-modal').classList.remove('open');
+
+ document.getElementById('btn-save-user').onclick = async () => {
+ const errEl = document.getElementById('nu-error');
+ errEl.style.display = 'none';
+ try {
+ await Api.post('/api/admin/users', {
+ email: document.getElementById('nu-email').value,
+ username: document.getElementById('nu-username').value,
+ password: document.getElementById('nu-password').value,
+ role: document.getElementById('nu-role').value,
+ });
+ document.getElementById('new-user-modal').classList.remove('open');
+ render(container);
+ } catch (e) {
+ errEl.textContent = e.message;
+ errEl.style.display = 'block';
+ }
+ };
+
+ // Toggle active
+ container.querySelectorAll('[data-toggle]').forEach(btn => {
+ btn.onclick = async () => {
+ const uid = btn.dataset.toggle;
+ const active = btn.dataset.active === 'true';
+ if (!confirm(`${active ? 'Deactivate' : 'Activate'} this user?`)) return;
+ try {
+ await Api.put(`/api/admin/users/${uid}`, { is_active: !active });
+ render(container);
+ } catch (e) { alert(e.message); }
+ };
+ });
+
+ } catch (e) {
+ container.innerHTML = ``;
+ }
+ }
+
+ function _userRow(u) {
+ return `
+ | ${u.username} |
+ ${u.email} |
+ ${u.role === 'admin' ? 'Admin' : 'User'} |
+ ${u.is_active ? 'Active' : 'Inactive'} |
+ ${new Date(u.created_at).toLocaleDateString()} |
+ |
+
`;
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/pages/dashboard.js b/src/static/js/pages/dashboard.js
new file mode 100644
index 0000000..2092daf
--- /dev/null
+++ b/src/static/js/pages/dashboard.js
@@ -0,0 +1,172 @@
+const DashboardPage = (() => {
+ let _from = null, _to = null;
+
+ function _fmtDate(d) {
+ return new Date(d).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' });
+ }
+
+ async function render(container) {
+ const today = new Date().toISOString().split('T')[0];
+ const d30ago = new Date(Date.now() - 29 * 86400000).toISOString().split('T')[0];
+ if (!_from) _from = d30ago;
+ if (!_to) _to = today;
+
+ container.innerHTML = `
+
+
+
+
+ ${[...Array(8)].map(() => `
`).join('')}
+
+
+
+
+
+
+
Daily Activity (last 30 days)
+
+
+
+
+
+
+
+
+ `;
+
+ document.getElementById('dash-apply').addEventListener('click', () => {
+ _from = document.getElementById('dash-from').value;
+ _to = document.getElementById('dash-to').value;
+ loadData(container);
+ });
+
+ loadData(container);
+
+ // Listen for SSE updates
+ SSE.on('session_update', () => loadData(container));
+ }
+
+ async function loadData(container) {
+ const params = { from: _from, to: _to };
+
+ try {
+ const [summary, projects, timeline, monthly, dow, tools, activity] = await Promise.all([
+ Api.get('/api/dashboard/summary', params),
+ Api.get('/api/dashboard/projects', params),
+ Api.get('/api/dashboard/timeline', { days: 30 }),
+ Api.get('/api/dashboard/monthly'),
+ Api.get('/api/dashboard/dow'),
+ Api.get('/api/dashboard/tools', params),
+ Api.get('/api/dashboard/activity'),
+ ]);
+
+ renderKpis(summary);
+ renderCharts(projects, timeline, monthly, dow, tools, activity);
+ } catch (e) {
+ console.error('Dashboard load error:', e);
+ }
+ }
+
+ function renderKpis(s) {
+ const grid = document.getElementById('kpi-grid');
+ if (!grid) return;
+ const cards = [
+ { label: 'Total Hours', value: s.total_hours.toFixed(1) + 'h', accent: true },
+ { label: 'Projects', value: s.total_projects },
+ { label: 'Working Days', value: s.working_days },
+ { label: 'Sessions', value: s.total_sessions },
+ { label: 'Avg / Day', value: s.avg_hours_per_day.toFixed(1) + 'h' },
+ { label: 'Top Project', value: s.top_project, sub: '' },
+ { label: 'Commits', value: s.total_commits },
+ { label: 'Files Changed', value: s.total_files_changed },
+ ];
+ grid.innerHTML = cards.map(c => `
+
+
${c.label}
+
${c.value}
+ ${c.sub !== undefined ? `
${c.sub}
` : ''}
+
+ `).join('');
+ }
+
+ function renderCharts(projects, timeline, monthly, dow, tools, activity) {
+ // Hours by project (top 12)
+ const top12 = projects.slice(0, 12);
+ makeChart('chart-proj-bar', ChartDefs.hoursBar(
+ top12.map(p => p.display_name),
+ top12.map(p => p.total_hours),
+ ));
+
+ // Donut
+ const top8 = projects.slice(0, 8);
+ makeChart('chart-donut', ChartDefs.donut(
+ top8.map(p => p.display_name),
+ top8.map(p => p.total_hours),
+ ));
+
+ // Daily line
+ makeChart('chart-daily', ChartDefs.lineArea(
+ timeline.map(d => d.date),
+ timeline.map(d => d.hours),
+ ));
+
+ // Monthly
+ makeChart('chart-monthly', ChartDefs.columnBar(
+ monthly.map(m => m.month.slice(0, 7)),
+ monthly.map(m => m.hours),
+ ));
+
+ // DOW
+ makeChart('chart-dow', ChartDefs.columnBar(
+ dow.map(d => d.label),
+ dow.map(d => d.hours),
+ ));
+
+ // Tools
+ const topTools = tools.slice(0, 10);
+ makeChart('chart-tools', ChartDefs.hoursBar(
+ topTools.map(t => t.tool),
+ topTools.map(t => t.count),
+ ));
+
+ // Timeline (today's sessions)
+ if (activity.length > 0) {
+ document.getElementById('timeline-wrap').style.height = Math.max(160, activity.length * 36) + 'px';
+ makeChart('chart-timeline', ChartDefs.timeline(activity));
+ } else {
+ const wrap = document.getElementById('timeline-wrap');
+ if (wrap) wrap.innerHTML = 'No sessions today
';
+ }
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/pages/keys.js b/src/static/js/pages/keys.js
new file mode 100644
index 0000000..46a9205
--- /dev/null
+++ b/src/static/js/pages/keys.js
@@ -0,0 +1,150 @@
+const KeysPage = (() => {
+ let _keys = [];
+
+ async function render(container) {
+ container.innerHTML = ``;
+ await load(container);
+ }
+
+ async function load(container) {
+ try {
+ _keys = await Api.get('/api/keys');
+ container.innerHTML = `
+
+
+
+
+
+ | Label | Prefix | Last Used | Status | |
+
+ ${_keys.length ? _keys.map(k => `
+
+ | ${k.label} |
+ ${k.key_prefix}… |
+ ${k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never'} |
+ ${k.is_active ? 'Active' : 'Revoked'} |
+ ${k.is_active ? `` : ''} |
+
+ `).join('') : '| No API keys yet |
'}
+
+
+
+
+
+
Hook Setup Instructions
+
+ 1. Create an API key above.
+ 2. Download cc-collector.py and save to ~/.claude/cc-collector.py
+ 3. Add the hook to your Claude Code settings:
+
+
+
${_buildHookSnippet('')}
+
+
+
+ Replace YOUR_API_KEY with your key after creating it.
+
+
⬇ Download cc-collector.py
+
+
+
+
+
+
+
Create API Key
+
+
+
+
+
+
Your key (shown once — copy it now):
+
+
+
+
+
+
+
+
+
+ `;
+
+ document.getElementById('btn-new-key').onclick = () => {
+ document.getElementById('new-key-modal').classList.add('open');
+ document.getElementById('key-result').style.display = 'none';
+ document.getElementById('key-error').style.display = 'none';
+ document.getElementById('btn-create-key').style.display = '';
+ };
+
+ document.getElementById('btn-cancel-key').onclick = () => {
+ document.getElementById('new-key-modal').classList.remove('open');
+ load(container);
+ };
+
+ document.getElementById('btn-create-key').onclick = async () => {
+ const label = document.getElementById('key-label').value.trim() || 'My Machine';
+ const errEl = document.getElementById('key-error');
+ errEl.style.display = 'none';
+ try {
+ const key = await Api.post('/api/keys', { label });
+ document.getElementById('key-raw').textContent = key.raw_key;
+ document.getElementById('key-result').style.display = 'block';
+ document.getElementById('btn-create-key').style.display = 'none';
+ // Update hook snippet
+ document.getElementById('hook-snippet').textContent = _buildHookSnippet(key.raw_key);
+ } catch (e) {
+ errEl.textContent = e.message;
+ errEl.style.display = 'block';
+ }
+ };
+
+ // Revoke buttons
+ container.querySelectorAll('[data-id]').forEach(btn => {
+ btn.onclick = async () => {
+ if (!confirm('Revoke this key?')) return;
+ try {
+ await Api.del(`/api/keys/${btn.dataset.id}`);
+ await load(container);
+ } catch (e) {
+ alert(e.message);
+ }
+ };
+ });
+
+ } catch (e) {
+ container.innerHTML = ``;
+ }
+ }
+
+ function _buildHookSnippet(key) {
+ const server = window.location.origin + '/cc-dashboard';
+ return `{
+ "hooks": {
+ "Stop": [{
+ "hooks": [{
+ "type": "command",
+ "command": "CC_API_KEY=${key || 'YOUR_API_KEY'} CC_SERVER=${server} python3 ~/.claude/cc-collector.py 2>/dev/null || true",
+ "async": true,
+ "statusMessage": "Syncing to CC Dashboard…"
+ }]
+ }]
+ }
+}`;
+ }
+
+ return { render };
+})();
+
+function copyHook() {
+ navigator.clipboard.writeText(document.getElementById('hook-snippet').textContent);
+}
+
+function copyRawKey() {
+ navigator.clipboard.writeText(document.getElementById('key-raw').textContent);
+}
diff --git a/src/static/js/pages/live.js b/src/static/js/pages/live.js
new file mode 100644
index 0000000..5a73e8e
--- /dev/null
+++ b/src/static/js/pages/live.js
@@ -0,0 +1,62 @@
+const LivePage = (() => {
+ const MAX_ITEMS = 50;
+ let _feedEl = null;
+
+ function render(container) {
+ container.innerHTML = `
+
+
+
+
+
+
⚡
+ Waiting for Claude Code sessions…
+
+
+
+
+ `;
+
+ _feedEl = document.getElementById('live-feed');
+ SSE.setDot(document.getElementById('live-dot'));
+
+ SSE.on('session_update', (data) => {
+ document.getElementById('live-status').textContent = 'Live';
+ _addItem(data);
+ });
+
+ SSE.on('*', () => {
+ document.getElementById('live-status').textContent = 'Live';
+ });
+ }
+
+ function _addItem(data) {
+ if (!_feedEl) return;
+ const emptyEl = _feedEl.querySelector('.empty');
+ if (emptyEl) emptyEl.remove();
+
+ const item = document.createElement('div');
+ item.className = 'feed-item';
+ item.innerHTML = `
+
+
+
Session synced — ${data.accepted || 0} new record${(data.accepted || 0) !== 1 ? 's' : ''}
+
Dashboard data updated
+
+ ${new Date().toLocaleTimeString()}
+ `;
+ _feedEl.insertBefore(item, _feedEl.firstChild);
+
+ // Keep max items
+ const items = _feedEl.querySelectorAll('.feed-item');
+ if (items.length > MAX_ITEMS) items[items.length - 1].remove();
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/pages/project-detail.js b/src/static/js/pages/project-detail.js
new file mode 100644
index 0000000..7549da5
--- /dev/null
+++ b/src/static/js/pages/project-detail.js
@@ -0,0 +1,86 @@
+const ProjectDetailPage = (() => {
+ async function render(container, projectId) {
+ container.innerHTML = ``;
+ try {
+ const detail = await Api.get(`/api/dashboard/project/${projectId}`);
+ const p = detail.project;
+
+ container.innerHTML = `
+
+
+
+
+
+
+
+
Top Files
+
+ | File | Edits |
+
+ ${detail.top_files.length ? detail.top_files.map(f => `
+ | ${f.file} | ${f.count} |
+ `).join('') : '| No data |
'}
+
+
+
+
+
Tool Usage
+
+ | Tool | Uses |
+
+ ${detail.top_tools.length ? detail.top_tools.map(t => `
+ | ${t.tool} | ${t.count} |
+ `).join('') : '| No data |
'}
+
+
+
+
+
+
+
Sessions
+
+
+ | Date | Time | Hours | Msgs | Work Summary |
+
+
+ ${detail.sessions.map(s => `
+
+ | ${s.date} |
+
+ ${new Date(s.start_at).toLocaleTimeString('en',{hour:'2-digit',minute:'2-digit'})}–${new Date(s.end_at).toLocaleTimeString('en',{hour:'2-digit',minute:'2-digit'})}
+ |
+ ${s.active_hours.toFixed(1)}h |
+ ${s.message_count} |
+ ${s.work_summary || '—'} |
+
+ `).join('')}
+
+
+
+
+ `;
+
+ if (detail.daily.length > 0) {
+ makeChart('proj-daily', ChartDefs.lineArea(
+ detail.daily.map(d => d.date),
+ detail.daily.map(d => d.hours),
+ ));
+ }
+ } catch (e) {
+ container.innerHTML = ``;
+ }
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/pages/projects.js b/src/static/js/pages/projects.js
new file mode 100644
index 0000000..6cd1190
--- /dev/null
+++ b/src/static/js/pages/projects.js
@@ -0,0 +1,48 @@
+const ProjectsPage = (() => {
+ async function render(container) {
+ container.innerHTML = ``;
+ try {
+ const projects = await Api.get('/api/dashboard/projects');
+ container.innerHTML = `
+
+
+
+
+
+
+ | Project |
+ Total Hours |
+ Sessions |
+ Working Days |
+ Last Active |
+ |
+
+
+
+ ${projects.map(p => `
+
+ | ${p.display_name} |
+ ${p.total_hours.toFixed(1)}h |
+ ${p.session_count} |
+ ${p.working_days} |
+ ${p.last_active || '—'} |
+
+
+ |
+
+ `).join('')}
+
+
+
+
+ `;
+ } catch (e) {
+ container.innerHTML = ``;
+ }
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/pages/settings.js b/src/static/js/pages/settings.js
new file mode 100644
index 0000000..0fc3f61
--- /dev/null
+++ b/src/static/js/pages/settings.js
@@ -0,0 +1,91 @@
+const SettingsPage = (() => {
+ async function render(container) {
+ let me;
+ try { me = await Api.get('/api/auth/me'); } catch { return; }
+
+ container.innerHTML = `
+
+
+
+
+
+
Change Password
+
+
+
+
+
+
+
+
+
+
Daily Overhead Hours
+
+ Extra hours added per working day to account for non-Claude work (deploys, reviews, meetings).
+
+
+
+
+
+
+
+
+
+
+
Account
+
+
+
Email: ${me.email}
+
Username: ${me.username}
+
Role: ${me.role}
+
+
+
+
+
+ `;
+
+ document.getElementById('btn-cp').onclick = async () => {
+ const msgEl = document.getElementById('cp-msg');
+ msgEl.style.display = 'none';
+ try {
+ await Api.post('/api/auth/change-password', {
+ current_password: document.getElementById('cp-current').value,
+ new_password: document.getElementById('cp-new').value,
+ });
+ msgEl.textContent = 'Password updated.';
+ msgEl.style.background = 'rgba(34,197,94,.12)';
+ msgEl.style.borderColor = 'rgba(34,197,94,.3)';
+ msgEl.style.color = 'var(--success)';
+ msgEl.style.display = 'block';
+ } catch (e) {
+ msgEl.style.background = '';
+ msgEl.style.borderColor = '';
+ msgEl.style.color = '';
+ msgEl.textContent = e.message;
+ msgEl.style.display = 'block';
+ }
+ };
+
+ document.getElementById('btn-overhead').onclick = async () => {
+ const val = parseFloat(document.getElementById('overhead-val').value);
+ const msgEl = document.getElementById('oh-msg');
+ try {
+ // Admin endpoint to update own user
+ await Api.put(`/api/admin/users/${me.id}`, { daily_overhead_hours: val });
+ msgEl.textContent = '✓ Saved';
+ msgEl.style.color = 'var(--success)';
+ msgEl.style.display = 'block';
+ setTimeout(() => { msgEl.style.display = 'none'; }, 2000);
+ } catch {
+ // Fallback: user updating themselves isn't admin — need a separate endpoint
+ // For now show a note
+ msgEl.textContent = 'Only admins can update this. Ask your admin.';
+ msgEl.style.color = 'var(--text-muted)';
+ msgEl.style.display = 'block';
+ }
+ };
+ }
+
+ return { render };
+})();
diff --git a/src/static/js/sse.js b/src/static/js/sse.js
new file mode 100644
index 0000000..063d914
--- /dev/null
+++ b/src/static/js/sse.js
@@ -0,0 +1,61 @@
+/**
+ * SSE client with auto-reconnect.
+ * Heartbeat ": heartbeat" comments keep connection alive through 30s LB timeout.
+ */
+const SSE = (() => {
+ const BASE = window.CC_BASE || '';
+ let _es = null;
+ let _handlers = {};
+ let _dotEl = null;
+ let _reconnectTimer = null;
+
+ function setDot(el) { _dotEl = el; }
+
+ function _setState(state) {
+ if (!_dotEl) return;
+ _dotEl.className = 'sse-dot ' + state;
+ _dotEl.title = state === 'connected' ? 'Live updates active' : state === 'error' ? 'Disconnected — retrying' : 'Connecting…';
+ }
+
+ function on(type, handler) { _handlers[type] = handler; }
+
+ function connect() {
+ if (_es) return;
+ _setState('');
+
+ // EventSource doesn't support custom headers — send token as query param
+ const token = Api.getAccessToken();
+ // We use a small wrapper: GET /api/events with Bearer via query won't work with
+ // standard EventSource. Instead we fetch a one-time SSE ticket or reuse JWT from
+ // localStorage. For simplicity, pass token via URL param and validate on server.
+ _es = new EventSource(`${BASE}/api/events?token=${encodeURIComponent(token)}`);
+
+ _es.onopen = () => { _setState('connected'); };
+
+ _es.onmessage = (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ const handler = _handlers[data.type];
+ if (handler) handler(data);
+ const allHandler = _handlers['*'];
+ if (allHandler) allHandler(data);
+ } catch { /* ignore parse errors */ }
+ };
+
+ _es.onerror = () => {
+ _setState('error');
+ _es.close();
+ _es = null;
+ if (_reconnectTimer) clearTimeout(_reconnectTimer);
+ _reconnectTimer = setTimeout(connect, 4000);
+ };
+ }
+
+ function disconnect() {
+ if (_reconnectTimer) clearTimeout(_reconnectTimer);
+ if (_es) { _es.close(); _es = null; }
+ _setState('');
+ }
+
+ return { connect, disconnect, on, setDot };
+})();