fix: accurate time tracking — union intervals for totals, no double-counted overhead

- summary/timeline: use union of session start_at/end_at intervals per day
  instead of summing active_hours, so parallel sessions don't inflate totals
- projects: show raw session hours per project (correct for per-project billing)
- monthly: remove daily_overhead_hours addition
- models: change daily_overhead_hours default 2.0 → 0.0
- daily_overhead_hours setting in user profile still works but defaults to 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-27 17:39:55 +01:00
parent 2eee1a7c02
commit c178a00b74
2 changed files with 57 additions and 52 deletions

View file

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

View file

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