init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,320 @@
|
||||
"""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 dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Callable
|
||||
|
||||
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)
|
||||
Reference in new issue
Block a user