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:
parent
2b4fd5dee8
commit
732e692c8a
31 changed files with 1147 additions and 122 deletions
23
alembic/versions/0006_session_ai.py
Normal file
23
alembic/versions/0006_session_ai.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
54
src/services/ai_session_summary.py
Normal file
54
src/services/ai_session_summary.py
Normal 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": ""}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
6
web/src/api/endpoints/projects.ts
Normal file
6
web/src/api/endpoints/projects.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import apiClient from '@/api/client'
|
||||
import type { ProjectOut } from '@/types'
|
||||
|
||||
export const projectsApi = {
|
||||
list: () => apiClient.get<ProjectOut[]>('/api/projects'),
|
||||
}
|
||||
|
|
@ -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}…`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
24
web/src/stores/projects.ts
Normal file
24
web/src/stores/projects.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
])
|
||||
|
|
|
|||
159
web/src/views/DevopsView.vue
Normal file
159
web/src/views/DevopsView.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue