Files
kadence/backend/app/services/report.py
T
2026-10-18 18:02:05 +03:00

442 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reports: the daily wrap-up, the morning briefing, the weekly review.
A report is built once, stored as JSON, and then *rendered* — to HTML for
Telegram or to a structure the web UI draws cards from. Because both come from
the same payload, the message in your chat and the page in the browser can
never tell you different numbers.
"""
from __future__ import annotations
import html
from datetime import date, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import DailyReport, PomodoroSession, utcnow
from app.services import achievements as ach
from app.services import notify, stats, tasks_service
from app.settings_schema import AppSettings
from app.sources.registry import SOURCE_LABELS
from app.timeutil import humanize_minutes, local_date
BAR_FULL = "█"
BAR_EMPTY = "░"
def _days(count: int, ru: bool) -> str:
""""1 day" / "3 days" / "1 день" / "3 дня" — plurals, cheaply."""
if ru:
if count % 10 == 1 and count % 100 != 11:
return f"{count} день"
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return f"{count} дня"
return f"{count} дней"
return f"{count} day" if count == 1 else f"{count} days"
def _esc(value: Any) -> str:
"""Escape borrowed text for Telegram's HTML parse mode.
Without this one task summary containing `&` makes Telegram reject the
entire message — jira-helper learned that the hard way.
"""
return html.escape(str(value), quote=False)
def progress_bar(value: int, total: int, width: int = 10) -> str:
if total <= 0:
return BAR_EMPTY * width
filled = min(width, round(width * value / total))
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
# --------------------------------------------------------------------------
# Daily report
# --------------------------------------------------------------------------
def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Assemble everything the daily report shows, as a plain dict."""
metrics = stats.day_metrics(session, settings, day)
streak = stats.streak(session, settings, until=day)
average_7d = stats.averages(session, settings, 7, until=day)
lifetime = stats.lifetime(session, settings)
day_counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
.group_by(PomodoroSession.local_date)
).all()
)
yesterday = day - timedelta(days=1)
context = {
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"previous_best": ach.previous_best(day_counts, day),
"yesterday_pomodoros": day_counts.get(yesterday, 0),
}
report_settings = settings.notifications.report
language = report_settings.language
open_tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(open_tasks, day)
payload: dict[str, Any] = {
"date": day.isoformat(),
"generated_at": utcnow().isoformat(),
"timezone": settings.timezone,
"metrics": metrics,
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"comparison": {
"yesterday": day_counts.get(yesterday, 0),
"previous_best": context["previous_best"],
},
"achievements": (
ach.evaluate(metrics, context, language)
if report_settings.include_achievements
else []
),
"fun_facts": (
ach.fun_facts(metrics, context, language)
if report_settings.include_funny_stats
else []
),
"verdict": ach.verdict(metrics, report_settings.tone, language),
"open_tasks": {
"overdue": len(buckets["overdue"]),
"today": len(buckets["today"]),
"upcoming": len(buckets["upcoming"]),
"someday": len(buckets["someday"]),
},
"score": _score(metrics, streak),
}
return payload
def _score(metrics: dict[str, Any], streak: dict[str, int]) -> float:
"""A single 0-100 number for the dashboard's big dial.
Goal progress is most of it; finishing tasks, resisting the blocker and
keeping a streak nudge it up, dropped pomodoros pull it down.
"""
score = min(metrics["goal_pct"], 120) * 0.6
score += min(metrics["tasks_completed_count"], 6) * 3
score += min(streak.get("current", 0), 7) * 1.5
score += min(metrics["longest_stretch_minutes"] / 30, 4) * 2
score -= metrics["abandoned"] * 3
score -= max(metrics["interruptions"] - 2, 0) * 1.5
return round(max(0.0, min(100.0, score)), 1)
def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
"""The daily report as Telegram HTML."""
report_settings = settings.notifications.report
ru = report_settings.language == "ru"
metrics = payload["metrics"]
lines: list[str] = []
header = "🍅 Отчёт за день" if ru else "🍅 Daily report"
lines.append(f"<b>{header} — {payload['date']}</b>")
lines.append("")
bar = progress_bar(metrics["pomodoros"], metrics["goal"])
goal_line = (
f"{bar} <b>{metrics['pomodoros']}/{metrics['goal']}</b> "
+ ("помидорок" if ru else "pomodoros")
)
lines.append(goal_line)
lines.append(
("⏱ Фокус: " if ru else "⏱ Focus: ")
+ f"<b>{humanize_minutes(metrics['focus_minutes'])}</b>"
+ (" · перерывы: " if ru else " · breaks: ")
+ humanize_minutes(metrics["break_minutes"])
)
detail_bits: list[str] = []
if metrics["longest_stretch_minutes"]:
detail_bits.append(
("🧠 дольше всего без разрыва: " if ru else "🧠 longest stretch: ")
+ humanize_minutes(metrics["longest_stretch_minutes"])
)
if metrics["first_start"]:
detail_bits.append(
("🕘 с " if ru else "🕘 from ")
+ f"{metrics['first_start']}"
+ (" до " if ru else " to ")
+ f"{metrics['last_end']}"
)
if metrics["abandoned"]:
detail_bits.append(
("💨 брошено: " if ru else "💨 dropped: ") + str(metrics["abandoned"])
)
if metrics["interruptions"]:
detail_bits.append(
("📵 прерываний: " if ru else "📵 interruptions: ") + str(metrics["interruptions"])
)
if detail_bits:
lines.append(" · ".join(detail_bits))
streak = payload["streak"]
if streak.get("current"):
lines.append(
(f"🔥 Серия: {_days(streak['current'], True)} (рекорд {streak['best']})")
if ru
else f"🔥 Streak: {_days(streak['current'], False)} (best {streak['best']})"
)
if report_settings.include_comparison:
avg = payload["average_7d"]["pomodoros"]
if avg:
lines.append(
(f"📊 Среднее за 7 дней: {avg} · вчера: {payload['comparison']['yesterday']}")
if ru
else (
f"📊 7-day average: {avg} · yesterday: {payload['comparison']['yesterday']}"
)
)
tasks = metrics["tasks_completed"]
if report_settings.include_task_list and tasks:
lines.append("")
lines.append(
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>"
)
for task in tasks[: report_settings.max_tasks_listed]:
label = SOURCE_LABELS.get(task["source"], task["source"])
title = _esc(task["title"])
if task.get("url"):
lines.append(f"• <a href=\"{_esc(task['url'])}\">{title}</a> <i>({label})</i>")
else:
lines.append(f"• {title} <i>({label})</i>")
if len(tasks) > report_settings.max_tasks_listed:
rest = len(tasks) - report_settings.max_tasks_listed
lines.append(("… и ещё " if ru else "… and ") + f"{rest}" + ("" if ru else " more"))
if report_settings.include_blocked_sites and metrics["blocked_top"]:
lines.append("")
lines.append(f"<b>{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}</b>")
for entry in metrics["blocked_top"][:5]:
lines.append(f"• {_esc(entry['target'])} × {entry['count']}")
if payload["achievements"]:
lines.append("")
lines.append(f"<b>{'🏅 Достижения' if ru else '🏅 Achievements'}</b>")
for item in payload["achievements"]:
detail = f" — {_esc(item['detail'])}" if item.get("detail") else ""
lines.append(f"{item['emoji']} <b>{_esc(item['title'])}</b>{detail}")
if payload["fun_facts"]:
lines.append("")
for fact in payload["fun_facts"]:
lines.append(_esc(fact))
open_tasks = payload["open_tasks"]
if open_tasks["overdue"] or open_tasks["today"]:
lines.append("")
lines.append(
(f"📋 Осталось: {open_tasks['overdue']} просрочено, {open_tasks['today']} на сегодня")
if ru
else (
f"📋 Still open: {open_tasks['overdue']} overdue, "
f"{open_tasks['today']} due today"
)
)
if payload["verdict"]:
lines.append("")
lines.append(f"<i>{_esc(payload['verdict'])}</i>")
return "\n".join(lines)
def get_or_build_daily(
session: Session, settings: AppSettings, day: date, *, rebuild: bool = False
) -> tuple[DailyReport, dict[str, Any]]:
row = session.scalar(select(DailyReport).where(DailyReport.report_date == day))
if row is not None and not rebuild:
return row, row.payload
payload = build_daily(session, settings, day)
if row is None:
row = DailyReport(report_date=day, payload=payload, score=payload["score"])
session.add(row)
else:
row.payload = payload
row.score = payload["score"]
row.generated_at = utcnow()
session.commit()
return row, payload
def send_daily(
session: Session, settings: AppSettings, day: date, *, force: bool = False
) -> dict[str, Any]:
"""Build (or reuse) the day's report and push it to the channels."""
row, payload = get_or_build_daily(session, settings, day, rebuild=True)
if row.sent_at is not None and not force:
return {"sent": False, "reason": "already sent", "payload": payload}
text = render_daily_telegram(payload, settings)
deliveries = notify.notify(settings, text, urgent=True)
ok = any(delivery.ok for delivery in deliveries)
if ok:
row.sent_at = utcnow()
row.sent_channel = ",".join(d.channel for d in deliveries if d.ok)
session.commit()
return {
"sent": ok,
"deliveries": [d.as_dict() for d in deliveries],
"payload": payload,
"text": text,
}
# --------------------------------------------------------------------------
# Morning briefing
# --------------------------------------------------------------------------
def render_morning_briefing(session: Session, settings: AppSettings) -> str:
""""Here is your day" — the todo list, the streak, and the goal to beat."""
ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
streak = stats.streak(session, settings, until=today - timedelta(days=1))
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}</b>", ""]
goal = settings.pomodoro.daily_goal_pomodoros
lines.append(
(f"🎯 Цель на сегодня: <b>{goal}</b> помидорок "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)")
if ru
else (
f"🎯 Today's goal: <b>{goal}</b> pomodoros "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} of focus)"
)
)
if streak.get("current"):
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ (" — не разрывай" if ru else " — keep it alive")
)
def section(title: str, records: list, emoji: str) -> None:
if not records:
return
lines.append("")
lines.append(f"<b>{emoji} {title} ({len(records)})</b>")
for record in records[:limit]:
label = SOURCE_LABELS.get(record.source, record.source)
title_text = _esc(record.title)
due = ""
if record.due_date and record.due_date < today:
days = (today - record.due_date).days
due = (
f" — <b>{days} дн. просрочки</b>" if ru else f" — <b>{days}d overdue</b>"
)
if record.url:
lines.append(
f"• <a href=\"{_esc(record.url)}\">{title_text}</a> <i>({label})</i>{due}"
)
else:
lines.append(f"• {title_text} <i>({label})</i>{due}")
if len(records) > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{len(records) - limit}")
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
laundry = [task for task in tasks if task.source == "laundry"]
if laundry:
lines.append("")
lines.append(f"<b>{'🧺 Стирка' if ru else '🧺 Laundry'}</b>")
for slot in laundry:
lines.append(f"• {_esc(slot.title)}")
if not any(buckets.values()):
lines.append("")
lines.append(
"✨ Входящих нет. Отличный день, чтобы поработать над чем-то важным."
if ru
else "✨ Nothing queued. A good day to work on something that matters."
)
lines.append("")
lines.append(
"<i>/start_work — запустить помидорку, /today — список, /report — отчёт</i>"
if ru
else "<i>/start_work to begin, /today for the list, /report for the numbers</i>"
)
return "\n".join(lines)
# --------------------------------------------------------------------------
# Weekly review
# --------------------------------------------------------------------------
def build_weekly(session: Session, settings: AppSettings, end_day: date) -> dict[str, Any]:
start_day = end_day - timedelta(days=6)
days = stats.range_metrics(session, settings, start_day, end_day)
total = sum(day["pomodoros"] for day in days)
focus = sum(day["focus_minutes"] for day in days)
best = max(days, key=lambda day: day["pomodoros"]) if days else None
goal_days = sum(1 for day in days if day["goal_met"])
return {
"start": start_day.isoformat(),
"end": end_day.isoformat(),
"days": days,
"pomodoros": total,
"focus_minutes": focus,
"best_day": best,
"goal_days": goal_days,
"average": round(total / len(days), 1) if days else 0,
"streak": stats.streak(session, settings, until=end_day),
"lifetime": stats.lifetime(session, settings),
}
def render_weekly_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
ru = settings.notifications.report.language == "ru"
lines = [
f"<b>{'📅 Итоги недели' if ru else '📅 Weekly review'} "
f"({payload['start']} → {payload['end']})</b>",
"",
("🍅 Помидорок: " if ru else "🍅 Pomodoros: ")
+ f"<b>{payload['pomodoros']}</b>"
+ (" · в среднем " if ru else " · average ")
+ f"{payload['average']}/"
+ ("день" if ru else "day"),
("⏱ Фокус: " if ru else "⏱ Focus: ") + humanize_minutes(payload["focus_minutes"]),
("🎯 Дней с выполненной целью: " if ru else "🎯 Days the goal was met: ")
+ f"{payload['goal_days']}/7",
]
if payload["best_day"] and payload["best_day"]["pomodoros"]:
best = payload["best_day"]
lines.append(
("🏆 Лучший день: " if ru else "🏆 Best day: ")
+ f"{best['date']} ({best['pomodoros']} 🍅)"
)
lines.append("")
lines.append(f"<b>{'По дням' if ru else 'Day by day'}</b>")
for day in payload["days"]:
bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8)
lines.append(f"<code>{day['date'][5:]} {bar}</code> {day['pomodoros']}")
streak = payload["streak"]
if streak.get("current"):
lines.append("")
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ f" · {'рекорд' if ru else 'best'}: {streak['best']}"
)
return "\n".join(lines)