"""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"{header} — {payload['date']}") lines.append("") bar = progress_bar(metrics["pomodoros"], metrics["goal"]) goal_line = ( f"{bar} {metrics['pomodoros']}/{metrics['goal']} " + ("помидорок" if ru else "pomodoros") ) lines.append(goal_line) lines.append( ("⏱ Фокус: " if ru else "⏱ Focus: ") + f"{humanize_minutes(metrics['focus_minutes'])}" + (" · перерывы: " 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"{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})" ) 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"• {title} ({label})") else: lines.append(f"• {title} ({label})") 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"{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}") for entry in metrics["blocked_top"][:5]: lines.append(f"• {_esc(entry['target'])} × {entry['count']}") if payload["achievements"]: lines.append("") lines.append(f"{'🏅 Достижения' if ru else '🏅 Achievements'}") for item in payload["achievements"]: detail = f" — {_esc(item['detail'])}" if item.get("detail") else "" lines.append(f"{item['emoji']} {_esc(item['title'])}{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"{_esc(payload['verdict'])}") 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"{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}", ""] goal = settings.pomodoro.daily_goal_pomodoros lines.append( (f"🎯 Цель на сегодня: {goal} помидорок " f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)") if ru else ( f"🎯 Today's goal: {goal} 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"{emoji} {title} ({len(records)})") 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" — {days} дн. просрочки" if ru else f" — {days}d overdue" ) if record.url: lines.append( f"• {title_text} ({label}){due}" ) else: lines.append(f"• {title_text} ({label}){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"{'🧺 Стирка' if ru else '🧺 Laundry'}") 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( "/start_work — запустить помидорку, /today — список, /report — отчёт" if ru else "/start_work to begin, /today for the list, /report for the numbers" ) 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"{'📅 Итоги недели' if ru else '📅 Weekly review'} " f"({payload['start']} → {payload['end']})", "", ("🍅 Помидорок: " if ru else "🍅 Pomodoros: ") + f"{payload['pomodoros']}" + (" · в среднем " 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"{'По дням' if ru else 'Day by day'}") for day in payload["days"]: bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8) lines.append(f"{day['date'][5:]} {bar} {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)