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:
parent
2eee1a7c02
commit
c178a00b74
2 changed files with 57 additions and 52 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue