fix: implement 5-phase contract fixes, devops page, AI summaries, expanded assistant

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 21:13:28 +01:00
parent 2b4fd5dee8
commit 732e692c8a
31 changed files with 1147 additions and 122 deletions

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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 ──────────────────────────────────────────────────────────────

View file

@ -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": ""}

View file

@ -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,

View file

@ -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,
)

View file

@ -1,10 +1,19 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/cc-dashboard/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CC Dashboard</title>
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'light') { document.documentElement.classList.remove('dark'); }
else if (theme === 'dark') { document.documentElement.classList.add('dark'); }
else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.classList.add('dark'); }
else { document.documentElement.classList.remove('dark'); }
})();
</script>
</head>
<body>
<div id="app"></div>

View file

@ -3,6 +3,7 @@ import type {
KpiSummary,
ProjectSummary,
MonthlyDataPoint,
DailyPoint,
DowDataPoint,
ToolUsage,
ActivityEvent,
@ -22,7 +23,7 @@ export const dashboardApi = {
apiClient.get<ProjectSummary[]>('/api/dashboard/projects', { params }),
timeline: (params: DashboardParams) =>
apiClient.get<MonthlyDataPoint[]>('/api/dashboard/timeline', { params }),
apiClient.get<DailyPoint[]>('/api/dashboard/timeline', { params }),
monthly: (params: DashboardParams) =>
apiClient.get<MonthlyDataPoint[]>('/api/dashboard/monthly', { params }),

View file

@ -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
}

View file

@ -0,0 +1,6 @@
import apiClient from '@/api/client'
import type { ProjectOut } from '@/types'
export const projectsApi = {
list: () => apiClient.get<ProjectOut[]>('/api/projects'),
}

View file

@ -189,8 +189,8 @@ const inputEl = ref<HTMLTextAreaElement>()
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}`
}

View file

@ -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()
}
</script>
<template>
@ -88,5 +93,31 @@ async function setView(v: 'week' | 'day') {
Week
</button>
</div>
<!-- Week length toggle (only in week view) -->
<div v-if="calendarStore.view === 'week'" class="flex items-center rounded-md border border-border overflow-hidden">
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
calendarStore.weekLength === 5
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
@click="setWeekLength(5)"
>
5d
</button>
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
calendarStore.weekLength === 7
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
@click="setWeekLength(7)"
>
7d
</button>
</div>
</div>
</template>

View file

@ -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(() => {
<svg v-else-if="item.icon === 'key'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-muted-foreground/60 group-hover:text-muted-foreground']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<svg v-else-if="item.icon === 'devops'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-muted-foreground/60 group-hover:text-muted-foreground']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<svg v-else-if="item.icon === 'settings'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-muted-foreground/60 group-hover:text-muted-foreground']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />

View file

@ -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')
}
</script>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted } from 'vue'
import Dialog from '@/components/ui/Dialog.vue'
import Input from '@/components/ui/Input.vue'
import Textarea from '@/components/ui/Textarea.vue'
import Select from '@/components/ui/Select.vue'
import Button from '@/components/ui/Button.vue'
import { useDevopsStore } from '@/stores/devops'
import { useProjectsStore } from '@/stores/projects'
import type { Task } from '@/types'
import type { TaskCreatePayload, TaskUpdatePayload } from '@/api/endpoints/tasks'
@ -24,6 +25,11 @@ const emit = defineEmits<{
}>()
const devopsStore = useDevopsStore()
const projectsStore = useProjectsStore()
onMounted(() => {
projectsStore.fetchProjects()
})
const form = ref({
title: '',
@ -157,12 +163,23 @@ async function handleSave() {
</div>
</div>
<!-- Project -->
<div v-if="projectsStore.projects.length" class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Project</label>
<Select v-model="form.project_id" :disabled="saving" placeholder="Select project...">
<option value="">None</option>
<option v-for="proj in projectsStore.projects" :key="proj.id" :value="proj.id">
{{ proj.display_name }}{{ proj.job_number ? ` (${proj.job_number})` : '' }}
</option>
</Select>
</div>
<!-- ADO Work Item -->
<div v-if="devopsStore.workItems.length" class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Azure DevOps Work Item</label>
<Select v-model="form.azure_work_item_id" :disabled="saving" placeholder="Link work item...">
<option v-for="wi in devopsStore.workItems" :key="wi.id" :value="String(wi.id)">
#{{ wi.id }} {{ wi.title }}
<option v-for="wi in devopsStore.workItems" :key="wi.id" :value="wi.id">
#{{ wi.ado_id }} {{ wi.title }}
</option>
</Select>
</div>

View file

@ -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) {

View file

@ -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

View file

@ -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',

View file

@ -9,10 +9,11 @@ export const useCalendarStore = defineStore('calendar', () => {
const blocks = ref<CalendarBlock[]>([])
const currentDate = ref<Date>(new Date())
const view = ref<'week' | 'day'>('week')
const weekLength = ref<5 | 7>(7)
const loading = ref(false)
const error = ref<string | null>(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<void> {
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<void> {
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,

View file

@ -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<ProjectOut[]>([])
const loading = ref(false)
async function fetchProjects(): Promise<void> {
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 }
})

View file

@ -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 {

View file

@ -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<string, number>
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
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCalendarStore } from '@/stores/calendar'
import CalendarToolbar from '@/components/calendar/CalendarToolbar.vue'
import CalendarGrid from '@/components/calendar/CalendarGrid.vue'
@ -13,6 +14,7 @@ import type { TaskCreatePayload, TaskUpdatePayload } from '@/api/endpoints/tasks
const calendarStore = useCalendarStore()
const tasksStore = useTasksStore()
const router = useRouter()
const showPlanner = ref(true)
const showTaskForm = ref(false)
@ -23,7 +25,12 @@ onMounted(() => {
})
function handleBlockClick(block: CalendarBlock) {
selectedBlock.value = block
if (block.project_id && block.kind === 'session') {
const day = block.start_at.substring(0, 10)
router.push({ name: 'project-detail', params: { id: block.project_id, date: day } })
} else {
selectedBlock.value = block
}
}
async function handleCreateTask(payload: TaskCreatePayload | TaskUpdatePayload) {

View file

@ -9,7 +9,7 @@ import CardContent from '@/components/ui/CardContent.vue'
import Progress from '@/components/ui/Progress.vue'
import Button from '@/components/ui/Button.vue'
import { formatDuration, formatDate, isoDateStr } from '@/lib/utils'
import type { KpiSummary, ProjectSummary, MonthlyDataPoint, DowDataPoint, ToolUsage } from '@/types'
import type { KpiSummary, ProjectSummary, MonthlyDataPoint, DailyPoint, DowDataPoint, ToolUsage } from '@/types'
type Preset = 'today' | '7d' | '30d' | 'custom'
@ -19,7 +19,7 @@ const customTo = ref('')
const summary = ref<KpiSummary | null>(null)
const projects = ref<ProjectSummary[]>([])
const monthly = ref<MonthlyDataPoint[]>([])
const monthly = ref<DailyPoint[]>([])
const dow = ref<DowDataPoint[]>([])
const tools = ref<ToolUsage[]>([])
const loading = ref(false)
@ -50,7 +50,7 @@ async function loadData() {
const [s, p, m, d, t] = await Promise.all([
dashboardApi.summary(params),
dashboardApi.projects(params),
dashboardApi.monthly(params),
dashboardApi.timeline(params),
dashboardApi.dow(params),
dashboardApi.tools(params),
])

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useDevopsStore } from '@/stores/devops'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Button from '@/components/ui/Button.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { toast } from 'vue-sonner'
const router = useRouter()
const devopsStore = useDevopsStore()
type StateFilter = 'All' | 'Active' | 'Resolved' | 'Closed'
const stateFilter = ref<StateFilter>('All')
onMounted(async () => {
await devopsStore.fetchIntegration()
if (devopsStore.integration) {
await devopsStore.fetchWorkItems()
}
})
const filteredWorkItems = computed(() => {
if (stateFilter.value === 'All') return devopsStore.workItems
return devopsStore.workItems.filter((wi) => wi.state === stateFilter.value)
})
async function syncNow() {
try {
await devopsStore.sync()
toast.success('Sync complete')
await devopsStore.fetchWorkItems()
} catch {
toast.error(devopsStore.error ?? 'Sync failed')
}
}
</script>
<template>
<div class="p-6 space-y-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<h2 class="text-lg font-semibold text-foreground">Azure DevOps</h2>
<div class="flex items-center gap-2">
<Button
v-if="devopsStore.integration"
variant="outline"
size="sm"
:loading="devopsStore.syncing"
@click="syncNow"
>
Sync Now
</Button>
</div>
</div>
<!-- Connection status -->
<Card>
<CardContent class="pt-4">
<div v-if="devopsStore.loading && !devopsStore.integration" class="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading...</span>
</div>
<div v-else-if="devopsStore.integration" class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-[hsl(var(--success))]" />
<span class="text-sm text-foreground">
Connected to
<strong>{{ devopsStore.integration.organization }}</strong>
/
<strong>{{ devopsStore.integration.project }}</strong>
</span>
<span v-if="devopsStore.integration.last_synced_at" class="text-xs text-muted-foreground ml-2">
Last synced: {{ new Date(devopsStore.integration.last_synced_at).toLocaleString() }}
</span>
</div>
<div v-else class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-muted-foreground" />
<span class="text-sm text-muted-foreground">Not connected.</span>
<button
class="text-sm text-primary hover:underline"
@click="router.push('/settings')"
>
Go to Settings to connect
</button>
</div>
<p v-if="devopsStore.integration?.last_sync_error" class="text-xs text-destructive mt-2">
Error: {{ devopsStore.integration.last_sync_error }}
</p>
</CardContent>
</Card>
<!-- Work items list -->
<Card v-if="devopsStore.integration">
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<CardTitle class="text-sm">Work Items</CardTitle>
<!-- State filter -->
<div class="flex items-center rounded-lg border border-border overflow-hidden bg-muted/30">
<button
v-for="state in (['All', 'Active', 'Resolved', 'Closed'] as const)"
:key="state"
:class="[
'px-3 py-1 text-xs font-medium transition-colors',
stateFilter === state
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
]"
@click="stateFilter = state"
>
{{ state }}
</button>
</div>
</div>
</CardHeader>
<CardContent>
<div v-if="devopsStore.loading" class="flex items-center justify-center py-8">
<Spinner size="md" class="text-primary" />
</div>
<div v-else-if="filteredWorkItems.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No work items found
</div>
<div v-else class="space-y-1">
<div
v-for="wi in filteredWorkItems"
:key="wi.id"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/30 transition-colors"
>
<span class="text-xs font-mono text-muted-foreground w-10 shrink-0">#{{ wi.ado_id }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ wi.title }}</p>
<p class="text-xs text-muted-foreground">{{ wi.type }}</p>
</div>
<span
:class="[
'text-xs px-2 py-0.5 rounded-full shrink-0',
wi.state === 'Active' ? 'bg-blue-500/10 text-blue-400' :
wi.state === 'Resolved' ? 'bg-green-500/10 text-green-400' :
wi.state === 'Closed' ? 'bg-muted text-muted-foreground' :
'bg-muted text-muted-foreground',
]"
>
{{ wi.state }}
</span>
<a
v-if="wi.url"
:href="wi.url"
target="_blank"
class="text-xs text-primary hover:underline shrink-0"
>
Open
</a>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dashboardApi } from '@/api/endpoints/dashboard'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
@ -8,26 +8,63 @@ import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { formatDuration, formatDateTime } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth'
import type { ProjectDetail } from '@/types'
const route = useRoute()
const router = useRouter()
const projectId = route.params.id as string
const dateParam = route.params.date as string | undefined
const authStore = useAuthStore()
const detail = ref<ProjectDetail | null>(null)
const data = ref<ProjectDetail | null>(null)
const loading = ref(false)
const summarizingSession = ref<string | null>(null)
onMounted(async () => {
loading.value = true
try {
const res = await dashboardApi.project(projectId)
detail.value = res.data
const params = dateParam
? { from: dateParam, to: dateParam }
: undefined
const res = await dashboardApi.project(projectId, params)
data.value = res.data
} finally {
loading.value = false
}
})
const maxTimelineHours = () =>
Math.max(...(detail.value?.timeline.map((p) => p.hours) ?? [1]), 1)
const maxDailyHours = computed(() =>
Math.max(...(data.value?.daily.map((p) => p.hours) ?? [1]), 1)
)
async function summarizeSession(sessionId: string) {
if (summarizingSession.value) return
summarizingSession.value = sessionId
try {
const res = await fetch(`/cc-dashboard/api/dashboard/sessions/${sessionId}/summarize`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (res.ok) {
const result = await res.json()
if (data.value) {
const idx = data.value.sessions.findIndex((s) => s.id === sessionId)
if (idx !== -1) {
data.value.sessions[idx] = {
...data.value.sessions[idx],
ai_title: result.title,
ai_result: result.result,
}
}
}
}
} catch {
// ignore
} finally {
summarizingSession.value = null
}
}
</script>
<template>
@ -36,25 +73,36 @@ const maxTimelineHours = () =>
<Spinner size="lg" class="text-primary" />
</div>
<template v-else-if="detail">
<template v-else-if="data">
<!-- Header -->
<div class="mb-6">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 class="text-xl font-bold text-foreground">{{ detail.display_name }}</h2>
<div class="flex items-center gap-3 mb-1">
<h2 class="text-xl font-bold text-foreground">{{ data.project.display_name }}</h2>
<span v-if="dateParam" class="text-sm text-primary font-medium">{{ dateParam }}</span>
</div>
<div v-if="dateParam" class="mb-2">
<button
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="router.push({ name: 'project-detail', params: { id: projectId } })"
>
All time
</button>
</div>
<div class="flex items-center gap-3 mt-1 flex-wrap">
<span v-if="detail.client" class="text-sm text-muted-foreground">
{{ detail.client }}
<span v-if="data.project.client" class="text-sm text-muted-foreground">
{{ data.project.client }}
</span>
<span
v-if="detail.job_number"
v-if="data.project.job_number"
class="text-xs bg-muted text-muted-foreground px-2 py-1 rounded"
>
{{ detail.job_number }}
{{ data.project.job_number }}
</span>
<a
v-if="detail.repo_url"
:href="detail.repo_url"
v-if="data.project.repo_url"
:href="data.project.repo_url"
target="_blank"
class="text-xs text-primary hover:underline"
>
@ -63,13 +111,15 @@ const maxTimelineHours = () =>
</div>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-foreground">{{ formatDuration(detail.total_hours) }}</p>
<p class="text-2xl font-bold text-foreground">
{{ formatDuration(data.daily.reduce((sum, p) => sum + p.hours, 0)) }}
</p>
<p class="text-xs text-muted-foreground">total hours</p>
</div>
</div>
</div>
<!-- Timeline chart -->
<!-- Daily Activity chart -->
<Card class="mb-6">
<CardHeader class="pb-2">
<CardTitle class="text-sm">Daily Activity</CardTitle>
@ -77,10 +127,10 @@ const maxTimelineHours = () =>
<CardContent>
<div class="h-32 flex items-end gap-px">
<div
v-for="point in detail.timeline"
v-for="point in data.daily"
:key="point.date"
class="flex-1 bg-primary/70 hover:bg-primary rounded-t transition-colors"
:style="{ height: `${(point.hours / maxTimelineHours()) * 100}%` }"
:style="{ height: `${(point.hours / maxDailyHours) * 100}%` }"
:title="`${point.date}: ${formatDuration(point.hours)}`"
/>
</div>
@ -95,15 +145,15 @@ const maxTimelineHours = () =>
<CardTitle class="text-sm">Top Files</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.top_files.length" class="text-sm text-muted-foreground">No data</div>
<div v-if="!data.top_files.length" class="text-sm text-muted-foreground">No data</div>
<div v-else class="space-y-1.5">
<div
v-for="file in detail.top_files.slice(0, 10)"
:key="file.path"
v-for="file in data.top_files.slice(0, 10)"
:key="file.file"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground truncate max-w-[200px]" :title="file.path">
{{ file.path.split('/').pop() }}
<span class="text-muted-foreground truncate max-w-[200px]" :title="file.file">
{{ file.file.split('/').pop() }}
</span>
<span class="text-foreground shrink-0 ml-2">{{ file.count }}×</span>
</div>
@ -117,10 +167,10 @@ const maxTimelineHours = () =>
<CardTitle class="text-sm">Tool Usage</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.top_tools.length" class="text-sm text-muted-foreground">No data</div>
<div v-if="!data.top_tools.length" class="text-sm text-muted-foreground">No data</div>
<div v-else class="space-y-2">
<div
v-for="tool in detail.top_tools.slice(0, 8)"
v-for="tool in data.top_tools.slice(0, 8)"
:key="tool.tool"
class="flex items-center gap-2"
>
@ -132,7 +182,7 @@ const maxTimelineHours = () =>
/>
</div>
<span class="text-xs text-muted-foreground w-8 text-right shrink-0">
{{ tool.pct.toFixed(0) }}%
{{ (tool.pct ?? 0).toFixed(0) }}%
</span>
</div>
</div>
@ -146,24 +196,56 @@ const maxTimelineHours = () =>
<CardTitle class="text-sm">Recent Sessions</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.sessions.length" class="text-sm text-muted-foreground">No sessions</div>
<div v-if="!data.sessions.length" class="text-sm text-muted-foreground">No sessions</div>
<div v-else class="space-y-2">
<div
v-for="session in detail.sessions.slice(0, 50)"
v-for="session in data.sessions.slice(0, 50)"
:key="session.id"
class="flex items-start gap-3 py-2 border-b border-border last:border-0"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-foreground">{{ formatDateTime(session.start_at) }}</p>
<p v-if="session.summary" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ session.summary }}
<p class="text-xs font-medium text-foreground">
{{ session.ai_title || session.work_summary?.substring(0, 80) || formatDateTime(session.start_at) }}
</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ formatDateTime(session.start_at) }}</p>
<p v-if="session.ai_result" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ session.ai_result }}
</p>
<p v-else-if="session.work_summary && session.ai_title" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ session.work_summary }}
</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-foreground">{{ formatDuration(session.duration_hours) }}</p>
<p class="text-xs text-muted-foreground">
{{ session.commit_count }} commits
</p>
<div class="flex items-start gap-2 shrink-0">
<div class="text-right">
<p class="text-xs font-medium text-foreground">{{ formatDuration(session.active_hours) }}</p>
<p class="text-xs text-muted-foreground">{{ session.commits.length }} commits</p>
</div>
<button
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-primary transition-colors"
:class="{ 'opacity-50 cursor-not-allowed': summarizingSession === session.id }"
title="Generate AI summary"
@click="summarizeSession(session.id)"
>
<svg
v-if="summarizingSession !== session.id"
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
<svg
v-else
class="h-3.5 w-3.5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</button>
</div>
</div>
</div>

View file

@ -86,7 +86,7 @@ const progressColor = (pct: number | null) => {
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">Budget</span>
<span :class="proj.progress_pct > 90 ? 'text-red-400' : 'text-muted-foreground'">
{{ proj.progress_pct.toFixed(0) }}%
{{ (proj.progress_pct ?? 0).toFixed(0) }}%
</span>
</div>
<Progress

View file

@ -38,7 +38,7 @@ onMounted(() => {
}
devopsStore.fetchIntegration().then(() => {
if (devopsStore.integration) {
adoOrg.value = devopsStore.integration.org
adoOrg.value = devopsStore.integration.organization
adoProject.value = devopsStore.integration.project
}
})
@ -74,7 +74,7 @@ async function saveAdo() {
savingAdo.value = true
try {
await devopsStore.saveIntegration({
org: adoOrg.value,
organization: adoOrg.value,
project: adoProject.value,
pat: adoPat.value,
})
@ -143,7 +143,7 @@ async function syncNow() {
<CardContent class="space-y-4">
<div v-if="devopsStore.integration" class="text-xs text-muted-foreground space-y-1">
<p>
Connected to <strong class="text-foreground">{{ devopsStore.integration.org }}</strong>
Connected to <strong class="text-foreground">{{ devopsStore.integration.organization }}</strong>
/ <strong class="text-foreground">{{ devopsStore.integration.project }}</strong>
</p>
<p v-if="devopsStore.integration.last_synced_at">