init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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),
|
||||
}
|
||||
Reference in new issue
Block a user