fix: calendar datetime filters, tasks N+1 query, AI report hours accuracy

- calendar.py: replace fragile string-concat datetime filters with proper
  datetime.combine(date, time.min/max) for PlannedBlock and ManualEntry overlap
- tasks.py list_tasks: batch-load projects + work items in 2 queries instead
  of N individual lookups per task (fixes N+1 performance issue)
- ai_reports.py: use wall-clock union hours + overhead for report totals,
  consistent with KPI summary endpoint; import _union_hours from aggregator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 18:46:47 +01:00
parent 908205da44
commit ac6ba28008
3 changed files with 70 additions and 16 deletions

View file

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

View file

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

View file

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