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