"""Badges and fun facts — the part of the report you actually read. Rules are pure functions of a day's metrics plus a little history, so they are trivial to extend: add a `Rule` to `RULES` and it shows up in the web report and the Telegram message at once. """ from __future__ import annotations import logging from collections.abc import Callable from dataclasses import dataclass from datetime import date from typing import Any Metrics = dict[str, Any] Context = dict[str, Any] def _plural(count: int, singular: str, plural: str | None = None) -> str: """"1 pomodoro" / "6 pomodoros" — the English side; Russian gets its own text.""" return f"{count} {singular if count == 1 else (plural or singular + 's')}" def _ru_pomodoros(count: int) -> str: 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} помидорок" @dataclass(frozen=True, slots=True) class Achievement: code: str emoji: str title_en: str title_ru: str detail_en: str = "" detail_ru: str = "" def render(self, language: str) -> dict[str, str]: ru = language == "ru" return { "code": self.code, "emoji": self.emoji, "title": self.title_ru if ru else self.title_en, "detail": (self.detail_ru if ru else self.detail_en), } @dataclass(frozen=True, slots=True) class Rule: code: str emoji: str title_en: str title_ru: str test: Callable[[Metrics, Context], bool] detail: Callable[[Metrics, Context], tuple[str, str]] | None = None def _nothing(_m: Metrics, _c: Context) -> tuple[str, str]: return "", "" RULES: list[Rule] = [ Rule( "goal_met", "🎯", "Daily goal met", "Дневная цель взята", lambda m, c: m["goal_met"], lambda m, c: ( f"{m['pomodoros']}/{m['goal']} pomodoros.", f"{m['pomodoros']}/{m['goal']} помидорок.", ), ), Rule( "double_goal", "🔥", "Double the goal", "Двойная норма", lambda m, c: m["goal"] > 0 and m["pomodoros"] >= 2 * m["goal"], ), Rule( "early_bird", "🌅", "Early bird", "Ранняя пташка", lambda m, c: bool(m["first_start"]) and m["first_start"] < "08:00", lambda m, c: ( f"First pomodoro at {m['first_start']}.", f"Первая помидорка в {m['first_start']}.", ), ), Rule( "night_owl", "🦉", "Night owl", "Ночная сова", lambda m, c: bool(m["last_end"]) and m["last_end"] > "23:00", ), Rule( "deep_work", "🧘", "Deep work", "Глубокая работа", lambda m, c: m["longest_stretch_minutes"] >= 90, lambda m, c: ( f"{m['longest_stretch_minutes']} minutes without breaking the chain.", f"{m['longest_stretch_minutes']} минут без разрыва цепочки.", ), ), Rule( "flawless", "💎", "Flawless", "Безупречно", lambda m, c: m["pomodoros"] >= 4 and m["abandoned"] == 0 and m["interruptions"] == 0, ), Rule( "task_crusher", "✅", "Task crusher", "Крушитель задач", lambda m, c: m["tasks_completed_count"] >= 5, lambda m, c: ( _plural(m["tasks_completed_count"], "task") + " closed.", f"{m['tasks_completed_count']} задач закрыто.", ), ), Rule( "gatekeeper", "🛡️", "Gatekeeper", "Страж", lambda m, c: m["blocked_attempts"] >= 5, lambda m, c: ( f"The blocker turned you away {m['blocked_attempts']} times. It won.", f"Блокировщик развернул тебя {m['blocked_attempts']} раз. И выиграл.", ), ), Rule( "well_rested", "☕", "Took the breaks", "Отдыхал как надо", lambda m, c: m["pomodoros"] >= 4 and m["breaks_taken"] >= m["pomodoros"] - 1, ), Rule( "streak_3", "📆", "3-day streak", "Серия 3 дня", lambda m, c: c.get("streak", {}).get("current", 0) >= 3, lambda m, c: ( f"{c['streak']['current']} days in a row with work logged.", f"{c['streak']['current']} дней подряд с работой.", ), ), Rule( "streak_7", "🏆", "Week-long streak", "Неделя подряд", lambda m, c: c.get("streak", {}).get("current", 0) >= 7, ), Rule( "personal_best", "🚀", "Personal best", "Личный рекорд", lambda m, c: m["pomodoros"] > 0 and m["pomodoros"] > c.get("previous_best", 0), lambda m, c: ( "Your best day ever: " + _plural(m["pomodoros"], "pomodoro") + ".", f"Лучший день: {_ru_pomodoros(m['pomodoros'])}.", ), ), Rule( "comeback", "🪃", "Comeback", "Возвращение", lambda m, c: m["pomodoros"] >= 4 and c.get("yesterday_pomodoros", 0) == 0, ), ] def evaluate(metrics: Metrics, context: Context, language: str = "en") -> list[dict[str, str]]: earned: list[dict[str, str]] = [] for rule in RULES: try: if not rule.test(metrics, context): continue except Exception: # pragma: no cover - a bad rule must not sink the report logging.getLogger(__name__).debug("rule %s failed", rule.code, exc_info=True) continue detail_en, detail_ru = (rule.detail or _nothing)(metrics, context) earned.append( Achievement( rule.code, rule.emoji, rule.title_en, rule.title_ru, detail_en, detail_ru ).render(language) ) return earned # -------------------------------------------------------------------------- # Fun facts # -------------------------------------------------------------------------- def fun_facts(metrics: Metrics, context: Context, language: str = "en") -> list[str]: """One-liners that make the numbers land. Only the true ones are returned.""" ru = language == "ru" facts: list[str] = [] focus = metrics["focus_minutes"] pomodoros = metrics["pomodoros"] if focus: episodes = round(focus / 22, 1) facts.append( f"🍅 {focus} минут фокуса — это {episodes} серий ситкома, которые ты не посмотрел." if ru else f"🍅 {focus} minutes of focus — that is {episodes} sitcom episodes you did not watch." ) if metrics["blocked_attempts"]: attempts = metrics["blocked_attempts"] top = metrics["blocked_top"][0]["target"] if metrics["blocked_top"] else "?" facts.append( f"🚫 {attempts} попыток сбежать, чаще всего в {top}. Не вышло." if ru else f"🚫 {attempts} escape attempts, mostly to {top}. None made it through." ) if metrics["interruptions"]: facts.append( f"📵 {metrics['interruptions']} прерываний. Каждое стоит ~23 минуты концентрации — по науке." if ru else ( "📵 " + _plural(metrics["interruptions"], "interruption") + ". Research prices each one at ~23 minutes of refocusing." ) ) if metrics["peak_hour"] is not None and pomodoros >= 2: hour = metrics["peak_hour"] facts.append( f"⏰ Пик продуктивности — {hour:02d}:00. Защищай это время." if ru else f"⏰ Your peak hour was {hour:02d}:00. Defend it." ) if metrics["wasted_minutes"] >= 10: facts.append( f"💨 {metrics['wasted_minutes']} минут ушло в брошенные помидорки." if ru else f"💨 {metrics['wasted_minutes']} minutes went into pomodoros you dropped." ) if pomodoros and metrics["breaks_taken"] < pomodoros - 1: skipped = pomodoros - 1 - metrics["breaks_taken"] facts.append( f"⚠️ Пропущено перерывов: {skipped}. Мозг это запомнит." if ru else "⚠️ You skipped " + _plural(skipped, "break") + ". Your brain keeps score." ) average = context.get("average_7d", {}).get("pomodoros", 0) if average and pomodoros: delta = round(100 * (pomodoros - average) / average) if abs(delta) >= 10: better = delta > 0 facts.append( (f"📈 На {abs(delta)}% {'выше' if better else 'ниже'} твоего среднего за неделю.") if ru else ( f"📈 {abs(delta)}% {'above' if better else 'below'} your 7-day average." ) ) lifetime_hours = context.get("lifetime", {}).get("focus_hours", 0) if lifetime_hours >= 1: facts.append( f"🏛️ Всего в приложении: {lifetime_hours} часов фокуса." if ru else f"🏛️ All-time focus logged here: {lifetime_hours} hours." ) return facts def verdict(metrics: Metrics, tone: str, language: str = "en") -> str: """The closing line. Four tones, because motivation is a personal taste.""" ru = language == "ru" pct = metrics["goal_pct"] table = { "friendly": [ (100, "Goal met. Enjoy the evening — you earned it. 🎉", "Цель взята. Отдыхай, заслужил. 🎉"), (60, "Solid day. Tomorrow you close the gap. 🙂", "Хороший день. Завтра добьёшь остаток. 🙂"), (1, "Something beats nothing. Start tomorrow with one pomodoro.", "Что-то лучше, чем ничего. Завтра начни с одной помидорки."), (0, "Nothing logged today. No drama — just start the timer tomorrow.", "Сегодня пусто. Без драмы — просто запусти таймер завтра."), ], "coach": [ (100, "Target hit. Now protect the streak. 💪", "Цель закрыта. Теперь держи серию. 💪"), (60, "Close. One more block would have done it. Plan it in.", "Почти. Ещё один блок — и всё. Запланируй."), (1, "You started, which is the hard part. Scale it tomorrow.", "Ты начал — это самое сложное. Завтра увеличивай."), (0, "Zero. Pick the smallest task and run one 10-minute timer tomorrow.", "Ноль. Завтра — самая мелкая задача и таймер на 10 минут."), ], "drill_sergeant": [ (100, "Acceptable. Do it again tomorrow. 🫡", "Принято. Завтра повторить. 🫡"), (60, "Half-measures. The goal was the goal.", "Полумеры. Цель была целью."), (1, "That was a warm-up, not a day of work.", "Это была разминка, а не рабочий день."), (0, "Nothing. Zero. The timer was right there.", "Ничего. Ноль. Таймер был прямо здесь."), ], "dry": [ (100, "Goal reached.", "Цель достигнута."), (60, "Goal partially reached.", "Цель достигнута частично."), (1, "Minimal activity recorded.", "Зафиксирована минимальная активность."), (0, "No activity recorded.", "Активность не зафиксирована."), ], } rows = table.get(tone, table["friendly"]) for threshold, text_en, text_ru in rows: if pct >= threshold: return text_ru if ru else text_en return "" def previous_best(day_counts: dict[date, int], until: date) -> int: return max((count for day, count in day_counts.items() if day < until), default=0)