Files
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[tool.ruff]` set a line length and a target version and nothing else, so which
rules ran depended on the installed ruff's defaults — fine while nobody ran it,
not fine for a CI job that can turn a push red because a tool changed its mind.

The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that
are each a real decision rather than a shrug: B008, because FastAPI's whole
dependency idiom is a call in a default argument; E501, because wrapping prose
is a formatter's business; and RUF001, because the reports and the bot speak
Russian and "ambiguous character" is the wrong complaint about a string the
user is meant to read.

The 73 findings that remained are fixed — mostly `datetime.timezone.utc` →
`datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order,
and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two
try/except/pass pairs, unpacking instead of list concatenation, one negated
return.

`ruff format` is deliberately *not* adopted: it would reformat 51 files, and
some of the layout here is hand-set (the comment tables, the DNS wire-format
structs) in ways a formatter flattens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:28:57 +03:00

322 lines
12 KiB
Python

"""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)