From 48607045435b241a4a736e645b0b9c626890789d Mon Sep 17 00:00:00 2001 From: beabigegg Date: Thu, 8 Jan 2026 22:52:28 +0800 Subject: [PATCH] feat: implement dashboard widgets functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/dashboard/__init__.py | 4 + backend/app/api/dashboard/router.py | 222 ++++++ backend/app/main.py | 2 + backend/app/schemas/dashboard.py | 72 ++ backend/tests/test_dashboard.py | 635 ++++++++++++++++++ .../dashboard/HealthSummaryWidget.tsx | 216 ++++++ .../src/components/dashboard/QuickActions.tsx | 136 ++++ .../components/dashboard/StatisticsCard.tsx | 90 +++ .../components/dashboard/WorkloadWidget.tsx | 148 ++++ frontend/src/components/dashboard/index.ts | 4 + frontend/src/pages/Dashboard.tsx | 269 +++++++- frontend/src/services/dashboard.ts | 46 ++ .../design.md | 101 +++ .../proposal.md | 38 ++ .../specs/dashboard/spec.md | 69 ++ .../2026-01-07-add-dashboard-widgets/tasks.md | 59 ++ openspec/specs/dashboard/spec.md | 69 ++ 17 files changed, 2152 insertions(+), 28 deletions(-) create mode 100644 backend/app/api/dashboard/__init__.py create mode 100644 backend/app/api/dashboard/router.py create mode 100644 backend/app/schemas/dashboard.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 frontend/src/components/dashboard/HealthSummaryWidget.tsx create mode 100644 frontend/src/components/dashboard/QuickActions.tsx create mode 100644 frontend/src/components/dashboard/StatisticsCard.tsx create mode 100644 frontend/src/components/dashboard/WorkloadWidget.tsx create mode 100644 frontend/src/components/dashboard/index.ts create mode 100644 frontend/src/services/dashboard.ts create mode 100644 openspec/changes/archive/2026-01-07-add-dashboard-widgets/design.md create mode 100644 openspec/changes/archive/2026-01-07-add-dashboard-widgets/proposal.md create mode 100644 openspec/changes/archive/2026-01-07-add-dashboard-widgets/specs/dashboard/spec.md create mode 100644 openspec/changes/archive/2026-01-07-add-dashboard-widgets/tasks.md create mode 100644 openspec/specs/dashboard/spec.md 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}

+ +
+
+ ) + } + + // 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 +