diff --git a/src/models.py b/src/models.py index 29ad685..441b184 100644 --- a/src/models.py +++ b/src/models.py @@ -25,7 +25,7 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(Enum("admin", "user", name="user_role"), default="user", nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - daily_overhead_hours: Mapped[float] = mapped_column(Float, default=2.0, nullable=False) + daily_overhead_hours: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="user", cascade="all, delete-orphan") diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py index 43aa3fe..058a152 100644 --- a/src/routers/dashboard.py +++ b/src/routers/dashboard.py @@ -1,6 +1,6 @@ """Dashboard aggregation endpoints.""" from collections import defaultdict -from datetime import date, timedelta +from datetime import date, datetime, timedelta from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select @@ -27,6 +27,23 @@ def _date_range(from_date: date | None, to_date: date | None): return from_date, to_date +def _union_hours(intervals: list[tuple[datetime, datetime]]) -> float: + """Total unique hours from potentially overlapping (start, end) pairs.""" + if not intervals: + return 0.0 + intervals = sorted(intervals, key=lambda x: x[0]) + seg_start, seg_end = intervals[0] + total = 0.0 + for start, end in intervals[1:]: + if start <= seg_end: + seg_end = max(seg_end, end) + else: + total += (seg_end - seg_start).total_seconds() + seg_start, seg_end = start, end + total += (seg_end - seg_start).total_seconds() + return total / 3600 + + @router.get("/summary", response_model=KpiSummary) async def summary( user: CurrentUser, @@ -55,28 +72,33 @@ async def summary( period_from=from_date, period_to=to_date, ) - # Apply overhead proportionally per day - day_totals: dict[date, float] = defaultdict(float) - for stat, _ in rows: - day_totals[stat.date] += stat.total_hours - + # Per-project hours: sum of each project's session hours proj_hours: dict[str, float] = defaultdict(float) - total_hours = total_sessions = total_commits = total_files = 0 + total_sessions = total_commits = total_files = 0 working_days: set = set() for stat, proj_name in rows: - day_total = day_totals[stat.date] - share = stat.total_hours / day_total if day_total > 0 else 0 - overhead = user.daily_overhead_hours * share - adjusted = stat.total_hours + overhead - - proj_hours[proj_name] += adjusted - total_hours += adjusted + proj_hours[proj_name] += stat.total_hours total_sessions += stat.session_count total_commits += stat.commit_count total_files += stat.files_changed_count working_days.add(stat.date) + # Total hours: union of session intervals per day to avoid double-counting parallel sessions + sessions_result = await db.execute( + select(Session.date, Session.start_at, Session.end_at) + .where( + Session.user_id == user.id, + Session.date >= from_date, + Session.date <= to_date, + ) + ) + day_intervals: dict[date, list] = defaultdict(list) + for s_date, start_at, end_at in sessions_result.all(): + day_intervals[s_date].append((start_at, end_at)) + + total_hours = sum(_union_hours(v) for v in day_intervals.values()) + top_project = max(proj_hours, key=proj_hours.get) if proj_hours else "—" n_days = len(working_days) @@ -116,10 +138,6 @@ async def projects_overview( ) rows = result.all() - day_totals: dict[date, float] = defaultdict(float) - for stat, _ in rows: - day_totals[stat.date] += stat.total_hours - proj_data: dict[str, dict] = {} for stat, project in rows: pid = project.id @@ -135,9 +153,7 @@ async def projects_overview( "days": set(), "last_active": None, } - day_total = day_totals[stat.date] - share = stat.total_hours / day_total if day_total > 0 else 0 - proj_data[pid]["total_hours"] += stat.total_hours + user.daily_overhead_hours * share + proj_data[pid]["total_hours"] += stat.total_hours proj_data[pid]["session_count"] += stat.session_count proj_data[pid]["days"].add(stat.date) if not proj_data[pid]["last_active"] or stat.date > proj_data[pid]["last_active"]: @@ -172,36 +188,36 @@ async def timeline( from_date, to_date = _date_range(from_date, to_date) days = (to_date - from_date).days + 1 - result = await db.execute( - select(DailyStat.date, func.sum(DailyStat.total_hours), func.sum(DailyStat.session_count)) + # Sessions count per day from daily_stats + stats_result = await db.execute( + select(DailyStat.date, func.sum(DailyStat.session_count)) .where( DailyStat.user_id == user.id, DailyStat.date >= from_date, DailyStat.date <= to_date, ) .group_by(DailyStat.date) - .order_by(DailyStat.date) ) - rows = result.all() + sess_map = {r[0]: int(r[1] or 0) for r in stats_result.all()} - # Build day map with overhead - day_map = {r[0]: (float(r[1] or 0), int(r[2] or 0)) for r in rows} + # Hours: union of session intervals per day + sessions_result = await db.execute( + select(Session.date, Session.start_at, Session.end_at) + .where( + Session.user_id == user.id, + Session.date >= from_date, + Session.date <= to_date, + ) + ) + day_intervals: dict[date, list] = defaultdict(list) + for s_date, start_at, end_at in sessions_result.all(): + day_intervals[s_date].append((start_at, end_at)) - # Get project counts per day for overhead distribution (simplified: add flat overhead per working day) points = [] for i in range(days): d = from_date + timedelta(days=i) - raw_h, sess = day_map.get(d, (0.0, 0)) - if raw_h > 0: - # Get distinct project count for this day to distribute overhead - proj_count_result = await db.scalar( - select(func.count(func.distinct(DailyStat.project_id))) - .where(DailyStat.user_id == user.id, DailyStat.date == d) - ) - adjusted = raw_h + user.daily_overhead_hours if proj_count_result else raw_h - else: - adjusted = 0.0 - points.append(DailyPoint(date=d, hours=round(adjusted, 2), sessions=sess)) + h = _union_hours(day_intervals.get(d, [])) + points.append(DailyPoint(date=d, hours=round(h, 2), sessions=sess_map.get(d, 0))) return points @@ -219,21 +235,10 @@ async def monthly(user: CurrentUser, db: AsyncSession = Depends(get_db)): ) rows = result.all() - # Apply daily overhead (rough: count distinct working days per month) - month_days_result = await db.execute( - select( - func.to_char(DailyStat.date, "YYYY-MM").label("month"), - func.count(func.distinct(DailyStat.date)), - ) - .where(DailyStat.user_id == user.id) - .group_by("month") - ) - month_days = {r[0]: r[1] for r in month_days_result.all()} - return [ MonthlyPoint( month=r[0], - hours=round(float(r[1] or 0) + user.daily_overhead_hours * month_days.get(r[0], 0), 2), + hours=round(float(r[1] or 0), 2), ) for r in rows ]