diff --git a/src/routers/calendar.py b/src/routers/calendar.py index 869dfc3..4b2ef18 100644 --- a/src/routers/calendar.py +++ b/src/routers/calendar.py @@ -1,5 +1,5 @@ """Calendar endpoint — unified session/planned/manual blocks.""" -from datetime import date, timedelta +from datetime import date, datetime, time, timedelta, timezone from typing import Literal from fastapi import APIRouter, Depends, Query @@ -61,12 +61,13 @@ async def get_calendar( else: to_date = min(to_date, from_date + timedelta(days=6)) - from_iso = from_date.isoformat() - to_iso = to_date.isoformat() + # Half-open overlap predicate: block overlaps [from_date, to_date] window + window_start = datetime.combine(from_date, time.min, tzinfo=timezone.utc) + window_end = datetime.combine(to_date, time.max, tzinfo=timezone.utc) blocks: list[CalendarBlock] = [] - # Sessions + # Sessions (filtered by date column — more efficient with existing index) sessions_result = await db.execute( select(Session, Project.display_name, Project.job_number) .join(Project, Session.project_id == Project.id) @@ -93,15 +94,15 @@ async def get_calendar( ) ) - # Planned blocks + # Planned blocks — half-open overlap check using proper datetime values planned_result = await db.execute( select(PlannedBlock, Project.display_name, Project.job_number, Task.title) .outerjoin(Project, PlannedBlock.project_id == Project.id) .outerjoin(Task, PlannedBlock.task_id == Task.id) .where( PlannedBlock.user_id == user.id, - PlannedBlock.start_at <= to_iso + "T23:59:59+00:00", - PlannedBlock.end_at >= from_iso + "T00:00:00+00:00", + PlannedBlock.start_at <= window_end, + PlannedBlock.end_at >= window_start, ) .order_by(PlannedBlock.start_at) ) @@ -121,14 +122,14 @@ async def get_calendar( ) ) - # Manual entries + # Manual entries — same overlap predicate manual_result = await db.execute( select(ManualEntry, Project.display_name, Project.job_number) .outerjoin(Project, ManualEntry.project_id == Project.id) .where( ManualEntry.user_id == user.id, - ManualEntry.start_at <= to_iso + "T23:59:59+00:00", - ManualEntry.end_at >= from_iso + "T00:00:00+00:00", + ManualEntry.start_at <= window_end, + ManualEntry.end_at >= window_start, ) .order_by(ManualEntry.start_at) ) diff --git a/src/routers/tasks.py b/src/routers/tasks.py index 61f9331..ffb75e9 100644 --- a/src/routers/tasks.py +++ b/src/routers/tasks.py @@ -58,13 +58,52 @@ async def list_tasks( task_date: date | None = None, db: AsyncSession = Depends(get_db), ): + # Batch-load related entities to avoid N+1 queries query = select(Task).where(Task.user_id == user.id) if task_date is not None: query = query.where(Task.planned_date == task_date) query = query.order_by(Task.planned_date, Task.sort_index, Task.created_at) result = await db.execute(query) tasks = result.scalars().all() - return [await _task_out(t, db) for t in tasks] + + if not tasks: + return [] + + # Prefetch projects and work items in two bulk queries + project_ids = {t.project_id for t in tasks if t.project_id} + wi_ids = {t.azure_work_item_id for t in tasks if t.azure_work_item_id} + + projects: dict[str, Project] = {} + if project_ids: + proj_result = await db.execute( + select(Project).where(Project.id.in_(project_ids)) + ) + projects = {p.id: p for p in proj_result.scalars().all()} + + work_items: dict[str, AzureWorkItem] = {} + if wi_ids: + wi_result = await db.execute( + select(AzureWorkItem).where(AzureWorkItem.id.in_(wi_ids)) + ) + work_items = {w.id: w for w in wi_result.scalars().all()} + + out = [] + for t in tasks: + proj = projects.get(t.project_id) if t.project_id else None + wi = work_items.get(t.azure_work_item_id) if t.azure_work_item_id else None + out.append(TaskOut( + id=t.id, user_id=t.user_id, project_id=t.project_id, + azure_work_item_id=t.azure_work_item_id, title=t.title, + notes=t.notes or "", planned_date=t.planned_date, + estimate_hours=t.estimate_hours, actual_hours=t.actual_hours, + status=t.status, priority=t.priority, sort_index=t.sort_index, + completed_at=t.completed_at, ado_synced_at=t.ado_synced_at, + created_at=t.created_at, + project_name=proj.display_name if proj else "", + job_number=proj.job_number if proj else "", + work_item_title=wi.title if wi else "", + )) + return out @router.post("", response_model=TaskOut, status_code=201) diff --git a/src/services/ai_reports.py b/src/services/ai_reports.py index 81794b7..49bff6b 100644 --- a/src/services/ai_reports.py +++ b/src/services/ai_reports.py @@ -1,5 +1,6 @@ """AI report generation + email delivery via Mailgun.""" import logging +from collections import defaultdict from datetime import date, datetime, timedelta, timezone from sqlalchemy import select @@ -7,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.config import settings from src.models import AiReport, Project, Session, Task, User +from src.services.aggregator import _union_hours from src.services.mailgun import send_email log = logging.getLogger(__name__) @@ -27,11 +29,15 @@ async def _gather_daily_context(user: User, report_date: date, db: AsyncSession) ) tasks = tasks_result.scalars().all() - total_hours = sum(s.active_hours for s, _, _ in rows) + # Wall-clock union hours (consistent with KPI summary endpoint) + day_intervals = [(s.start_at, s.end_at) for s, _, _ in rows] + total_hours = _union_hours(day_intervals) + user.daily_overhead_hours if day_intervals else 0.0 + projects_worked: dict[str, float] = {} for s, display_name, job_number in rows: label = f"{job_number} {display_name}".strip() if job_number else display_name - projects_worked[label] = projects_worked.get(label, 0) + s.active_hours + duration_h = (s.end_at - s.start_at).total_seconds() / 3600 + projects_worked[label] = projects_worked.get(label, 0) + duration_h return { "date": report_date.isoformat(), @@ -58,10 +64,18 @@ async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession) ) ) rows = sessions_result.all() + # Wall-clock union per day + overhead — consistent with KPI summary + day_intervals_map: dict[date, list] = defaultdict(list) projects_worked: dict[str, float] = {} for s, display_name, job_number in rows: + day_intervals_map[s.date].append((s.start_at, s.end_at)) label = f"{job_number} {display_name}".strip() if job_number else display_name - projects_worked[label] = projects_worked.get(label, 0) + s.active_hours + duration_h = (s.end_at - s.start_at).total_seconds() / 3600 + projects_worked[label] = projects_worked.get(label, 0) + duration_h + + working_days_set = set(day_intervals_map.keys()) + total_hours = sum(_union_hours(v) for v in day_intervals_map.values()) + total_hours += user.daily_overhead_hours * len(working_days_set) tasks_result = await db.execute( select(Task).where( @@ -75,8 +89,8 @@ async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession) return { "week_start": week_start.isoformat(), "week_end": week_end.isoformat(), - "total_hours": round(sum(s.active_hours for s, _, _ in rows), 2), - "working_days": len({s.date for s, _, _ in rows}), + "total_hours": round(total_hours, 2), + "working_days": len(working_days_set), "session_count": len(rows), "projects": { k: round(v, 2)