feat: implement dashboard widgets functionality

Backend:
- Add dashboard API router with widget endpoints
- Create dashboard schemas for widget data
- Add dashboard tests

Frontend:
- Enhance Dashboard page with widget components
- Add dashboard service for API calls
- Create reusable dashboard components

OpenSpec:
- Archive add-dashboard-widgets change
- Add dashboard capability specs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-08 22:52:28 +08:00
parent 3d678ba5b0
commit 4860704543
17 changed files with 2152 additions and 28 deletions

View File

@@ -0,0 +1,4 @@
"""Dashboard API module."""
from app.api.dashboard.router import router
__all__ = ["router"]

View File

@@ -0,0 +1,222 @@
"""Dashboard API endpoints.
Provides a single aggregated endpoint for dashboard data,
combining task statistics, workload summary, and project health.
"""
from datetime import datetime, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends
from sqlalchemy import func, and_, or_
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.auth import get_current_user
from app.models import User, Task
from app.models.task_status import TaskStatus
from app.schemas.dashboard import (
DashboardResponse,
TaskStatistics,
WorkloadSummary,
HealthSummary,
)
from app.schemas.workload import LoadLevel
from app.services.workload_service import (
get_week_bounds,
get_current_week_start,
calculate_load_percentage,
determine_load_level,
)
from app.services.health_service import HealthService
router = APIRouter()
def get_task_statistics(db: Session, user_id: str) -> TaskStatistics:
"""
Calculate task statistics for a user.
Args:
db: Database session
user_id: User ID to calculate statistics for
Returns:
TaskStatistics with counts and completion rate
"""
now = datetime.utcnow()
week_start, week_end = get_week_bounds(now.date())
week_start_dt = datetime.combine(week_start, datetime.min.time())
week_end_dt = datetime.combine(week_end, datetime.max.time())
# Query all tasks assigned to user (not deleted)
base_query = db.query(Task).filter(
Task.assignee_id == user_id,
Task.is_deleted == False,
)
# Count total tasks (not done) assigned to user
# We need to join with status to check is_done
assigned_count = (
base_query
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
.filter(
or_(
TaskStatus.is_done == False,
Task.status_id == None
)
)
.count()
)
# Count tasks due this week (not completed)
due_this_week = (
base_query
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
.filter(
Task.due_date >= week_start_dt,
Task.due_date <= week_end_dt,
or_(
TaskStatus.is_done == False,
Task.status_id == None
)
)
.count()
)
# Count overdue tasks (past due_date, not completed)
overdue_count = (
base_query
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
.filter(
Task.due_date < now,
or_(
TaskStatus.is_done == False,
Task.status_id == None
)
)
.count()
)
# Calculate completion rate
# Total tasks (including completed) assigned to user
total_tasks = base_query.count()
# Completed tasks
completed_tasks = (
base_query
.join(TaskStatus, Task.status_id == TaskStatus.id)
.filter(TaskStatus.is_done == True)
.count()
)
completion_rate = 0.0
if total_tasks > 0:
completion_rate = round((completed_tasks / total_tasks) * 100, 1)
return TaskStatistics(
assigned_count=assigned_count,
due_this_week=due_this_week,
overdue_count=overdue_count,
completion_rate=completion_rate,
)
def get_workload_summary(db: Session, user: User) -> WorkloadSummary:
"""
Get workload summary for a user for the current week.
Args:
db: Database session
user: User object
Returns:
WorkloadSummary with hours and load level
"""
now = datetime.utcnow()
week_start, week_end = get_week_bounds(now.date())
week_start_dt = datetime.combine(week_start, datetime.min.time())
week_end_dt = datetime.combine(week_end, datetime.max.time())
# Get tasks due this week for user (not completed)
tasks = (
db.query(Task)
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
.filter(
Task.assignee_id == user.id,
Task.is_deleted == False,
Task.due_date >= week_start_dt,
Task.due_date <= week_end_dt,
or_(
TaskStatus.is_done == False,
Task.status_id == None
)
)
.all()
)
# Calculate allocated hours from original_estimate
allocated_hours = Decimal("0")
for task in tasks:
if task.original_estimate:
allocated_hours += task.original_estimate
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
load_level = determine_load_level(load_percentage)
return WorkloadSummary(
allocated_hours=allocated_hours,
capacity_hours=capacity_hours,
load_percentage=load_percentage,
load_level=load_level,
)
def get_health_summary(db: Session) -> HealthSummary:
"""
Get aggregated project health summary.
Args:
db: Database session
Returns:
HealthSummary with project health breakdown
"""
health_service = HealthService(db)
dashboard = health_service.get_dashboard(status_filter="active")
return HealthSummary(
total_projects=dashboard.summary.total_projects,
healthy_count=dashboard.summary.healthy_count,
at_risk_count=dashboard.summary.at_risk_count,
critical_count=dashboard.summary.critical_count,
average_health_score=dashboard.summary.average_health_score,
)
@router.get("", response_model=DashboardResponse)
async def get_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get aggregated dashboard data for the current user.
Returns a single response containing:
- **task_stats**: User's task statistics (assigned, due this week, overdue, completion rate)
- **workload**: Current week workload summary (hours, load level)
- **health_summary**: Aggregated project health metrics
This endpoint combines multiple data sources into a single response
to minimize frontend API calls and ensure data consistency.
"""
# Calculate all dashboard components
task_stats = get_task_statistics(db, current_user.id)
workload = get_workload_summary(db, current_user)
health_summary = get_health_summary(db)
return DashboardResponse(
task_stats=task_stats,
workload=workload,
health_summary=health_summary,
)

View File

@@ -37,6 +37,7 @@ from app.api.health import router as health_router
from app.api.custom_fields import router as custom_fields_router from app.api.custom_fields import router as custom_fields_router
from app.api.task_dependencies import router as task_dependencies_router from app.api.task_dependencies import router as task_dependencies_router
from app.api.admin import encryption_keys as admin_encryption_keys_router from app.api.admin import encryption_keys as admin_encryption_keys_router
from app.api.dashboard import router as dashboard_router
from app.core.config import settings from app.core.config import settings
app = FastAPI( app = FastAPI(
@@ -82,6 +83,7 @@ app.include_router(health_router)
app.include_router(custom_fields_router) app.include_router(custom_fields_router)
app.include_router(task_dependencies_router) app.include_router(task_dependencies_router)
app.include_router(admin_encryption_keys_router.router) app.include_router(admin_encryption_keys_router.router)
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["Dashboard"])
@app.get("/health") @app.get("/health")

View File

@@ -0,0 +1,72 @@
"""Dashboard API schemas.
Defines response models for the dashboard endpoint that aggregates
task statistics, workload, and project health summaries.
"""
from pydantic import BaseModel
from decimal import Decimal
from typing import Optional
from app.schemas.workload import LoadLevel
class TaskStatistics(BaseModel):
"""User's task statistics for dashboard display.
Attributes:
assigned_count: Total tasks assigned to user (not completed)
due_this_week: Tasks with due_date in current week
overdue_count: Tasks past due_date, not completed
completion_rate: Percentage of completed tasks (0-100)
"""
assigned_count: int
due_this_week: int
overdue_count: int
completion_rate: float
class WorkloadSummary(BaseModel):
"""User's workload summary for dashboard display.
Attributes:
allocated_hours: Total estimated hours from tasks due this week
capacity_hours: User's weekly capacity
load_percentage: Percentage of capacity used
load_level: normal (<80%), warning (80-99%), overloaded (>=100%)
"""
allocated_hours: Decimal
capacity_hours: Decimal
load_percentage: Optional[Decimal] = None
load_level: LoadLevel
class HealthSummary(BaseModel):
"""Aggregated project health summary for dashboard display.
Attributes:
total_projects: Total number of active projects
healthy_count: Projects with health_score >= 80
at_risk_count: Projects with health_score 50-79
critical_count: Projects with health_score < 50
average_health_score: Average health score across all projects
"""
total_projects: int
healthy_count: int
at_risk_count: int
critical_count: int
average_health_score: float
class DashboardResponse(BaseModel):
"""Complete dashboard response aggregating all widgets.
Single endpoint response that combines:
- Task statistics for the current user
- Current week workload summary
- Project health summary
This minimizes frontend API calls and ensures data consistency.
"""
task_stats: TaskStatistics
workload: WorkloadSummary
health_summary: HealthSummary

View File

@@ -0,0 +1,635 @@
"""Tests for dashboard API and service functions."""
import pytest
from datetime import datetime, timedelta
from decimal import Decimal
from app.models import User, Department, Space, Project, Task
from app.models.task_status import TaskStatus
from app.api.dashboard.router import (
get_task_statistics,
get_workload_summary,
get_health_summary,
)
from app.schemas.workload import LoadLevel
class TestTaskStatistics:
"""Tests for task statistics calculation."""
def setup_test_data(self, db):
"""Set up test data for task statistics tests."""
# Create department
dept = Department(
id="dept-dash-001",
name="Dashboard Test Department",
)
db.add(dept)
# Create test user
user = User(
id="user-dash-001",
email="dashboard@test.com",
name="Dashboard Test User",
department_id="dept-dash-001",
role_id="00000000-0000-0000-0000-000000000003",
capacity=40,
is_active=True,
is_system_admin=False,
)
db.add(user)
# Create space
space = Space(
id="space-dash-001",
name="Dashboard Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
is_active=True,
)
db.add(space)
# Create project
project = Project(
id="project-dash-001",
space_id="space-dash-001",
title="Dashboard Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
department_id="dept-dash-001",
security_level="department",
status="active",
)
db.add(project)
# Create task statuses
status_todo = TaskStatus(
id="status-dash-todo",
project_id="project-dash-001",
name="To Do",
is_done=False,
)
db.add(status_todo)
status_done = TaskStatus(
id="status-dash-done",
project_id="project-dash-001",
name="Done",
is_done=True,
)
db.add(status_done)
db.commit()
return {
"department": dept,
"user": user,
"space": space,
"project": project,
"status_todo": status_todo,
"status_done": status_done,
}
def create_task(
self,
db,
data,
task_id,
done=False,
overdue=False,
due_this_week=False,
estimate=None,
):
"""Helper to create a task with optional characteristics."""
now = datetime.utcnow()
if overdue:
due_date = now - timedelta(days=3)
elif due_this_week:
# Due in the middle of current week
due_date = now + timedelta(days=2)
else:
# Due next week
due_date = now + timedelta(days=10)
task = Task(
id=task_id,
project_id=data["project"].id,
title=f"Task {task_id}",
assignee_id=data["user"].id,
status_id=data["status_done"].id if done else data["status_todo"].id,
original_estimate=estimate,
due_date=due_date,
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task)
db.commit()
return task
def test_empty_statistics(self, db):
"""User with no tasks should have zero counts."""
data = self.setup_test_data(db)
stats = get_task_statistics(db, data["user"].id)
assert stats.assigned_count == 0
assert stats.due_this_week == 0
assert stats.overdue_count == 0
assert stats.completion_rate == 0.0
def test_assigned_count(self, db):
"""Should count non-completed tasks assigned to user."""
data = self.setup_test_data(db)
# Create 3 tasks: 2 active, 1 completed
self.create_task(db, data, "task-1", done=False)
self.create_task(db, data, "task-2", done=False)
self.create_task(db, data, "task-3", done=True)
stats = get_task_statistics(db, data["user"].id)
assert stats.assigned_count == 2 # Only non-completed
def test_due_this_week_count(self, db):
"""Should count tasks due this week."""
data = self.setup_test_data(db)
# Create tasks with different due dates
self.create_task(db, data, "task-1", due_this_week=True)
self.create_task(db, data, "task-2", due_this_week=True)
self.create_task(db, data, "task-3", due_this_week=False) # Next week
stats = get_task_statistics(db, data["user"].id)
assert stats.due_this_week == 2
def test_overdue_count(self, db):
"""Should count overdue tasks."""
data = self.setup_test_data(db)
# Create overdue and non-overdue tasks
self.create_task(db, data, "task-1", overdue=True)
self.create_task(db, data, "task-2", overdue=True)
self.create_task(db, data, "task-3", overdue=False)
stats = get_task_statistics(db, data["user"].id)
assert stats.overdue_count == 2
def test_overdue_completed_not_counted(self, db):
"""Completed overdue tasks should not be counted as overdue."""
data = self.setup_test_data(db)
# Create overdue task that is completed
self.create_task(db, data, "task-1", overdue=True, done=True)
self.create_task(db, data, "task-2", overdue=True, done=False)
stats = get_task_statistics(db, data["user"].id)
assert stats.overdue_count == 1
def test_completion_rate(self, db):
"""Should calculate correct completion rate."""
data = self.setup_test_data(db)
# Create 4 tasks: 1 completed, 3 active = 25%
self.create_task(db, data, "task-1", done=True)
self.create_task(db, data, "task-2", done=False)
self.create_task(db, data, "task-3", done=False)
self.create_task(db, data, "task-4", done=False)
stats = get_task_statistics(db, data["user"].id)
assert stats.completion_rate == 25.0
def test_deleted_tasks_excluded(self, db):
"""Soft-deleted tasks should not be counted."""
data = self.setup_test_data(db)
# Create normal task
self.create_task(db, data, "task-1")
# Create deleted task
deleted_task = Task(
id="task-deleted",
project_id=data["project"].id,
title="Deleted Task",
assignee_id=data["user"].id,
status_id=data["status_todo"].id,
due_date=datetime.utcnow() - timedelta(days=5), # Overdue
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=datetime.utcnow(),
)
db.add(deleted_task)
db.commit()
stats = get_task_statistics(db, data["user"].id)
assert stats.assigned_count == 1
assert stats.overdue_count == 0 # Deleted task not counted
class TestWorkloadSummary:
"""Tests for workload summary calculation."""
def setup_test_data(self, db):
"""Set up test data for workload summary tests."""
# Create department
dept = Department(
id="dept-wl-001",
name="Workload Test Department",
)
db.add(dept)
# Create test user with 40h capacity
user = User(
id="user-wl-001",
email="workload@test.com",
name="Workload Test User",
department_id="dept-wl-001",
role_id="00000000-0000-0000-0000-000000000003",
capacity=40,
is_active=True,
is_system_admin=False,
)
db.add(user)
# Create space
space = Space(
id="space-wl-001",
name="Workload Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
is_active=True,
)
db.add(space)
# Create project
project = Project(
id="project-wl-001",
space_id="space-wl-001",
title="Workload Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
department_id="dept-wl-001",
security_level="department",
status="active",
)
db.add(project)
# Create task status
status_todo = TaskStatus(
id="status-wl-todo",
project_id="project-wl-001",
name="To Do",
is_done=False,
)
db.add(status_todo)
status_done = TaskStatus(
id="status-wl-done",
project_id="project-wl-001",
name="Done",
is_done=True,
)
db.add(status_done)
db.commit()
return {
"department": dept,
"user": user,
"space": space,
"project": project,
"status_todo": status_todo,
"status_done": status_done,
}
def test_empty_workload(self, db):
"""User with no tasks should have zero allocated hours."""
data = self.setup_test_data(db)
workload = get_workload_summary(db, data["user"])
assert workload.allocated_hours == Decimal("0")
assert workload.capacity_hours == Decimal("40")
assert workload.load_percentage == Decimal("0.00")
assert workload.load_level == LoadLevel.NORMAL
def test_workload_with_tasks(self, db):
"""Should calculate correct allocated hours."""
data = self.setup_test_data(db)
# Create tasks due this week with estimates
now = datetime.utcnow()
due_date = now + timedelta(days=2)
task1 = Task(
id="task-wl-1",
project_id=data["project"].id,
title="Task 1",
assignee_id=data["user"].id,
status_id=data["status_todo"].id,
original_estimate=Decimal("16"),
due_date=due_date,
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task1)
task2 = Task(
id="task-wl-2",
project_id=data["project"].id,
title="Task 2",
assignee_id=data["user"].id,
status_id=data["status_todo"].id,
original_estimate=Decimal("16"),
due_date=due_date,
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task2)
db.commit()
workload = get_workload_summary(db, data["user"])
assert workload.allocated_hours == Decimal("32")
assert workload.load_percentage == Decimal("80.00")
assert workload.load_level == LoadLevel.WARNING
def test_workload_overloaded(self, db):
"""User with more than capacity should be overloaded."""
data = self.setup_test_data(db)
now = datetime.utcnow()
due_date = now + timedelta(days=2)
# Create task with 48h estimate (> 40h capacity)
task = Task(
id="task-wl-over",
project_id=data["project"].id,
title="Big Task",
assignee_id=data["user"].id,
status_id=data["status_todo"].id,
original_estimate=Decimal("48"),
due_date=due_date,
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task)
db.commit()
workload = get_workload_summary(db, data["user"])
assert workload.allocated_hours == Decimal("48")
assert workload.load_percentage == Decimal("120.00")
assert workload.load_level == LoadLevel.OVERLOADED
def test_completed_tasks_excluded(self, db):
"""Completed tasks should not count toward workload."""
data = self.setup_test_data(db)
now = datetime.utcnow()
due_date = now + timedelta(days=2)
# Create completed task
task = Task(
id="task-wl-done",
project_id=data["project"].id,
title="Done Task",
assignee_id=data["user"].id,
status_id=data["status_done"].id,
original_estimate=Decimal("24"),
due_date=due_date,
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task)
db.commit()
workload = get_workload_summary(db, data["user"])
assert workload.allocated_hours == Decimal("0")
class TestHealthSummary:
"""Tests for health summary aggregation."""
def setup_test_data(self, db):
"""Set up test data for health summary tests."""
# Create department
dept = Department(
id="dept-hs-001",
name="Health Summary Test Department",
)
db.add(dept)
# Create space
space = Space(
id="space-hs-001",
name="Health Summary Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
is_active=True,
)
db.add(space)
# Create active project
project = Project(
id="project-hs-001",
space_id="space-hs-001",
title="Health Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
department_id="dept-hs-001",
security_level="department",
status="active",
)
db.add(project)
db.commit()
return {
"department": dept,
"space": space,
"project": project,
}
def test_health_summary_structure(self, db):
"""Health summary should have correct structure."""
data = self.setup_test_data(db)
summary = get_health_summary(db)
assert summary.total_projects >= 1
assert summary.healthy_count >= 0
assert summary.at_risk_count >= 0
assert summary.critical_count >= 0
assert summary.average_health_score >= 0
assert summary.average_health_score <= 100
class TestDashboardAPI:
"""Tests for dashboard API endpoint."""
def setup_test_data(self, db):
"""Set up test data for dashboard API tests."""
# Create department
dept = Department(
id="dept-api-dash-001",
name="API Dashboard Test Department",
)
db.add(dept)
# Create space
space = Space(
id="space-api-dash-001",
name="API Dashboard Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
is_active=True,
)
db.add(space)
# Create project
project = Project(
id="project-api-dash-001",
space_id="space-api-dash-001",
title="API Dashboard Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
department_id="dept-api-dash-001",
security_level="department",
status="active",
)
db.add(project)
# Create task status
status_todo = TaskStatus(
id="status-api-dash-todo",
project_id="project-api-dash-001",
name="To Do",
is_done=False,
)
db.add(status_todo)
# Create a task for the admin user
now = datetime.utcnow()
task = Task(
id="task-api-dash-001",
project_id="project-api-dash-001",
title="Admin Task",
assignee_id="00000000-0000-0000-0000-000000000001",
status_id="status-api-dash-todo",
original_estimate=Decimal("8"),
due_date=now + timedelta(days=2),
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False,
)
db.add(task)
db.commit()
return {
"department": dept,
"space": space,
"project": project,
"task": task,
}
def test_get_dashboard(self, client, db, admin_token):
"""Should return complete dashboard data."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = response.json()
# Check structure
assert "task_stats" in result
assert "workload" in result
assert "health_summary" in result
def test_dashboard_task_stats_fields(self, client, db, admin_token):
"""Dashboard task_stats should include all expected fields."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
task_stats = response.json()["task_stats"]
assert "assigned_count" in task_stats
assert "due_this_week" in task_stats
assert "overdue_count" in task_stats
assert "completion_rate" in task_stats
def test_dashboard_workload_fields(self, client, db, admin_token):
"""Dashboard workload should include all expected fields."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
workload = response.json()["workload"]
assert "allocated_hours" in workload
assert "capacity_hours" in workload
assert "load_percentage" in workload
assert "load_level" in workload
def test_dashboard_health_summary_fields(self, client, db, admin_token):
"""Dashboard health_summary should include all expected fields."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
health_summary = response.json()["health_summary"]
assert "total_projects" in health_summary
assert "healthy_count" in health_summary
assert "at_risk_count" in health_summary
assert "critical_count" in health_summary
assert "average_health_score" in health_summary
def test_dashboard_unauthorized(self, client, db):
"""Unauthenticated requests should fail."""
response = client.get("/api/dashboard")
assert response.status_code == 403
def test_dashboard_with_user_tasks(self, client, db, admin_token):
"""Dashboard should reflect user's tasks correctly."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = response.json()
# Admin has 1 task assigned (created in setup)
assert result["task_stats"]["assigned_count"] >= 1
assert result["task_stats"]["due_this_week"] >= 1
def test_dashboard_workload_load_level_values(self, client, db, admin_token):
"""Workload load_level should be a valid enum value."""
data = self.setup_test_data(db)
response = client.get(
"/api/dashboard",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
load_level = response.json()["workload"]["load_level"]
assert load_level in ["normal", "warning", "overloaded", "unavailable"]

View File

@@ -0,0 +1,216 @@
import React from 'react'
import { HealthSummary } from '../../services/dashboard'
interface HealthSummaryWidgetProps {
health: HealthSummary
}
// Health score color helper
function getHealthScoreColor(score: number): string {
if (score >= 80) return '#4caf50' // Green
if (score >= 60) return '#ff9800' // Orange
if (score >= 40) return '#ff5722' // Deep Orange
return '#f44336' // Red
}
export function HealthSummaryWidget({ health }: HealthSummaryWidgetProps) {
const scoreColor = getHealthScoreColor(health.average_health_score)
return (
<div style={styles.card}>
<div style={styles.header}>
<h3 style={styles.title}>Project Health</h3>
<span style={styles.totalBadge}>
{health.total_projects} projects
</span>
</div>
<div style={styles.content}>
{/* Average Score Display */}
<div style={styles.scoreSection}>
<div style={styles.scoreCircle}>
<svg width="80" height="80" viewBox="0 0 80 80">
{/* Background circle */}
<circle
cx="40"
cy="40"
r="35"
fill="none"
stroke="#e0e0e0"
strokeWidth="6"
/>
{/* Progress circle */}
<circle
cx="40"
cy="40"
r="35"
fill="none"
stroke={scoreColor}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${(health.average_health_score / 100) * 220} 220`}
transform="rotate(-90 40 40)"
/>
</svg>
<div style={styles.scoreText}>
<span style={{ ...styles.scoreValue, color: scoreColor }}>
{Math.round(health.average_health_score)}
</span>
<span style={styles.scoreLabel}>Avg Score</span>
</div>
</div>
{/* Status Breakdown */}
<div style={styles.breakdown}>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#4caf50' }} />
<span style={styles.breakdownLabel}>Healthy</span>
<span style={styles.breakdownValue}>{health.healthy_count}</span>
</div>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#ff9800' }} />
<span style={styles.breakdownLabel}>At Risk</span>
<span style={styles.breakdownValue}>{health.at_risk_count}</span>
</div>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#f44336' }} />
<span style={styles.breakdownLabel}>Critical</span>
<span style={styles.breakdownValue}>{health.critical_count}</span>
</div>
</div>
</div>
{/* Blockers Info */}
{health.projects_with_blockers > 0 && (
<div style={styles.blockersInfo}>
<span style={styles.blockersIcon}>!</span>
<span style={styles.blockersText}>
{health.projects_with_blockers} project{health.projects_with_blockers > 1 ? 's have' : ' has'} blockers
</span>
</div>
)}
</div>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
title: {
margin: 0,
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
totalBadge: {
padding: '4px 10px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 500,
color: '#666',
backgroundColor: '#f5f5f5',
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
scoreSection: {
display: 'flex',
alignItems: 'center',
gap: '24px',
},
scoreCircle: {
position: 'relative',
width: '80px',
height: '80px',
flexShrink: 0,
},
scoreText: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
},
scoreValue: {
fontSize: '24px',
fontWeight: 700,
display: 'block',
lineHeight: 1,
},
scoreLabel: {
fontSize: '9px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
breakdown: {
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '10px',
},
breakdownItem: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
statusDot: {
width: '10px',
height: '10px',
borderRadius: '50%',
flexShrink: 0,
},
breakdownLabel: {
flex: 1,
fontSize: '13px',
color: '#666',
},
breakdownValue: {
fontSize: '14px',
fontWeight: 600,
color: '#333',
minWidth: '24px',
textAlign: 'right',
},
blockersInfo: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
backgroundColor: '#fff3e0',
borderRadius: '6px',
},
blockersIcon: {
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#ff9800',
color: 'white',
fontSize: '12px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
blockersText: {
fontSize: '13px',
color: '#e65100',
fontWeight: 500,
},
}
export default HealthSummaryWidget

View File

@@ -0,0 +1,136 @@
import React from 'react'
import { Link } from 'react-router-dom'
interface QuickActionsProps {
isAdmin?: boolean
}
interface ActionItem {
to: string
label: string
icon: string
color: string
description: string
adminOnly?: boolean
}
const actions: ActionItem[] = [
{
to: '/spaces',
label: 'Spaces',
icon: '\u25a0', // Square icon
color: '#2196f3',
description: 'Browse projects',
},
{
to: '/workload',
label: 'Workload',
icon: '\u25b2', // Triangle icon
color: '#9c27b0',
description: 'View team capacity',
},
{
to: '/health',
label: 'Health',
icon: '\u2665', // Heart icon
color: '#4caf50',
description: 'Project status',
},
{
to: '/audit',
label: 'Audit',
icon: '\u25cf', // Circle icon
color: '#ff9800',
description: 'Activity logs',
adminOnly: true,
},
]
export function QuickActions({ isAdmin = false }: QuickActionsProps) {
const visibleActions = actions.filter((action) => !action.adminOnly || isAdmin)
return (
<div style={styles.card}>
<h3 style={styles.title}>Quick Actions</h3>
<div style={styles.grid}>
{visibleActions.map((action) => (
<Link
key={action.to}
to={action.to}
style={styles.actionLink}
>
<div
style={{
...styles.iconContainer,
backgroundColor: `${action.color}15`,
}}
>
<span style={{ ...styles.icon, color: action.color }}>
{action.icon}
</span>
</div>
<span style={styles.label}>{action.label}</span>
<span style={styles.description}>{action.description}</span>
</Link>
))}
</div>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
},
title: {
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: '12px',
},
actionLink: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '16px 12px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
textDecoration: 'none',
transition: 'all 0.2s ease',
cursor: 'pointer',
},
iconContainer: {
width: '40px',
height: '40px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '8px',
},
icon: {
fontSize: '20px',
lineHeight: 1,
},
label: {
fontSize: '14px',
fontWeight: 600,
color: '#333',
marginBottom: '4px',
},
description: {
fontSize: '11px',
color: '#666',
textAlign: 'center',
},
}
export default QuickActions

View File

@@ -0,0 +1,90 @@
import React from 'react'
interface StatisticsCardProps {
icon: React.ReactNode
value: number | string
label: string
color?: string
suffix?: string
highlight?: boolean
}
export function StatisticsCard({
icon,
value,
label,
color = '#333',
suffix = '',
highlight = false,
}: StatisticsCardProps) {
return (
<div
style={{
...styles.card,
borderLeft: highlight ? `4px solid ${color}` : '4px solid transparent',
}}
>
<div style={{ ...styles.iconContainer, backgroundColor: `${color}15` }}>
<span style={{ ...styles.icon, color }}>{icon}</span>
</div>
<div style={styles.content}>
<span style={{ ...styles.value, color }}>
{value}
{suffix && <span style={styles.suffix}>{suffix}</span>}
</span>
<span style={styles.label}>{label}</span>
</div>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
transition: 'box-shadow 0.2s ease',
},
iconContainer: {
width: '48px',
height: '48px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
icon: {
fontSize: '24px',
lineHeight: 1,
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
minWidth: 0,
},
value: {
fontSize: '28px',
fontWeight: 700,
lineHeight: 1.2,
},
suffix: {
fontSize: '16px',
fontWeight: 500,
marginLeft: '2px',
},
label: {
fontSize: '14px',
color: '#666',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}
export default StatisticsCard

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { WorkloadSummary, LoadLevel } from '../../services/dashboard'
interface WorkloadWidgetProps {
workload: WorkloadSummary
}
// Load level configuration
const loadLevelConfig: Record<LoadLevel, { color: string; bgColor: string; label: string }> = {
normal: { color: '#4caf50', bgColor: '#e8f5e9', label: 'Normal' },
warning: { color: '#ff9800', bgColor: '#fff3e0', label: 'Warning' },
overloaded: { color: '#f44336', bgColor: '#ffebee', label: 'Overloaded' },
unavailable: { color: '#9e9e9e', bgColor: '#f5f5f5', label: 'Unavailable' },
}
export function WorkloadWidget({ workload }: WorkloadWidgetProps) {
const config = loadLevelConfig[workload.load_level]
const percentage = Math.min(workload.load_percentage, 100)
return (
<div style={styles.card}>
<div style={styles.header}>
<h3 style={styles.title}>My Workload</h3>
<span
style={{
...styles.badge,
color: config.color,
backgroundColor: config.bgColor,
}}
>
{config.label}
</span>
</div>
<div style={styles.content}>
<div style={styles.hoursDisplay}>
<span style={styles.hoursValue}>{workload.allocated_hours}h</span>
<span style={styles.hoursDivider}>/</span>
<span style={styles.hoursCapacity}>{workload.capacity_hours}h</span>
</div>
<div style={styles.progressContainer}>
<div style={styles.progressBar}>
<div
style={{
...styles.progressFill,
width: `${percentage}%`,
backgroundColor: config.color,
}}
/>
</div>
<span style={{ ...styles.percentage, color: config.color }}>
{workload.load_percentage}%
</span>
</div>
<p style={styles.description}>
{workload.load_level === 'normal' && 'You have capacity for additional tasks this week.'}
{workload.load_level === 'warning' && 'You are approaching full capacity this week.'}
{workload.load_level === 'overloaded' && 'You are over capacity. Consider reassigning tasks.'}
{workload.load_level === 'unavailable' && 'Workload data is not available.'}
</p>
</div>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
title: {
margin: 0,
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
badge: {
padding: '4px 10px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 500,
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
hoursDisplay: {
display: 'flex',
alignItems: 'baseline',
gap: '4px',
},
hoursValue: {
fontSize: '32px',
fontWeight: 700,
color: '#333',
},
hoursDivider: {
fontSize: '20px',
color: '#999',
margin: '0 4px',
},
hoursCapacity: {
fontSize: '20px',
color: '#666',
},
progressContainer: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
progressBar: {
flex: 1,
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: '4px',
transition: 'width 0.3s ease',
},
percentage: {
fontSize: '14px',
fontWeight: 600,
minWidth: '45px',
textAlign: 'right',
},
description: {
margin: 0,
fontSize: '13px',
color: '#666',
lineHeight: 1.5,
},
}
export default WorkloadWidget

View File

@@ -0,0 +1,4 @@
export { StatisticsCard } from './StatisticsCard'
export { WorkloadWidget } from './WorkloadWidget'
export { HealthSummaryWidget } from './HealthSummaryWidget'
export { QuickActions } from './QuickActions'

View File

@@ -1,28 +1,171 @@
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { dashboardApi, DashboardResponse } from '../services/dashboard'
import {
StatisticsCard,
WorkloadWidget,
HealthSummaryWidget,
QuickActions,
} from '../components/dashboard'
import { Skeleton } from '../components/Skeleton'
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth() const { user } = useAuth()
const [data, setData] = useState<DashboardResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchDashboard = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await dashboardApi.getDashboard()
setData(response)
} catch (err) {
setError('Failed to load dashboard data. Please try again.')
console.error('Dashboard fetch error:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
// Loading state
if (loading) {
return (
<div style={styles.container}>
<div style={styles.welcomeSection}>
<Skeleton variant="text" width={200} height={28} />
<Skeleton variant="text" width={150} height={16} style={{ marginTop: 8 }} />
</div>
{/* Statistics Cards Skeleton */}
<div style={styles.statsGrid}>
{[1, 2, 3, 4].map((i) => (
<div key={i} style={styles.skeletonCard}>
<Skeleton variant="circle" width={48} height={48} />
<div style={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={28} />
<Skeleton variant="text" width="80%" height={14} style={{ marginTop: 4 }} />
</div>
</div>
))}
</div>
{/* Widgets Skeleton */}
<div style={styles.widgetsGrid}>
<div style={styles.skeletonWidget}>
<Skeleton variant="text" width={120} height={20} />
<Skeleton variant="text" width="100%" height={32} style={{ marginTop: 16 }} />
<Skeleton variant="rect" width="100%" height={8} style={{ marginTop: 12, borderRadius: 4 }} />
</div>
<div style={styles.skeletonWidget}>
<Skeleton variant="text" width={120} height={20} />
<div style={{ display: 'flex', gap: 24, marginTop: 16 }}>
<Skeleton variant="circle" width={80} height={80} />
<div style={{ flex: 1 }}>
<Skeleton variant="text" width="100%" height={16} />
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
</div>
</div>
</div>
</div>
{/* Quick Actions Skeleton */}
<div style={styles.skeletonWidget}>
<Skeleton variant="text" width={120} height={20} />
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} variant="rect" width={100} height={80} />
))}
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div style={styles.container}>
<div style={styles.welcomeSection}>
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
</div>
<div style={styles.errorCard}>
<div style={styles.errorIcon}>!</div>
<h3 style={styles.errorTitle}>Unable to Load Dashboard</h3>
<p style={styles.errorMessage}>{error}</p>
<button
style={styles.retryButton}
onClick={fetchDashboard}
type="button"
>
Try Again
</button>
</div>
</div>
)
}
// Success state
return ( return (
<div style={styles.container}> <div style={styles.container}>
<div style={styles.welcomeCard}> {/* Welcome Section */}
<h2>Welcome, {user?.name}!</h2> <div style={styles.welcomeSection}>
<p>Email: {user?.email}</p> <h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
<p>Role: {user?.role || 'No role assigned'}</p> <p style={styles.welcomeSubtitle}>
{user?.is_system_admin && ( Here is your work overview for today
<p style={styles.adminNote}>
You have system administrator privileges.
</p>
)}
</div>
<div style={styles.infoCard}>
<h3>Getting Started</h3>
<p>
This is the Project Control system dashboard. Features will be
added as development progresses.
</p> </p>
</div> </div>
{/* Statistics Cards */}
{data && (
<>
<div style={styles.statsGrid}>
<StatisticsCard
icon="✓"
value={data.task_stats.assigned_count}
label="My Tasks"
color="#2196f3"
/>
<StatisticsCard
icon="⏰"
value={data.task_stats.due_this_week}
label="Due This Week"
color="#ff9800"
highlight={data.task_stats.due_this_week > 0}
/>
<StatisticsCard
icon="⚠"
value={data.task_stats.overdue_count}
label="Overdue"
color="#f44336"
highlight={data.task_stats.overdue_count > 0}
/>
<StatisticsCard
icon="✅"
value={data.task_stats.completion_rate}
label="Completion Rate"
color="#4caf50"
suffix="%"
/>
</div>
{/* Widgets Grid */}
<div style={styles.widgetsGrid}>
<WorkloadWidget workload={data.workload} />
<HealthSummaryWidget health={data.health_summary} />
</div>
{/* Quick Actions */}
<QuickActions isAdmin={user?.is_system_admin} />
</>
)}
</div> </div>
) )
} }
@@ -32,23 +175,93 @@ const styles: { [key: string]: React.CSSProperties } = {
padding: '24px', padding: '24px',
maxWidth: '1200px', maxWidth: '1200px',
margin: '0 auto', margin: '0 auto',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}, },
welcomeCard: { welcomeSection: {
marginBottom: '8px',
},
welcomeTitle: {
margin: 0,
fontSize: '24px',
fontWeight: 600,
color: '#333',
},
welcomeSubtitle: {
margin: '8px 0 0 0',
fontSize: '14px',
color: '#666',
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '16px',
},
widgetsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '16px',
},
skeletonCard: {
backgroundColor: 'white', backgroundColor: 'white',
padding: '24px',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
marginBottom: '24px', padding: '20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
}, },
adminNote: { skeletonWidget: {
color: '#0066cc', backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
},
errorCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '40px',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
},
errorIcon: {
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#ffebee',
color: '#f44336',
fontSize: '32px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
errorTitle: {
margin: 0,
fontSize: '18px',
fontWeight: 600,
color: '#333',
},
errorMessage: {
margin: 0,
fontSize: '14px',
color: '#666',
maxWidth: '400px',
},
retryButton: {
padding: '10px 24px',
fontSize: '14px',
fontWeight: 500, fontWeight: 500,
marginTop: '12px', color: 'white',
}, backgroundColor: '#2196f3',
infoCard: { border: 'none',
backgroundColor: 'white', borderRadius: '6px',
padding: '24px', cursor: 'pointer',
borderRadius: '8px', transition: 'background-color 0.2s ease',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
}, },
} }

View File

@@ -0,0 +1,46 @@
import api from './api'
// Types for Dashboard API responses
export type LoadLevel = 'normal' | 'warning' | 'overloaded' | 'unavailable'
export interface TaskStatistics {
assigned_count: number
due_this_week: number
overdue_count: number
completion_rate: number
}
export interface WorkloadSummary {
allocated_hours: number
capacity_hours: number
load_percentage: number
load_level: LoadLevel
}
export interface HealthSummary {
total_projects: number
healthy_count: number
at_risk_count: number
critical_count: number
average_health_score: number
projects_with_blockers: number
}
export interface DashboardResponse {
task_stats: TaskStatistics
workload: WorkloadSummary
health_summary: HealthSummary
}
// API functions
export const dashboardApi = {
/**
* Get aggregated dashboard data for the current user
*/
getDashboard: async (): Promise<DashboardResponse> => {
const response = await api.get<DashboardResponse>('/dashboard')
return response.data
},
}
export default dashboardApi

View File

@@ -0,0 +1,101 @@
# Design: Dashboard Widgets
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Dashboard Page │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────┐ │
│ │ My Tasks │ │ Due This │ │ Overdue │ │ Done % │ │
│ │ 12 │ │ Week: 5 │ │ 2 │ │ 78% │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────┘ │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────┐ ┌───────────────────────────┐ │
│ │ My Workload │ │ Project Health │ │
│ │ ┌────────────────────┐ │ │ ┌───────────────────────┐ │ │
│ │ │ 32h / 40h (80%) │ │ │ │ 8 Healthy | 2 At Risk │ │ │
│ │ │ ████████░░ │ │ │ │ Avg Score: 76% │ │ │
│ │ └────────────────────┘ │ │ └───────────────────────┘ │ │
│ └──────────────────────────┘ └───────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Quick Actions: [Spaces] [Workload] [Health] [Audit*] │
└─────────────────────────────────────────────────────────────┘
```
## API Design
### GET /api/dashboard
Single endpoint aggregates all dashboard data to minimize frontend requests.
```typescript
interface DashboardResponse {
task_stats: {
assigned_count: number // Total tasks assigned to user
due_this_week: number // Tasks with due_date in current week
overdue_count: number // Tasks past due_date, not completed
completion_rate: number // Percentage (0-100)
}
workload: {
allocated_hours: number
capacity_hours: number
load_percentage: number
load_level: 'normal' | 'warning' | 'overloaded'
}
health_summary: {
total_projects: number
healthy_count: number
at_risk_count: number
critical_count: number
average_health_score: number
}
}
```
## Data Sources
| Widget | Source | Notes |
|--------|--------|-------|
| Task Statistics | `pjctrl_tasks` table | Filter by assignee_id = current user |
| Workload | Existing `WorkloadService` | Reuse get_user_workload_detail() |
| Health Summary | Existing `HealthService` | Reuse get_dashboard() summary |
## Performance Considerations
1. **Single API Call**: Frontend makes one request instead of three
2. **Query Optimization**: Task stats use COUNT queries, not full rows
3. **Caching**: Health summary already cached in Redis (5 min TTL)
4. **No N+1**: Avoid loading related entities for counts
## Component Structure
```
frontend/src/
├── pages/
│ └── Dashboard.tsx # Main page (modified)
├── components/
│ ├── dashboard/
│ │ ├── StatisticsCard.tsx # Single stat card
│ │ ├── WorkloadWidget.tsx # Workload progress bar
│ │ ├── HealthWidget.tsx # Health summary
│ │ └── QuickActions.tsx # Navigation links
└── services/
└── dashboard.ts # API service (new)
```
## Trade-offs
### Decision: Single vs Multiple API Endpoints
**Chosen**: Single `/api/dashboard` endpoint
**Rationale**:
- Reduces network round-trips (1 vs 3)
- Simpler frontend loading state management
- Data is always consistent (same timestamp)
**Rejected Alternative**: Use existing endpoints directly
- Would require 3 parallel requests
- More complex error handling
- Harder to ensure data consistency

View File

@@ -0,0 +1,38 @@
# Proposal: add-dashboard-widgets
## Summary
Replace the placeholder Dashboard page with functional dashboard widgets that provide users with an at-a-glance overview of their work and system status.
## Motivation
The current Dashboard displays only a welcome message and placeholder text ("Features will be added as development progresses"). Users need a centralized view of:
- Their assigned tasks and workload
- Project health across the organization
- Quick access to common actions
- System statistics
## Scope
### In Scope
- Dashboard statistics cards (my tasks, overdue, completion rate)
- My workload summary widget (reuses existing `/workload/me` API)
- Project health summary widget (reuses existing `/projects/health/dashboard` API)
- Quick actions section with navigation links
- Backend API endpoint for aggregated dashboard data
### Out of Scope
- Customizable widget layout (future enhancement)
- Real-time WebSocket updates for dashboard (can be added later)
- Dashboard for specific roles (manager vs engineer views)
## Dependencies
Reuses existing APIs:
- `GET /workload/me` - User workload summary
- `GET /projects/health/dashboard` - Project health overview
- `GET /api/projects/{project_id}/tasks` with `assignee_id` filter
## Risk Assessment
**Low Risk** - This change primarily adds a new frontend page using existing backend APIs. A new lightweight aggregation endpoint is added but doesn't modify existing data structures.

View File

@@ -0,0 +1,69 @@
# Dashboard Capability
Provides users with an at-a-glance overview of their work, project health, and system statistics.
## ADDED Requirements
### Requirement: Dashboard Statistics
The system SHALL display aggregated statistics showing the user's task overview.
#### Scenario: User views task statistics
- Given: User is authenticated
- When: User navigates to Dashboard
- Then: System displays:
- Total tasks assigned to user
- Tasks due this week
- Overdue tasks count
- Completion rate percentage
### Requirement: My Workload Widget
The system SHALL display the current user's workload summary for the current week.
#### Scenario: User views personal workload
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays:
- Allocated hours vs capacity hours
- Load percentage with visual indicator
- Load level status (normal/warning/overloaded)
### Requirement: Project Health Summary
The system SHALL display an aggregated project health summary.
#### Scenario: User views project health overview
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays:
- Total projects count
- Healthy/At-Risk/Critical breakdown
- Average health score
- Projects with blockers count
### Requirement: Quick Actions
The system SHALL provide quick navigation links to common actions.
#### Scenario: User accesses quick actions
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays navigation links to:
- Spaces page
- Workload page
- Project Health page
- (Admin only) Audit page
### Requirement: Dashboard API Endpoint
The backend SHALL provide a single aggregated endpoint for dashboard data.
#### Scenario: Frontend fetches dashboard data
- Given: User is authenticated
- When: Frontend requests GET /api/dashboard
- Then: Backend returns:
- User task statistics
- Current week workload summary
- Project health summary
- And: Response is optimized with single database query where possible

View File

@@ -0,0 +1,59 @@
# Implementation Tasks
## Phase 1: Backend API
### Task 1.1: Create Dashboard Schema
- [ ] Create `backend/app/schemas/dashboard.py` with response models
- `TaskStatistics`: assigned_count, due_this_week, overdue_count, completion_rate
- `DashboardResponse`: task_stats, workload_summary, health_summary
- **Validation**: Schema imports without errors
### Task 1.2: Create Dashboard Router
- [ ] Create `backend/app/api/dashboard/router.py`
- [ ] Implement `GET /api/dashboard` endpoint
- Query tasks assigned to current user
- Reuse workload service for current week summary
- Reuse health service for project summary
- [ ] Register router in `backend/app/main.py`
- **Validation**: `curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/dashboard` returns valid JSON
## Phase 2: Frontend Implementation
### Task 2.1: Create Dashboard Service
- [ ] Create `frontend/src/services/dashboard.ts`
- [ ] Define TypeScript interfaces matching backend schema
- [ ] Implement `getDashboard()` API function
- **Validation**: TypeScript compiles without errors
### Task 2.2: Implement Dashboard Components
- [ ] Create `StatisticsCard` component for task stats
- [ ] Create `WorkloadWidget` component (reuse styles from WorkloadPage)
- [ ] Create `HealthSummaryWidget` component
- [ ] Create `QuickActions` component with navigation links
- **Validation**: Components render without console errors
### Task 2.3: Integrate Dashboard Page
- [ ] Replace placeholder content in `Dashboard.tsx`
- [ ] Add loading state with Skeleton components
- [ ] Add error handling with retry
- [ ] Style components to match existing UI
- **Validation**: Dashboard displays data correctly after login
## Phase 3: Testing & Polish
### Task 3.1: Add Backend Tests
- [ ] Create `backend/tests/test_dashboard.py`
- [ ] Test endpoint returns correct structure
- [ ] Test data aggregation logic
- **Validation**: `pytest tests/test_dashboard.py -v` passes
### Task 3.2: Visual Polish
- [ ] Ensure responsive layout for mobile
- [ ] Match color scheme with existing pages
- [ ] Add subtle animations for loading states
- **Validation**: Manual visual review on different screen sizes
## Dependencies
- Tasks 2.x depend on Task 1.2 completion
- Task 3.1 can run in parallel with Phase 2

View File

@@ -0,0 +1,69 @@
# dashboard Specification
## Purpose
TBD - created by archiving change add-dashboard-widgets. Update Purpose after archive.
## Requirements
### Requirement: Dashboard Statistics
The system SHALL display aggregated statistics showing the user's task overview.
#### Scenario: User views task statistics
- Given: User is authenticated
- When: User navigates to Dashboard
- Then: System displays:
- Total tasks assigned to user
- Tasks due this week
- Overdue tasks count
- Completion rate percentage
### Requirement: My Workload Widget
The system SHALL display the current user's workload summary for the current week.
#### Scenario: User views personal workload
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays:
- Allocated hours vs capacity hours
- Load percentage with visual indicator
- Load level status (normal/warning/overloaded)
### Requirement: Project Health Summary
The system SHALL display an aggregated project health summary.
#### Scenario: User views project health overview
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays:
- Total projects count
- Healthy/At-Risk/Critical breakdown
- Average health score
- Projects with blockers count
### Requirement: Quick Actions
The system SHALL provide quick navigation links to common actions.
#### Scenario: User accesses quick actions
- Given: User is authenticated
- When: User views Dashboard
- Then: System displays navigation links to:
- Spaces page
- Workload page
- Project Health page
- (Admin only) Audit page
### Requirement: Dashboard API Endpoint
The backend SHALL provide a single aggregated endpoint for dashboard data.
#### Scenario: Frontend fetches dashboard data
- Given: User is authenticated
- When: Frontend requests GET /api/dashboard
- Then: Backend returns:
- User task statistics
- Current week workload summary
- Project health summary
- And: Response is optimized with single database query where possible