This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
"""Turning rows into numbers: a day, a range, streaks, and a heatmap.
Everything the report and the dashboard show comes from here, so the web UI and
the Telegram message can never disagree about how many pomodoros you did.
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent, PomodoroSession, TaskRecord
from app.settings_schema import AppSettings
from app.timeutil import local_date, to_local
# Two work intervals closer together than this count as one unbroken stretch.
_STREAK_GAP_SECONDS = 20 * 60
def _focus_blocks(rows: list[PomodoroSession], tz_name: str) -> list[tuple[datetime, datetime]]:
blocks: list[tuple[datetime, datetime]] = []
for row in rows:
if row.kind != "work" or row.status != "completed" or row.ended_at is None:
continue
blocks.append((to_local(row.started_at, tz_name), to_local(row.ended_at, tz_name)))
return sorted(blocks)
def longest_focus_stretch_minutes(rows: list[PomodoroSession], tz_name: str) -> int:
"""The longest run of back-to-back pomodoros, in minutes of actual focus."""
blocks = _focus_blocks(rows, tz_name)
if not blocks:
return 0
best = current = (blocks[0][1] - blocks[0][0]).total_seconds()
previous_end = blocks[0][1]
for start, end in blocks[1:]:
gap = (start - previous_end).total_seconds()
length = (end - start).total_seconds()
current = current + length if gap <= _STREAK_GAP_SECONDS else length
best = max(best, current)
previous_end = end
return int(best // 60)
def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Everything measurable about one day."""
tz_name = settings.timezone
rows = list(
session.scalars(
select(PomodoroSession)
.where(PomodoroSession.local_date == day)
.order_by(PomodoroSession.started_at.asc())
)
)
work_done = [r for r in rows if r.kind == "work" and r.status == "completed"]
work_dropped = [r for r in rows if r.kind == "work" and r.status == "abandoned"]
breaks_done = [r for r in rows if r.kind != "work" and r.status == "completed"]
focus_seconds = sum(r.actual_seconds for r in work_done)
break_seconds = sum(r.actual_seconds for r in breaks_done)
dropped_seconds = sum(r.actual_seconds for r in work_dropped)
by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
tasks = list(
session.scalars(
select(TaskRecord).where(
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
)
)
)
blocks = list(
session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))
)
block_counter = Counter(event.target for event in blocks)
pomodoro_time_by_task: Counter[str] = Counter()
for row in work_done:
label = row.task_title or "Unassigned"
pomodoro_time_by_task[label] += row.actual_seconds
first = to_local(work_done[0].started_at, tz_name) if work_done else None
last_row = work_done[-1] if work_done else None
last = to_local(last_row.ended_at or last_row.started_at, tz_name) if last_row else None
goal = settings.pomodoro.daily_goal_pomodoros
completed = len(work_done)
planned_seconds = sum(r.planned_seconds for r in work_done)
return {
"date": day.isoformat(),
"pomodoros": completed,
"goal": goal,
"goal_pct": round(100 * completed / goal) if goal else 0,
"goal_met": completed >= goal,
"focus_minutes": round(focus_seconds / 60),
"break_minutes": round(break_seconds / 60),
"wasted_minutes": round(dropped_seconds / 60),
"abandoned": len(work_dropped),
"abandon_reasons": [r.abandon_reason for r in work_dropped if r.abandon_reason],
"breaks_taken": len(breaks_done),
"breaks_owed": completed,
"interruptions": sum(r.interruptions for r in rows),
"completion_rate": (
round(100 * focus_seconds / planned_seconds) if planned_seconds else 0
),
"longest_stretch_minutes": longest_focus_stretch_minutes(rows, tz_name),
"first_start": first.strftime("%H:%M") if first else None,
"last_end": last.strftime("%H:%M") if last else None,
"by_hour": [{"hour": hour, "count": by_hour.get(hour, 0)} for hour in range(24)],
"peak_hour": (by_hour.most_common(1)[0][0] if by_hour else None),
"tasks_completed": [
{
"id": task.id,
"title": task.title,
"source": task.source,
"project": task.project,
"url": task.url,
}
for task in tasks
],
"tasks_completed_count": len(tasks),
"time_by_task": [
{"title": title, "minutes": round(seconds / 60)}
for title, seconds in pomodoro_time_by_task.most_common(10)
],
"blocked_attempts": sum(block_counter.values()),
"blocked_top": [
{"target": target, "count": count} for target, count in block_counter.most_common(8)
],
"sessions": [
{
"id": row.id,
"kind": row.kind,
"status": row.status,
"started_at": to_local(row.started_at, tz_name).isoformat(),
"minutes": round(row.actual_seconds / 60),
"task": row.task_title,
"interruptions": row.interruptions,
}
for row in rows
],
}
def range_metrics(
session: Session, settings: AppSettings, start: date, end: date
) -> list[dict[str, Any]]:
"""Per-day metrics for `[start, end]`, inclusive, cheaply (one query)."""
rows = list(
session.scalars(
select(PomodoroSession).where(
PomodoroSession.local_date >= start,
PomodoroSession.local_date <= end,
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
)
)
per_day: dict[date, list[PomodoroSession]] = {}
for row in rows:
per_day.setdefault(row.local_date, []).append(row)
out: list[dict[str, Any]] = []
cursor = start
while cursor <= end:
day_rows = per_day.get(cursor, [])
out.append(
{
"date": cursor.isoformat(),
"pomodoros": len(day_rows),
"focus_minutes": round(sum(r.actual_seconds for r in day_rows) / 60),
"goal_met": len(day_rows) >= settings.pomodoro.daily_goal_pomodoros,
}
)
cursor += timedelta(days=1)
return out
def averages(session: Session, settings: AppSettings, days: int, *, until: date) -> dict[str, float]:
"""Mean pomodoros/focus over the `days` days ending the day before `until`."""
start = until - timedelta(days=days)
end = until - timedelta(days=1)
if end < start:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
rows = range_metrics(session, settings, start, end)
if not rows:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
return {
"pomodoros": round(sum(r["pomodoros"] for r in rows) / len(rows), 1),
"focus_minutes": round(sum(r["focus_minutes"] for r in rows) / len(rows), 1),
}
def streak(session: Session, settings: AppSettings, *, until: date | None = None) -> dict[str, int]:
"""Consecutive days ending at `until` with at least one pomodoro, and with the goal met.
Today only breaks a streak once it is over, so a day with nothing on it yet
keeps yesterday's streak alive.
"""
until = until or local_date(settings.timezone)
days = list(
session.scalars(
select(PomodoroSession.local_date)
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
)
)
counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
).all()
)
active = set(days)
def run(from_day: date, predicate) -> int:
length, cursor = 0, from_day
while predicate(cursor):
length += 1
cursor -= timedelta(days=1)
return length
goal = settings.pomodoro.daily_goal_pomodoros
anchor = until if until in active else until - timedelta(days=1)
goal_anchor = (
until if counts.get(until, 0) >= goal else until - timedelta(days=1)
)
return {
"current": run(anchor, lambda day: day in active),
"goal_streak": run(goal_anchor, lambda day: counts.get(day, 0) >= goal),
"best": _best_streak(sorted(active)),
"total_days": len(active),
}
def _best_streak(days: list[date]) -> int:
best = run = 0
previous: date | None = None
for day in days:
run = run + 1 if previous is not None and (day - previous).days == 1 else 1
best = max(best, run)
previous = day
return best
def heatmap(session: Session, settings: AppSettings, weeks: int = 12) -> list[dict[str, Any]]:
"""Per-day counts for the last `weeks` weeks, for the dashboard's calendar."""
end = local_date(settings.timezone)
start = end - timedelta(weeks=weeks)
return range_metrics(session, settings, start, end)
def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
total, seconds = session.execute(
select(func.count(PomodoroSession.id), func.coalesce(func.sum(PomodoroSession.actual_seconds), 0)).where(
PomodoroSession.kind == "work", PomodoroSession.status == "completed"
)
).one()
tasks = session.scalar(
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True))
)
blocked = session.scalar(select(func.count(BlockEvent.id)))
return {
"pomodoros": int(total or 0),
"focus_minutes": round(int(seconds or 0) / 60),
"focus_hours": round(int(seconds or 0) / 3600, 1),
"tasks_completed": int(tasks or 0),
"blocked_attempts": int(blocked or 0),
}