diff --git a/backend/app/api/dashboard/__init__.py b/backend/app/api/dashboard/__init__.py
new file mode 100644
index 0000000..9175989
--- /dev/null
+++ b/backend/app/api/dashboard/__init__.py
@@ -0,0 +1,4 @@
+"""Dashboard API module."""
+from app.api.dashboard.router import router
+
+__all__ = ["router"]
diff --git a/backend/app/api/dashboard/router.py b/backend/app/api/dashboard/router.py
new file mode 100644
index 0000000..6e49d1b
--- /dev/null
+++ b/backend/app/api/dashboard/router.py
@@ -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,
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 049dfcf..054e85d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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.task_dependencies import router as task_dependencies_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
app = FastAPI(
@@ -82,6 +83,7 @@ app.include_router(health_router)
app.include_router(custom_fields_router)
app.include_router(task_dependencies_router)
app.include_router(admin_encryption_keys_router.router)
+app.include_router(dashboard_router, prefix="/api/dashboard", tags=["Dashboard"])
@app.get("/health")
diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py
new file mode 100644
index 0000000..e87b516
--- /dev/null
+++ b/backend/app/schemas/dashboard.py
@@ -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
diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py
new file mode 100644
index 0000000..a010a34
--- /dev/null
+++ b/backend/tests/test_dashboard.py
@@ -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"]
diff --git a/frontend/src/components/dashboard/HealthSummaryWidget.tsx b/frontend/src/components/dashboard/HealthSummaryWidget.tsx
new file mode 100644
index 0000000..0321756
--- /dev/null
+++ b/frontend/src/components/dashboard/HealthSummaryWidget.tsx
@@ -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 (
+
+
+
Project Health
+
+ {health.total_projects} projects
+
+
+
+
+ {/* Average Score Display */}
+
+
+
+ {/* Background circle */}
+
+ {/* Progress circle */}
+
+
+
+
+ {Math.round(health.average_health_score)}
+
+ Avg Score
+
+
+
+ {/* Status Breakdown */}
+
+
+
+ Healthy
+ {health.healthy_count}
+
+
+
+ At Risk
+ {health.at_risk_count}
+
+
+
+ Critical
+ {health.critical_count}
+
+
+
+
+ {/* Blockers Info */}
+ {health.projects_with_blockers > 0 && (
+
+ !
+
+ {health.projects_with_blockers} project{health.projects_with_blockers > 1 ? 's have' : ' has'} blockers
+
+
+ )}
+
+
+ )
+}
+
+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
diff --git a/frontend/src/components/dashboard/QuickActions.tsx b/frontend/src/components/dashboard/QuickActions.tsx
new file mode 100644
index 0000000..8275cd0
--- /dev/null
+++ b/frontend/src/components/dashboard/QuickActions.tsx
@@ -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 (
+
+
Quick Actions
+
+ {visibleActions.map((action) => (
+
+
+
+ {action.icon}
+
+
+
{action.label}
+
{action.description}
+
+ ))}
+
+
+ )
+}
+
+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
diff --git a/frontend/src/components/dashboard/StatisticsCard.tsx b/frontend/src/components/dashboard/StatisticsCard.tsx
new file mode 100644
index 0000000..316fe15
--- /dev/null
+++ b/frontend/src/components/dashboard/StatisticsCard.tsx
@@ -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 (
+
+
+ {icon}
+
+
+
+ {value}
+ {suffix && {suffix} }
+
+ {label}
+
+
+ )
+}
+
+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
diff --git a/frontend/src/components/dashboard/WorkloadWidget.tsx b/frontend/src/components/dashboard/WorkloadWidget.tsx
new file mode 100644
index 0000000..ae7ab2b
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkloadWidget.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import { WorkloadSummary, LoadLevel } from '../../services/dashboard'
+
+interface WorkloadWidgetProps {
+ workload: WorkloadSummary
+}
+
+// Load level configuration
+const loadLevelConfig: Record = {
+ 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 (
+
+
+
My Workload
+
+ {config.label}
+
+
+
+
+
+ {workload.allocated_hours}h
+ /
+ {workload.capacity_hours}h
+
+
+
+
+
+ {workload.load_percentage}%
+
+
+
+
+ {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.'}
+
+
+
+ )
+}
+
+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
diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts
new file mode 100644
index 0000000..f65abe1
--- /dev/null
+++ b/frontend/src/components/dashboard/index.ts
@@ -0,0 +1,4 @@
+export { StatisticsCard } from './StatisticsCard'
+export { WorkloadWidget } from './WorkloadWidget'
+export { HealthSummaryWidget } from './HealthSummaryWidget'
+export { QuickActions } from './QuickActions'
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index cf2b17d..74fc254 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,28 +1,171 @@
+import { useEffect, useState, useCallback } from 'react'
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() {
const { user } = useAuth()
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
+ {/* Statistics Cards Skeleton */}
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+ {/* Widgets Skeleton */}
+
+
+ {/* Quick Actions Skeleton */}
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+ )
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
Welcome, {user?.name}!
+
+
+
+
!
+
Unable to Load Dashboard
+
{error}
+
+ Try Again
+
+
+
+ )
+ }
+
+ // Success state
return (
-
-
Welcome, {user?.name}!
-
Email: {user?.email}
-
Role: {user?.role || 'No role assigned'}
- {user?.is_system_admin && (
-
- You have system administrator privileges.
-
- )}
-
-
-
-
Getting Started
-
- This is the Project Control system dashboard. Features will be
- added as development progresses.
+ {/* Welcome Section */}
+
+
Welcome, {user?.name}!
+
+ Here is your work overview for today
+
+ {/* Statistics Cards */}
+ {data && (
+ <>
+
+
+ 0}
+ />
+ 0}
+ />
+
+
+
+ {/* Widgets Grid */}
+
+
+
+
+
+ {/* Quick Actions */}
+
+ >
+ )}
)
}
@@ -32,23 +175,93 @@ const styles: { [key: string]: React.CSSProperties } = {
padding: '24px',
maxWidth: '1200px',
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',
- padding: '24px',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
- marginBottom: '24px',
+ padding: '20px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
},
- adminNote: {
- color: '#0066cc',
+ skeletonWidget: {
+ 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,
- marginTop: '12px',
- },
- infoCard: {
- backgroundColor: 'white',
- padding: '24px',
- borderRadius: '8px',
- boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
+ color: 'white',
+ backgroundColor: '#2196f3',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ transition: 'background-color 0.2s ease',
},
}
diff --git a/frontend/src/services/dashboard.ts b/frontend/src/services/dashboard.ts
new file mode 100644
index 0000000..3b5f14f
--- /dev/null
+++ b/frontend/src/services/dashboard.ts
@@ -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
=> {
+ const response = await api.get('/dashboard')
+ return response.data
+ },
+}
+
+export default dashboardApi
diff --git a/openspec/changes/archive/2026-01-07-add-dashboard-widgets/design.md b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/design.md
new file mode 100644
index 0000000..cc8c539
--- /dev/null
+++ b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/design.md
@@ -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
diff --git a/openspec/changes/archive/2026-01-07-add-dashboard-widgets/proposal.md b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/proposal.md
new file mode 100644
index 0000000..a74afaa
--- /dev/null
+++ b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/proposal.md
@@ -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.
diff --git a/openspec/changes/archive/2026-01-07-add-dashboard-widgets/specs/dashboard/spec.md b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/specs/dashboard/spec.md
new file mode 100644
index 0000000..dc37010
--- /dev/null
+++ b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/specs/dashboard/spec.md
@@ -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
diff --git a/openspec/changes/archive/2026-01-07-add-dashboard-widgets/tasks.md b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/tasks.md
new file mode 100644
index 0000000..a208fa2
--- /dev/null
+++ b/openspec/changes/archive/2026-01-07-add-dashboard-widgets/tasks.md
@@ -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
diff --git a/openspec/specs/dashboard/spec.md b/openspec/specs/dashboard/spec.md
new file mode 100644
index 0000000..0074561
--- /dev/null
+++ b/openspec/specs/dashboard/spec.md
@@ -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
+