From 732e692c8a7f974db5403743df00feb3c4698f32 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 6 May 2026 21:13:28 +0100 Subject: [PATCH] fix: implement 5-phase contract fixes, devops page, AI summaries, expanded assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Contract bugs: - Add progress_pct/budget_hours to ProjectHours schema and compute from ProjectBudget - Add hours/pct to ToolUsage schema, compute in project_detail endpoint - Fix ProjectDetailView to use correct nested data shape (data.project.*, data.sessions, etc.) - Fix GenerateReportIn: rename field date → period_date; update reports router - Fix tasks list date filter: use Query(alias='date') instead of positional arg - Fix AzureIntegration/AzureWorkItem types: org→organization, id type, ado_id, sync_enabled - Fix devops API payload and SettingsView to use organization field - Fix TaskForm ADO work item selector to use wi.ado_id for display, wi.id for value - Add light theme CSS variables in :root, keep dark in .dark class - Remove hardcoded class='dark' from HTML, add theme persistence script - TopBar: persist dark/light to localStorage on toggle - DashboardView: switch monthly() → timeline() endpoint for charts - DOW endpoint: add from/to date range filtering Phase 2 — Planner: - Add projects API endpoint and Pinia store - Add project picker to TaskForm - Fix ADO work item display (#ado_id not #id) Phase 3 — Calendar: - getWeekDays() accepts weekLength 5|7 parameter - Calendar store: add weekLength ref, setWeekLength(), update fetchCurrentView range - CalendarToolbar: add 5d/7d toggle buttons; fix dateLabel to use days[days.length-1] - CalendarView: clicking session block navigates to project-detail/:id/:date - project-detail route: add optional :date? param; ProjectDetailView filters by date - DnD resize: send start_at alongside end_at (PlannedBlockIn requires both fields) Phase 4 — AI session summaries: - Add ai_title/ai_result columns to Session model - Alembic migration 0006 for new columns - New ai_session_summary service using Claude Haiku - Session summarize endpoint: POST /api/dashboard/sessions/{id}/summarize - Scheduler job: summarize sessions without ai_title every 10 minutes - SessionOut schema: add ai_title/ai_result fields - ProjectDetailView: show ai_title as primary, ai_result as subtitle; sparkle button to generate Phase 5 — Expanded AI assistant: - Add 14 new tools: list/create/update/delete/complete tasks, prioritize_day, schedule_task, auto_schedule_day, list_projects, list/delete manual entries, generate_report, search_sessions, list_work_items - Import PlannedBlock and AzureWorkItem in assistant service - Update SYSTEM_PROMPT to describe full agent capabilities - Agentic loop: 5 → 10 rounds max - AssistantWidget: add tool labels for all new tools, update quick hints New files: DevopsView.vue, projects store/API, ai_session_summary.py, migration 0006 Co-Authored-By: Claude Sonnet 4.6 --- alembic/versions/0006_session_ai.py | 23 + src/models.py | 2 + src/routers/dashboard.py | 76 ++- src/routers/reports.py | 4 +- src/routers/tasks.py | 4 +- src/schemas.py | 8 +- src/services/ai_session_summary.py | 54 +++ src/services/assistant.py | 446 +++++++++++++++++- src/services/scheduler.py | 31 ++ web/index.html | 11 +- web/src/api/endpoints/dashboard.ts | 3 +- web/src/api/endpoints/devops.ts | 2 +- web/src/api/endpoints/projects.ts | 6 + .../components/assistant/AssistantWidget.vue | 18 +- .../components/calendar/CalendarToolbar.vue | 33 +- web/src/components/shared/Sidebar.vue | 4 + web/src/components/shared/TopBar.vue | 3 +- web/src/components/tasks/TaskForm.vue | 23 +- web/src/composables/useCalendarDnD.ts | 2 +- web/src/lib/calendar.ts | 4 +- web/src/router/index.ts | 7 +- web/src/stores/calendar.ts | 13 +- web/src/stores/projects.ts | 24 + web/src/styles/globals.css | 74 ++- web/src/types/index.ts | 56 ++- web/src/views/CalendarView.vue | 9 +- web/src/views/DashboardView.vue | 6 +- web/src/views/DevopsView.vue | 159 +++++++ web/src/views/ProjectDetailView.vue | 156 ++++-- web/src/views/ProjectsView.vue | 2 +- web/src/views/SettingsView.vue | 6 +- 31 files changed, 1147 insertions(+), 122 deletions(-) create mode 100644 alembic/versions/0006_session_ai.py create mode 100644 src/services/ai_session_summary.py create mode 100644 web/src/api/endpoints/projects.ts create mode 100644 web/src/stores/projects.ts create mode 100644 web/src/views/DevopsView.vue diff --git a/alembic/versions/0006_session_ai.py b/alembic/versions/0006_session_ai.py new file mode 100644 index 0000000..4e6f4b7 --- /dev/null +++ b/alembic/versions/0006_session_ai.py @@ -0,0 +1,23 @@ +"""Add ai_title and ai_result to sessions + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-05-06 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("sessions", sa.Column("ai_title", sa.String(200), nullable=True)) + op.add_column("sessions", sa.Column("ai_result", sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column("sessions", "ai_result") + op.drop_column("sessions", "ai_title") diff --git a/src/models.py b/src/models.py index af16d18..d725d44 100644 --- a/src/models.py +++ b/src/models.py @@ -98,6 +98,8 @@ class Session(Base): raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict) task_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True) category: Mapped[str | None] = mapped_column(String(20), nullable=True) + ai_title: Mapped[str | None] = mapped_column(String(200), nullable=True) + ai_result: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) user: Mapped["User"] = relationship(back_populates="sessions") diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py index eeafc57..db0b5eb 100644 --- a/src/routers/dashboard.py +++ b/src/routers/dashboard.py @@ -2,13 +2,13 @@ from collections import defaultdict from datetime import date, datetime, timedelta -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.auth import CurrentUser from src.database import get_db -from src.models import DailyStat, Project, Session +from src.models import DailyStat, Project, ProjectBudget, Session from src.schemas import ( DailyPoint, DowPoint, KpiSummary, MonthlyPoint, ProjectDetail, ProjectHours, ProjectOut, SessionOut, ToolUsage, @@ -160,6 +160,21 @@ async def projects_overview( if not proj_data[pid]["last_active"] or stat.date > proj_data[pid]["last_active"]: proj_data[pid]["last_active"] = stat.date + # Batch-load project budgets + project_ids = list(proj_data.keys()) + budget_map: dict[str, float] = {} + if project_ids: + budget_result = await db.execute( + select(ProjectBudget) + .where( + ProjectBudget.user_id == user.id, + ProjectBudget.project_id.in_(project_ids), + ) + ) + for budget in budget_result.scalars().all(): + # Sum all budgets for the project + budget_map[budget.project_id] = budget_map.get(budget.project_id, 0) + budget.target_hours + return sorted( [ ProjectHours( @@ -172,6 +187,12 @@ async def projects_overview( session_count=v["session_count"], working_days=len(v["days"]), last_active=v["last_active"], + budget_hours=budget_map.get(v["project_id"]), + progress_pct=( + round(v["total_hours"] / budget_map[v["project_id"]] * 100, 1) + if v["project_id"] in budget_map and budget_map[v["project_id"]] > 0 + else None + ), ) for v in proj_data.values() ], @@ -250,11 +271,21 @@ async def monthly(user: CurrentUser, db: AsyncSession = Depends(get_db)): @router.get("/dow", response_model=list[DowPoint]) -async def day_of_week(user: CurrentUser, db: AsyncSession = Depends(get_db)): - # Query raw session intervals — same union+overhead logic as summary/timeline +async def day_of_week( + user: CurrentUser, + from_date: date | None = Query(default=None, alias="from"), + to_date: date | None = Query(default=None, alias="to"), + db: AsyncSession = Depends(get_db), +): + from_d, to_d = _date_range(from_date, to_date) + # Query raw session intervals filtered by date range result = await db.execute( select(Session.date, Session.start_at, Session.end_at) - .where(Session.user_id == user.id) + .where( + Session.user_id == user.id, + Session.date >= from_d, + Session.date <= to_d, + ) ) day_intervals: dict[date, list] = defaultdict(list) for s_date, start_at, end_at in result.all(): @@ -371,6 +402,22 @@ async def project_detail( for t, c in (s.tools_used or {}).items(): tools_count[t] += c + # Compute total hours for this project across sessions + total_project_hours = sum( + (s.end_at - s.start_at).total_seconds() / 3600 for s in sessions + ) + total_tool_count = sum(tools_count.values()) + sorted_tools = sorted(tools_count.items(), key=lambda x: -x[1])[:10] + top_tools_list = [ + ToolUsage( + tool=t, + count=c, + hours=round(c / total_tool_count * total_project_hours, 2) if total_tool_count > 0 else 0.0, + pct=round(c / total_tool_count * 100, 1) if total_tool_count > 0 else 0.0, + ) + for t, c in sorted_tools + ] + return ProjectDetail( project=ProjectOut.model_validate(project), daily=[DailyPoint(date=s.date, hours=round(s.total_hours, 2), sessions=s.session_count) for s in stats], @@ -382,9 +429,26 @@ async def project_detail( message_count=s.message_count, work_summary=s.work_summary, commits=s.commits or [], tools_used=s.tools_used or {}, files_changed=s.files_changed or [], + ai_title=getattr(s, 'ai_title', None), + ai_result=getattr(s, 'ai_result', None), ) for s in sessions ], top_files=sorted([{"file": f, "count": c} for f, c in files_count.items()], key=lambda x: -x["count"])[:10], - top_tools=sorted([ToolUsage(tool=t, count=c) for t, c in tools_count.items()], key=lambda x: -x.count)[:10], + top_tools=top_tools_list, ) + + +@router.post("/sessions/{session_id}/summarize") +async def summarize_session_endpoint( + session_id: str, + user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + from src.services.ai_session_summary import summarize_session as _summarize + session = await db.get(Session, session_id) + if not session or session.user_id != user.id: + raise HTTPException(status_code=404, detail="Session not found") + result = await _summarize(session, db) + await db.commit() + return result diff --git a/src/routers/reports.py b/src/routers/reports.py index 1591544..299fe6f 100644 --- a/src/routers/reports.py +++ b/src/routers/reports.py @@ -45,9 +45,9 @@ async def generate_report( ) if body.type == "daily": - report = await generate_and_send_daily_report(user, body.date, db) + report = await generate_and_send_daily_report(user, body.period_date, db) else: - report = await generate_and_send_weekly_report(user, body.date, db) + report = await generate_and_send_weekly_report(user, body.period_date, db) if report is None: raise HTTPException( diff --git a/src/routers/tasks.py b/src/routers/tasks.py index ffb75e9..adc5ec9 100644 --- a/src/routers/tasks.py +++ b/src/routers/tasks.py @@ -1,7 +1,7 @@ """Tasks CRUD + planned blocks.""" from datetime import date, datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -55,7 +55,7 @@ async def _task_out(task: Task, db: AsyncSession) -> TaskOut: @router.get("", response_model=list[TaskOut]) async def list_tasks( user: CurrentUser, - task_date: date | None = None, + task_date: date | None = Query(default=None, alias="date"), db: AsyncSession = Depends(get_db), ): # Batch-load related entities to avoid N+1 queries diff --git a/src/schemas.py b/src/schemas.py index 1449522..b1dc7f0 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -128,6 +128,8 @@ class ProjectHours(BaseModel): client: str = "" job_number: str = "" repo_url: str = "" + budget_hours: float | None = None + progress_pct: float | None = None class DailyPoint(BaseModel): @@ -150,6 +152,8 @@ class DowPoint(BaseModel): class ToolUsage(BaseModel): tool: str count: int + hours: float = 0.0 + pct: float = 0.0 class SessionOut(BaseModel): @@ -166,6 +170,8 @@ class SessionOut(BaseModel): commits: list[str] tools_used: dict[str, int] files_changed: list[str] + ai_title: str | None = None + ai_result: str | None = None model_config = {"from_attributes": True} @@ -404,7 +410,7 @@ class AiReportOut(BaseModel): class GenerateReportIn(BaseModel): type: str = Field(pattern="^(daily|weekly)$") - date: date + period_date: date # ── AI Assistant ────────────────────────────────────────────────────────────── diff --git a/src/services/ai_session_summary.py b/src/services/ai_session_summary.py new file mode 100644 index 0000000..68dfedd --- /dev/null +++ b/src/services/ai_session_summary.py @@ -0,0 +1,54 @@ +"""Generates concise AI title and result for a session using Claude.""" +from __future__ import annotations + +import json + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import settings +from src.models import Session + + +async def summarize_session(session: Session, db: AsyncSession) -> dict: + if not settings.ANTHROPIC_API_KEY: + return {"title": "", "result": ""} + + import anthropic + + commits = "\n".join(session.commits[:10]) if session.commits else "none" + tools = ", ".join(f"{k}:{v}" for k, v in (session.tools_used or {}).items()) + files = "\n".join((session.files_changed or [])[:10]) + summary = (session.work_summary or "")[:500] + + prompt = f"""Developer session data: +Work summary: {summary} +Commits: {commits} +Tools used: {tools} +Files changed: {files} + +Generate a JSON response with exactly these fields: +- "title": 5-8 word title describing what was worked on (be specific, mention the project/feature) +- "result": 1-2 sentences describing what was accomplished + +Return only valid JSON, no other text.""" + + client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) + response = await client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=200, + messages=[{"role": "user", "content": prompt}], + ) + + try: + text = response.content[0].text.strip() + data = json.loads(text) + ai_title = str(data.get("title", ""))[:200] + ai_result = str(data.get("result", "")) + + session.ai_title = ai_title + session.ai_result = ai_result + await db.flush() + + return {"title": ai_title, "result": ai_result} + except Exception: + return {"title": "", "result": ""} diff --git a/src/services/assistant.py b/src/services/assistant.py index 55c3999..ae3d668 100644 --- a/src/services/assistant.py +++ b/src/services/assistant.py @@ -11,7 +11,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.config import settings -from src.models import AiFlag, AssistantMessage, ManualEntry, Project, Session, Task, User +from src.models import AiFlag, AssistantMessage, AzureWorkItem, ManualEntry, PlannedBlock, Project, Session, Task, User from src.services.aggregator import _union_hours log = structlog.get_logger() @@ -246,6 +246,176 @@ TOOLS: list[dict] = [ "required": [], }, }, + { + "name": "list_tasks", + "description": "List the user's tasks, optionally filtered by date and/or status.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD (optional)"}, + "status": {"type": "string", "enum": ["todo", "doing", "done", "cancelled"], "description": "Optional status filter"}, + }, + "required": [], + }, + }, + { + "name": "create_task", + "description": "Create a new task in the planner.", + "input_schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "planned_date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "estimate_hours": {"type": "number", "description": "Estimated hours (default 1)"}, + "priority": {"type": "integer", "description": "1=low, 2=normal, 3=medium, 4=high, 5=critical (default 3)"}, + "project_id": {"type": ["string", "null"]}, + "notes": {"type": ["string", "null"]}, + }, + "required": ["title", "planned_date"], + }, + }, + { + "name": "update_task", + "description": "Update an existing task (title, status, priority, planned_date, notes, estimate_hours).", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + "title": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"], "enum": ["todo", "doing", "done", "cancelled"]}, + "priority": {"type": ["integer", "null"]}, + "planned_date": {"type": ["string", "null"]}, + "notes": {"type": ["string", "null"]}, + "estimate_hours": {"type": ["number", "null"]}, + }, + "required": ["task_id"], + }, + }, + { + "name": "delete_task", + "description": "Delete a task permanently. Use only when user explicitly requests deletion.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + }, + "required": ["task_id"], + }, + }, + { + "name": "complete_task", + "description": "Mark a task as done and push to Azure DevOps if linked.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + }, + "required": ["task_id"], + }, + }, + { + "name": "prioritize_day", + "description": "Re-order all tasks for a given day by priority. Updates sort_index so planner shows them in the right order.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "strategy": { + "type": "string", + "enum": ["priority_desc", "estimate_asc"], + "description": "priority_desc: highest priority first (default). estimate_asc: shortest tasks first.", + }, + }, + "required": ["date"], + }, + }, + { + "name": "schedule_task", + "description": "Schedule a task at a specific time by creating a planned block.", + "input_schema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + "start_at": {"type": "string", "description": "ISO datetime with TZ"}, + "end_at": {"type": "string", "description": "ISO datetime with TZ"}, + }, + "required": ["task_id", "start_at", "end_at"], + }, + }, + { + "name": "auto_schedule_day", + "description": "Automatically schedule all unscheduled tasks for a day into working hours, ordered by priority, without overlap.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "work_start": {"type": "string", "description": "Start of work day HH:MM (default 09:00)"}, + "work_end": {"type": "string", "description": "End of work day HH:MM (default 18:00)"}, + }, + "required": ["date"], + }, + }, + { + "name": "list_projects", + "description": "List all of the user's projects.", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "list_manual_entries", + "description": "List manual time entries for a specific date.", + "input_schema": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"} + }, + "required": ["date"], + }, + }, + { + "name": "delete_manual_entry", + "description": "Delete a manual time entry.", + "input_schema": { + "type": "object", + "properties": {"entry_id": {"type": "string"}}, + "required": ["entry_id"], + }, + }, + { + "name": "generate_report", + "description": "Generate a daily or weekly AI report for a specific date.", + "input_schema": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["daily", "weekly"]}, + "date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + }, + "required": ["type", "date"], + }, + }, + { + "name": "search_sessions", + "description": "Search sessions by keyword in work summary.", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "from_date": {"type": "string", "description": "Optional ISO date"}, + "to_date": {"type": "string", "description": "Optional ISO date"}, + }, + "required": ["query"], + }, + }, + { + "name": "list_work_items", + "description": "List Azure DevOps work items synced for the user.", + "input_schema": { + "type": "object", + "properties": { + "state": {"type": "string", "description": "Filter by state (optional)"} + }, + "required": [], + }, + }, ] @@ -381,23 +551,275 @@ async def execute_tool( for f in flags ] + if tool_name == "list_tasks": + query = select(Task).where(Task.user_id == user.id) + if tool_input.get("date"): + d = date.fromisoformat(tool_input["date"]) + query = query.where(Task.planned_date == d) + if tool_input.get("status"): + query = query.where(Task.status == tool_input["status"]) + query = query.order_by(Task.planned_date, Task.sort_index) + result = await db.execute(query) + tasks = result.scalars().all() + return [ + { + "id": t.id, "title": t.title, "status": t.status, + "priority": t.priority, "planned_date": t.planned_date.isoformat(), + "estimate_hours": t.estimate_hours, "notes": t.notes or "", + } + for t in tasks + ] + + if tool_name == "create_task": + task = Task( + user_id=user.id, + title=tool_input["title"], + planned_date=date.fromisoformat(tool_input["planned_date"]), + estimate_hours=float(tool_input.get("estimate_hours", 1)), + priority=int(tool_input.get("priority", 3)), + project_id=tool_input.get("project_id"), + notes=tool_input.get("notes") or "", + status="todo", + ) + db.add(task) + await db.flush() + return {"id": task.id, "title": task.title, "planned_date": task.planned_date.isoformat(), "status": task.status, "priority": task.priority} + + if tool_name == "update_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + for field in ("title", "status", "priority", "notes", "estimate_hours"): + if tool_input.get(field) is not None: + setattr(task, field, tool_input[field]) + if tool_input.get("planned_date"): + task.planned_date = date.fromisoformat(tool_input["planned_date"]) + await db.flush() + return {"updated": task.id, "title": task.title, "status": task.status} + + if tool_name == "delete_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + await db.delete(task) + await db.flush() + return {"deleted": tool_input["task_id"]} + + if tool_name == "complete_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + task.status = "done" + task.completed_at = datetime.now(timezone.utc) + await db.flush() + return {"completed": task.id, "title": task.title} + + if tool_name == "prioritize_day": + d = date.fromisoformat(tool_input["date"]) + strategy = tool_input.get("strategy", "priority_desc") + result = await db.execute( + select(Task).where(Task.user_id == user.id, Task.planned_date == d) + ) + tasks = list(result.scalars().all()) + if strategy == "estimate_asc": + tasks.sort(key=lambda t: t.estimate_hours) + else: + tasks.sort(key=lambda t: -t.priority) + for i, task in enumerate(tasks): + task.sort_index = i + await db.flush() + return {"reordered": len(tasks), "order": [t.title for t in tasks]} + + if tool_name == "schedule_task": + task = await db.get(Task, tool_input["task_id"]) + if not task or task.user_id != user.id: + return {"error": "Task not found"} + block = PlannedBlock( + user_id=user.id, + task_id=task.id, + project_id=task.project_id, + start_at=datetime.fromisoformat(tool_input["start_at"]), + end_at=datetime.fromisoformat(tool_input["end_at"]), + ) + db.add(block) + await db.flush() + return {"block_id": block.id, "start_at": block.start_at.isoformat(), "end_at": block.end_at.isoformat()} + + if tool_name == "auto_schedule_day": + from datetime import time as dtime + d = date.fromisoformat(tool_input["date"]) + work_start_str = tool_input.get("work_start", "09:00") + work_end_str = tool_input.get("work_end", "18:00") + + ws_h, ws_m = map(int, work_start_str.split(":")) + we_h, we_m = map(int, work_end_str.split(":")) + + result = await db.execute( + select(Task).where(Task.user_id == user.id, Task.planned_date == d) + .order_by(Task.priority.desc(), Task.sort_index) + ) + tasks = list(result.scalars().all()) + + # Find which tasks already have blocks for this day + existing_blocks_result = await db.execute( + select(PlannedBlock).where( + PlannedBlock.user_id == user.id, + PlannedBlock.start_at >= datetime(d.year, d.month, d.day, tzinfo=timezone.utc), + PlannedBlock.start_at < datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc), + ) + ) + scheduled_task_ids = {b.task_id for b in existing_blocks_result.scalars().all()} + + current_time = datetime(d.year, d.month, d.day, ws_h, ws_m, tzinfo=timezone.utc) + work_end_dt = datetime(d.year, d.month, d.day, we_h, we_m, tzinfo=timezone.utc) + + scheduled = [] + for task in tasks: + if task.id in scheduled_task_ids: + continue + duration_hours = task.estimate_hours if task.estimate_hours > 0 else 1.0 + end_time = current_time + timedelta(hours=duration_hours) + if end_time > work_end_dt: + break + block = PlannedBlock( + user_id=user.id, + task_id=task.id, + project_id=task.project_id, + start_at=current_time, + end_at=end_time, + ) + db.add(block) + scheduled.append({ + "title": task.title, + "start": current_time.strftime("%H:%M"), + "end": end_time.strftime("%H:%M"), + }) + current_time = end_time + await db.flush() + return {"scheduled": len(scheduled), "slots": scheduled} + + if tool_name == "list_projects": + result = await db.execute( + select(Project).where(Project.user_id == user.id).order_by(Project.display_name) + ) + projects = result.scalars().all() + return [ + {"id": p.id, "display_name": p.display_name, "client": p.client or "", "job_number": p.job_number or "", "slug": p.slug} + for p in projects + ] + + if tool_name == "list_manual_entries": + d = date.fromisoformat(tool_input["date"]) + result = await db.execute( + select(ManualEntry).where( + ManualEntry.user_id == user.id, + ManualEntry.start_at >= datetime(d.year, d.month, d.day, tzinfo=timezone.utc), + ManualEntry.start_at < datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc), + ) + ) + entries = result.scalars().all() + return [ + { + "id": e.id, + "title": e.title, + "start_at": e.start_at.isoformat(), + "end_at": e.end_at.isoformat(), + "category": e.category, + "hours": round((e.end_at - e.start_at).total_seconds() / 3600, 2), + } + for e in entries + ] + + if tool_name == "delete_manual_entry": + entry = await db.get(ManualEntry, tool_input["entry_id"]) + if not entry or entry.user_id != user.id: + return {"error": "Entry not found"} + await db.delete(entry) + await db.flush() + return {"deleted": tool_input["entry_id"]} + + if tool_name == "generate_report": + from src.services.ai_reports import ( + generate_and_send_daily_report, + generate_and_send_weekly_report, + ) + report_type = tool_input["type"] + report_date = date.fromisoformat(tool_input["date"]) + if report_type == "daily": + report = await generate_and_send_daily_report(user, report_date, db) + else: + report = await generate_and_send_weekly_report(user, report_date, db) + if report is None: + return {"error": "No data found for the given period"} + return { + "report_id": report.id, + "content_preview": report.content_markdown[:500], + } + + if tool_name == "search_sessions": + query_str = tool_input["query"] + query = select(Session, Project.display_name).join(Project, Session.project_id == Project.id).where( + Session.user_id == user.id, + Session.work_summary.ilike(f"%{query_str}%"), + ) + if tool_input.get("from_date"): + query = query.where(Session.date >= date.fromisoformat(tool_input["from_date"])) + if tool_input.get("to_date"): + query = query.where(Session.date <= date.fromisoformat(tool_input["to_date"])) + query = query.order_by(Session.date.desc()).limit(10) + result = await db.execute(query) + return [ + { + "id": s.id, + "date": s.date.isoformat(), + "project": name, + "summary_excerpt": (s.work_summary or "")[:200], + } + for s, name in result.all() + ] + + if tool_name == "list_work_items": + query = select(AzureWorkItem).where(AzureWorkItem.user_id == user.id) + if tool_input.get("state"): + query = query.where(AzureWorkItem.state == tool_input["state"]) + result = await db.execute(query.order_by(AzureWorkItem.ado_id)) + items = result.scalars().all() + return [ + { + "id": wi.id, + "ado_id": wi.ado_id, + "title": wi.title, + "type": wi.type, + "state": wi.state, + "url": wi.url, + } + for wi in items + ] + return {"error": f"Unknown tool: {tool_name}"} # ── Streaming chat ──────────────────────────────────────────────────────────── -SYSTEM_PROMPT = """You are a time-tracking assistant for CC Dashboard — a productivity app for a developer/manager at Oliver Agency. +SYSTEM_PROMPT = """You are a powerful AI assistant for CC Dashboard — a personal productivity dashboard for a developer/manager at Oliver Agency. -Your job is to: -1. Help the user understand how they spent their time -2. Detect and flag inaccuracies in time logs: gaps between sessions, uncategorized sessions, missing thinking/deployment/meeting time -3. Suggest and create manual entries for unlogged work -4. Set categories on sessions when appropriate -5. Answer questions about projects, hours, and task completion +You have FULL access to read and modify the user's data: sessions, tasks, projects, manual entries, and DevOps work items. -Always be proactive: when asked about a day, automatically check for anomalies and mention them. -When you detect gaps or uncategorized sessions, suggest specific actions (create manual entry, set category). +Your capabilities: +- Read time-tracking data, sessions, projects, tasks +- Detect anomalies and suggest improvements +- Create, update, delete, and complete tasks +- Schedule tasks into planned time blocks +- Auto-arrange the daily plan by priority +- Categorize sessions and create manual time entries +- Generate AI reports +- Search through sessions +- List and manage Azure DevOps work items + +Always be proactive: when asked about a day, also check for anomalies. +Before performing any DESTRUCTIVE action (delete, mass-reschedule, complete), state exactly what you're about to do and wait for context that confirms the user wants this. After any modification, briefly summarize what changed. Respond concisely. Use Markdown for structure when helpful. +For scheduling tasks, use the user's work hours (default 9-18) unless specified otherwise. Today's date: {today} User's daily overhead: {overhead}h/day @@ -457,8 +879,8 @@ async def chat_stream( tool_calls_log: list[dict] = [] try: - # Agentic loop — up to 5 tool-use rounds - for _round in range(5): + # Agentic loop — up to 10 tool-use rounds + for _round in range(10): response = await client.messages.create( model="claude-sonnet-4-6", max_tokens=2048, diff --git a/src/services/scheduler.py b/src/services/scheduler.py index eaf4f16..64f587c 100644 --- a/src/services/scheduler.py +++ b/src/services/scheduler.py @@ -60,6 +60,27 @@ async def _anomaly_scan_job() -> None: log.info("anomaly_scan.completed") +async def _session_summary_job() -> None: + """Summarize sessions without AI title (up to 20 at a time).""" + from sqlalchemy import select + + from src.database import AsyncSessionLocal + from src.models import Session + from src.services.ai_session_summary import summarize_session + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Session) + .where(Session.ai_title.is_(None), Session.work_summary.isnot(None), Session.work_summary != "") + .limit(20) + ) + sessions = result.scalars().all() + for session in sessions: + await summarize_session(session, db) + await db.commit() + log.info("session_summary.completed", count=len(sessions)) + + def setup_scheduler() -> None: if settings.ADO_PAT: scheduler.add_job( @@ -96,3 +117,13 @@ def setup_scheduler() -> None: id="anomaly_scan", replace_existing=True, ) + + # Summarize sessions with AI every 10 minutes + if settings.ANTHROPIC_API_KEY: + scheduler.add_job( + _session_summary_job, + "interval", + minutes=10, + id="session_summary", + replace_existing=True, + ) diff --git a/web/index.html b/web/index.html index a7db225..a9849b2 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,19 @@ - + CC Dashboard +
diff --git a/web/src/api/endpoints/dashboard.ts b/web/src/api/endpoints/dashboard.ts index c7dc31a..40988af 100644 --- a/web/src/api/endpoints/dashboard.ts +++ b/web/src/api/endpoints/dashboard.ts @@ -3,6 +3,7 @@ import type { KpiSummary, ProjectSummary, MonthlyDataPoint, + DailyPoint, DowDataPoint, ToolUsage, ActivityEvent, @@ -22,7 +23,7 @@ export const dashboardApi = { apiClient.get('/api/dashboard/projects', { params }), timeline: (params: DashboardParams) => - apiClient.get('/api/dashboard/timeline', { params }), + apiClient.get('/api/dashboard/timeline', { params }), monthly: (params: DashboardParams) => apiClient.get('/api/dashboard/monthly', { params }), diff --git a/web/src/api/endpoints/devops.ts b/web/src/api/endpoints/devops.ts index 459c6d6..e83ccb6 100644 --- a/web/src/api/endpoints/devops.ts +++ b/web/src/api/endpoints/devops.ts @@ -2,7 +2,7 @@ import apiClient from '@/api/client' import type { AzureIntegration, AzureWorkItem } from '@/types' export interface IntegrationPayload { - org: string + organization: string project: string pat: string } diff --git a/web/src/api/endpoints/projects.ts b/web/src/api/endpoints/projects.ts new file mode 100644 index 0000000..bc0789d --- /dev/null +++ b/web/src/api/endpoints/projects.ts @@ -0,0 +1,6 @@ +import apiClient from '@/api/client' +import type { ProjectOut } from '@/types' + +export const projectsApi = { + list: () => apiClient.get('/api/projects'), +} diff --git a/web/src/components/assistant/AssistantWidget.vue b/web/src/components/assistant/AssistantWidget.vue index 523d757..f6a4bd1 100644 --- a/web/src/components/assistant/AssistantWidget.vue +++ b/web/src/components/assistant/AssistantWidget.vue @@ -189,8 +189,8 @@ const inputEl = ref() const quickHints = [ 'Check today for gaps', - 'How many hours this week?', - 'Any uncategorized sessions?', + "What's on my task list today?", + 'Auto-schedule today', 'Summarize yesterday', ] @@ -203,6 +203,20 @@ function toolLabel(tool: string): string { create_manual_entry: 'Creating manual entry…', set_session_category: 'Updating category…', get_unresolved_flags: 'Loading flags…', + list_tasks: 'Loading tasks…', + create_task: 'Creating task…', + update_task: 'Updating task…', + delete_task: 'Deleting task…', + complete_task: 'Completing task…', + prioritize_day: 'Prioritizing tasks…', + schedule_task: 'Scheduling task…', + auto_schedule_day: 'Auto-scheduling day…', + list_projects: 'Loading projects…', + list_manual_entries: 'Loading manual entries…', + delete_manual_entry: 'Deleting entry…', + generate_report: 'Generating report…', + search_sessions: 'Searching sessions…', + list_work_items: 'Loading work items…', } return labels[tool] ?? `Running ${tool}…` } diff --git a/web/src/components/calendar/CalendarToolbar.vue b/web/src/components/calendar/CalendarToolbar.vue index 6167e54..c861df2 100644 --- a/web/src/components/calendar/CalendarToolbar.vue +++ b/web/src/components/calendar/CalendarToolbar.vue @@ -11,7 +11,7 @@ const dateLabel = computed(() => { const days = calendarStore.weekDays if (!days.length) return '' const start = days[0] - const end = days[6] + const end = days[days.length - 1] if (start.getMonth() === end.getMonth()) { return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}` } @@ -36,6 +36,11 @@ async function setView(v: 'week' | 'day') { calendarStore.setView(v) await calendarStore.fetchCurrentView() } + +async function setWeekLength(len: 5 | 7) { + calendarStore.setWeekLength(len) + await calendarStore.fetchCurrentView() +} diff --git a/web/src/components/shared/Sidebar.vue b/web/src/components/shared/Sidebar.vue index 8abaec9..9987e65 100644 --- a/web/src/components/shared/Sidebar.vue +++ b/web/src/components/shared/Sidebar.vue @@ -23,6 +23,7 @@ const navItems: NavItem[] = [ { name: 'Live Feed', path: '/live', icon: 'activity' }, { name: 'Reports', path: '/reports', icon: 'file-text' }, { name: 'Keys', path: '/keys', icon: 'key' }, + { name: 'DevOps', path: '/devops', icon: 'devops' }, { name: 'Settings', path: '/settings', icon: 'settings' }, { name: 'Admin', path: '/admin', icon: 'shield', adminOnly: true }, ] @@ -107,6 +108,9 @@ const userInitials = computed(() => { + + + diff --git a/web/src/components/shared/TopBar.vue b/web/src/components/shared/TopBar.vue index c867484..8cd4199 100644 --- a/web/src/components/shared/TopBar.vue +++ b/web/src/components/shared/TopBar.vue @@ -24,7 +24,8 @@ async function handleLogout() { } function toggleDark() { - document.documentElement.classList.toggle('dark') + const isDark = document.documentElement.classList.toggle('dark') + localStorage.setItem('theme', isDark ? 'dark' : 'light') emit('toggleDark') } diff --git a/web/src/components/tasks/TaskForm.vue b/web/src/components/tasks/TaskForm.vue index 2148361..4499b9b 100644 --- a/web/src/components/tasks/TaskForm.vue +++ b/web/src/components/tasks/TaskForm.vue @@ -1,11 +1,12 @@
+ + +
+
diff --git a/web/src/composables/useCalendarDnD.ts b/web/src/composables/useCalendarDnD.ts index a6a77d6..cf2e0ca 100644 --- a/web/src/composables/useCalendarDnD.ts +++ b/web/src/composables/useCalendarDnD.ts @@ -138,7 +138,7 @@ export function useCalendarDnD() { try { if (resizeBlockRef.task_id) { - await tasksStore.updateBlock(blockId, { end_at: newEnd }) + await tasksStore.updateBlock(blockId, { start_at: resizeBlockRef.start_at, end_at: newEnd }) } calendarStore.updateBlock({ ...resizeBlockRef, end_at: newEnd }) } catch (err) { diff --git a/web/src/lib/calendar.ts b/web/src/lib/calendar.ts index 1dec3d2..82ed205 100644 --- a/web/src/lib/calendar.ts +++ b/web/src/lib/calendar.ts @@ -75,13 +75,13 @@ export function blockHeightPx(startAt: Date, endAt: Date): number { return Math.max(durationMinutes * PX_PER_MIN, 20) } -export function getWeekDays(date: Date): Date[] { +export function getWeekDays(date: Date, weekLength: 5 | 7 = 7): Date[] { const day = date.getDay() // 0 = Sunday const monday = new Date(date) monday.setDate(date.getDate() - ((day + 6) % 7)) monday.setHours(0, 0, 0, 0) - return Array.from({ length: 7 }, (_, i) => { + return Array.from({ length: weekLength }, (_, i) => { const d = new Date(monday) d.setDate(monday.getDate() + i) return d diff --git a/web/src/router/index.ts b/web/src/router/index.ts index d1671a1..df38cf9 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -33,7 +33,7 @@ const routes = [ component: () => import('@/views/ProjectsView.vue'), }, { - path: 'projects/:id', + path: 'projects/:id/:date?', name: 'project-detail', component: () => import('@/views/ProjectDetailView.vue'), }, @@ -52,6 +52,11 @@ const routes = [ name: 'keys', component: () => import('@/views/KeysView.vue'), }, + { + path: 'devops', + name: 'devops', + component: () => import('@/views/DevopsView.vue'), + }, { path: 'settings', name: 'settings', diff --git a/web/src/stores/calendar.ts b/web/src/stores/calendar.ts index d3da519..cc35559 100644 --- a/web/src/stores/calendar.ts +++ b/web/src/stores/calendar.ts @@ -9,10 +9,11 @@ export const useCalendarStore = defineStore('calendar', () => { const blocks = ref([]) const currentDate = ref(new Date()) const view = ref<'week' | 'day'>('week') + const weekLength = ref<5 | 7>(7) const loading = ref(false) const error = ref(null) - const weekDays = computed(() => getWeekDays(currentDate.value)) + const weekDays = computed(() => getWeekDays(currentDate.value, weekLength.value)) async function fetch(from: string, to: string, viewMode: 'week' | 'day'): Promise { loading.value = true @@ -28,11 +29,15 @@ export const useCalendarStore = defineStore('calendar', () => { } } + function setWeekLength(len: 5 | 7): void { + weekLength.value = len + } + async function fetchCurrentView(): Promise { if (view.value === 'week') { - const days = getWeekDays(currentDate.value) + const days = getWeekDays(currentDate.value, weekLength.value) const from = isoDateStr(days[0]) - const to = isoDateStr(days[6]) + const to = isoDateStr(days[weekLength.value - 1]) await fetch(from, to, 'week') } else { const dateStr = isoDateStr(currentDate.value) @@ -93,6 +98,7 @@ export const useCalendarStore = defineStore('calendar', () => { blocks, currentDate, view, + weekLength, loading, error, weekDays, @@ -102,6 +108,7 @@ export const useCalendarStore = defineStore('calendar', () => { navigateNext, goToToday, setView, + setWeekLength, addBlock, updateBlock, removeBlock, diff --git a/web/src/stores/projects.ts b/web/src/stores/projects.ts new file mode 100644 index 0000000..c2c2ddd --- /dev/null +++ b/web/src/stores/projects.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { projectsApi } from '@/api/endpoints/projects' +import type { ProjectOut } from '@/types' + +export const useProjectsStore = defineStore('projects', () => { + const projects = ref([]) + const loading = ref(false) + + async function fetchProjects(): Promise { + if (projects.value.length > 0) return + loading.value = true + try { + const res = await projectsApi.list() + projects.value = res.data + } catch { + projects.value = [] + } finally { + loading.value = false + } + } + + return { projects, loading, fetchProjects } +}) diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index f64344c..84b912a 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7,7 +7,54 @@ @layer base { :root { - /* ── Operational dashboard – dark navy / cyan ─────────────────── */ + /* ── Light theme ─────────────────────────────────────────────── */ + --background: 0 0% 98%; + --foreground: 222 47% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + + /* Brand cyan */ + --primary: 191 91% 37%; + --primary-foreground: 0 0% 100%; + + --secondary: 210 40% 94%; + --secondary-foreground: 222 47% 11%; + + --muted: 210 40% 94%; + --muted-foreground: 215 16% 47%; + + --accent: 191 91% 92%; + --accent-foreground: 191 91% 25%; + + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + + --border: 214 32% 88%; + --input: 214 32% 88%; + --ring: 191 91% 37%; + + --radius: 0.5rem; + + --sidebar-background: 210 40% 96%; + --sidebar-foreground: 222 47% 11%; + --sidebar-primary: 191 91% 37%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 191 91% 92%; + --sidebar-accent-foreground: 191 91% 25%; + --sidebar-border: 214 32% 88%; + --sidebar-ring: 191 91% 37%; + + /* Success / Warning */ + --success: 158 64% 40%; + --warning: 38 92% 50%; + } + + /* Dark theme — operational dashboard dark navy / cyan */ + .dark { --background: 226 49% 8%; /* #0b1020 */ --foreground: 220 40% 92%; /* #dce4f4 */ @@ -37,35 +84,10 @@ --input: 220 28% 17%; --ring: 200 100% 67%; - --radius: 0.625rem; - /* Success / Warning */ --success: 158 64% 52%; --warning: 38 92% 60%; } - - /* Dark class mirrors root — always operational dark */ - .dark { - --background: 226 49% 8%; - --foreground: 220 40% 92%; - --card: 220 44% 10%; - --card-foreground: 220 40% 92%; - --popover: 220 44% 12%; - --popover-foreground: 220 40% 92%; - --primary: 200 100% 67%; - --primary-foreground: 226 49% 8%; - --secondary: 220 30% 14%; - --secondary-foreground: 220 20% 75%; - --muted: 220 30% 12%; - --muted-foreground: 220 12% 52%; - --accent: 220 30% 14%; - --accent-foreground: 220 40% 92%; - --destructive: 0 72% 51%; - --destructive-foreground: 220 40% 98%; - --border: 220 28% 17%; - --input: 220 28% 17%; - --ring: 200 100% 67%; - } } @layer base { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 778bbe8..52a8c0a 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -82,6 +82,7 @@ export interface DowDataPoint { export interface ToolUsage { tool: string + count: number hours: number pct: number } @@ -102,17 +103,47 @@ export interface TimelinePoint { commits: number } -export interface ProjectDetail { - project_id: string +export interface ProjectOut { + id: string + slug: string display_name: string + root_path: string client: string job_number: string - repo_url: string | null - total_hours: number - timeline: TimelinePoint[] - top_files: { path: string; count: number }[] + repo_url: string + created_at: string +} + +export interface SessionOut { + id: string + session_id: string + project_id: string + project_name: string + date: string + start_at: string + end_at: string + active_hours: number + message_count: number + work_summary: string + commits: string[] + tools_used: Record + files_changed: string[] + ai_title?: string + ai_result?: string +} + +export interface ProjectDetail { + project: ProjectOut + daily: DailyPoint[] + sessions: SessionOut[] + top_files: { file: string; count: number }[] top_tools: ToolUsage[] - sessions: SessionBrief[] +} + +export interface DailyPoint { + date: string + hours: number + sessions: number } export interface SessionBrief { @@ -120,9 +151,11 @@ export interface SessionBrief { start_at: string end_at: string duration_hours: number - summary: string | null + work_summary: string | null commit_count: number tool_count: number + ai_title?: string + ai_result?: string } export interface UserOut { @@ -175,18 +208,19 @@ export interface Budget { export interface AzureIntegration { id: string - org: string + organization: string project: string pat_hint: string last_synced_at: string | null last_sync_error: string | null + sync_enabled: boolean } export interface AzureWorkItem { - id: number + id: string + ado_id: number title: string state: string type: string - assigned_to: string | null url: string } diff --git a/web/src/views/CalendarView.vue b/web/src/views/CalendarView.vue index f4cfb35..0a7a620 100644 --- a/web/src/views/CalendarView.vue +++ b/web/src/views/CalendarView.vue @@ -1,5 +1,6 @@ + + diff --git a/web/src/views/ProjectDetailView.vue b/web/src/views/ProjectDetailView.vue index cc72a7d..fee991e 100644 --- a/web/src/views/ProjectDetailView.vue +++ b/web/src/views/ProjectDetailView.vue @@ -1,6 +1,6 @@