2025-10-26 20:23:26 +03:00
|
|
|
|
from typing import Dict, Any, Optional
|
|
|
|
|
|
from datetime import datetime
|
2025-11-12 23:20:01 +03:00
|
|
|
|
from i18n import t
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
|
|
|
|
|
class StatsFormatter:
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_rating_change(rating_change: int) -> str:
|
|
|
|
|
|
"""Format rating change with colored circles"""
|
|
|
|
|
|
if rating_change > 0:
|
|
|
|
|
|
return f"🟢 +{rating_change}"
|
|
|
|
|
|
elif rating_change < 0:
|
|
|
|
|
|
return f"🔴 {rating_change}"
|
|
|
|
|
|
else:
|
2025-11-16 13:24:39 +03:00
|
|
|
|
return "⚪ 0"
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _escape_md(text: str) -> str:
|
|
|
|
|
|
"""Escape literal Markdown-special chars in dynamic text (e.g. usernames) for legacy Telegram Markdown"""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return text
|
|
|
|
|
|
for ch in ('_', '*', '`', '['):
|
|
|
|
|
|
text = text.replace(ch, '\\' + ch)
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_accuracy(accuracy: Optional[float]) -> str:
|
|
|
|
|
|
"""Format accuracy percentage, or '-' if not available (game not analyzed)"""
|
|
|
|
|
|
if accuracy is None:
|
|
|
|
|
|
return "-"
|
|
|
|
|
|
return f"{accuracy:.0f}%"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_row_outcome_circle(row: dict) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Colored circle for the tracked user's outcome in this row:
|
|
|
|
|
|
🟢 win, 🔴 loss, ⚪ draw (or unknown side).
|
|
|
|
|
|
"""
|
|
|
|
|
|
tracked_is_white = row.get('tracked_is_white')
|
|
|
|
|
|
result = row.get('result', '')
|
|
|
|
|
|
if tracked_is_white is None or result == "1/2-1/2":
|
|
|
|
|
|
return "⚪"
|
|
|
|
|
|
tracked_won = (result == "1-0" and tracked_is_white) or (result == "0-1" and not tracked_is_white)
|
|
|
|
|
|
return "🟢" if tracked_won else "🔴"
|
|
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
@staticmethod
|
2026-07-03 09:38:51 +00:00
|
|
|
|
def _format_game_rows_block(rows: list) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Format a list of individual game rows as a column-aligned, monospace table:
|
2026-07-03 10:12:45 +00:00
|
|
|
|
outcome | accuracy(tracked) | name(tracked)+color | rating(tracked) | vs | rating(opponent) | accuracy(opponent)
|
2026-07-03 10:08:35 +00:00
|
|
|
|
|
|
|
|
|
|
Only the tracked player is named — the opponent is shown by rating/accuracy
|
2026-07-03 10:12:45 +00:00
|
|
|
|
only, keeping the line short enough for mobile screens. A small circle right
|
|
|
|
|
|
after the tracked player's name shows which color they played (⚪ white / ⚫ black).
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
|
|
|
|
|
Column widths are computed from the actual data so every column lines up
|
|
|
|
|
|
character-for-character. Caller is expected to wrap the result in a
|
|
|
|
|
|
Markdown ``` code block ``` so Telegram renders it with a monospace font.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
NAME_WIDTH = 7
|
|
|
|
|
|
|
|
|
|
|
|
prepared = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
circle = StatsFormatter._format_row_outcome_circle(row)
|
2026-07-03 10:12:45 +00:00
|
|
|
|
tracked_is_white = row.get('tracked_is_white') is not False
|
|
|
|
|
|
color_marker = "⚪" if tracked_is_white else "⚫"
|
|
|
|
|
|
if tracked_is_white:
|
2026-07-03 10:08:35 +00:00
|
|
|
|
tracked_name = row.get('white_name') or '?'
|
|
|
|
|
|
tracked_rating = row.get('white_rating')
|
|
|
|
|
|
tracked_accuracy = row.get('white_accuracy')
|
|
|
|
|
|
opp_rating = row.get('black_rating')
|
|
|
|
|
|
opp_accuracy = row.get('black_accuracy')
|
2026-07-03 10:12:45 +00:00
|
|
|
|
else:
|
|
|
|
|
|
tracked_name = row.get('black_name') or '?'
|
|
|
|
|
|
tracked_rating = row.get('black_rating')
|
|
|
|
|
|
tracked_accuracy = row.get('black_accuracy')
|
|
|
|
|
|
opp_rating = row.get('white_rating')
|
|
|
|
|
|
opp_accuracy = row.get('white_accuracy')
|
|
|
|
|
|
name_field = f"{tracked_name[:NAME_WIDTH]}{color_marker}"
|
2026-07-03 10:08:35 +00:00
|
|
|
|
tr_str = str(tracked_rating) if tracked_rating is not None else "-"
|
|
|
|
|
|
or_str = str(opp_rating) if opp_rating is not None else "-"
|
|
|
|
|
|
ta = StatsFormatter._format_accuracy(tracked_accuracy)
|
|
|
|
|
|
oa = StatsFormatter._format_accuracy(opp_accuracy)
|
2026-07-03 10:12:45 +00:00
|
|
|
|
prepared.append((circle, ta, name_field, tr_str, or_str, oa))
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2026-07-03 10:12:45 +00:00
|
|
|
|
acc_width = max(max(len(p[1]), len(p[5])) for p in prepared)
|
|
|
|
|
|
name_width = NAME_WIDTH + 1 # +1 for the color marker glued to the name
|
|
|
|
|
|
rating_width = max(max(len(p[3]), len(p[4])) for p in prepared)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
|
|
|
|
|
lines = []
|
2026-07-03 10:12:45 +00:00
|
|
|
|
for circle, ta, name_field, tr_str, or_str, oa in prepared:
|
2026-07-03 09:38:51 +00:00
|
|
|
|
lines.append(
|
2026-07-03 10:12:45 +00:00
|
|
|
|
f"{circle} {ta:>{acc_width}} {name_field:<{name_width}} {tr_str:>{rating_width}} "
|
|
|
|
|
|
f"- {or_str:>{rating_width}} {oa:>{acc_width}}"
|
2026-07-03 09:38:51 +00:00
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en', accuracy_extra: Optional[Dict[str, Any]] = None) -> str:
|
2025-10-26 20:23:26 +03:00
|
|
|
|
"""Format statistics response according to the template"""
|
|
|
|
|
|
if not data or data.get('data') is None:
|
2025-11-12 23:20:01 +03:00
|
|
|
|
message = data.get('message', t('no_data', lang)) if data else t('no_data', lang)
|
2025-11-23 01:46:12 +03:00
|
|
|
|
# Filter out old "No active player" messages - this functionality is deprecated
|
|
|
|
|
|
if 'No active player' in message or 'Нет активного игрока' in message or 'active player' in message.lower() or 'активного игрока' in message.lower():
|
|
|
|
|
|
return t('no_data', lang)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
return f"📭 {message}"
|
|
|
|
|
|
|
|
|
|
|
|
# Extract data from API response
|
|
|
|
|
|
api_data = data.get('data', {})
|
|
|
|
|
|
tasks = api_data.get('tasks', {})
|
|
|
|
|
|
games = api_data.get('games', {})
|
|
|
|
|
|
|
|
|
|
|
|
# Format date range
|
2025-11-12 23:20:01 +03:00
|
|
|
|
date_range = StatsFormatter._get_date_range(period, lang)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
|
|
|
|
|
# Format tasks section
|
|
|
|
|
|
task_text = ""
|
|
|
|
|
|
if tasks and tasks.get('total', 0) > 0:
|
|
|
|
|
|
total_tasks = tasks.get('total', 0)
|
|
|
|
|
|
solved = tasks.get('solved', 0)
|
|
|
|
|
|
unsolved = tasks.get('unsolved', 0)
|
2025-11-12 23:20:01 +03:00
|
|
|
|
task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
2026-07-03 09:38:51 +00:00
|
|
|
|
# Supplementary per-mode accuracy (+ per-game rows for today/yesterday), from a parallel
|
|
|
|
|
|
# games-of-period fetch. Purely additive: absent/None just means no accuracy shown.
|
|
|
|
|
|
accuracy_by_mode = (accuracy_extra.get('data') or {}) if accuracy_extra else {}
|
|
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
# Format games section
|
|
|
|
|
|
games_text = ""
|
|
|
|
|
|
if games:
|
|
|
|
|
|
for game_type, game_data in games.items():
|
|
|
|
|
|
if not game_data or game_data.get('games_played', 0) == 0:
|
|
|
|
|
|
continue
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
# Get game type emoji
|
|
|
|
|
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
games_count = game_data.get('games_played', 0)
|
|
|
|
|
|
rating_change = game_data.get('rating_change', 0)
|
|
|
|
|
|
rating = game_data.get('final_rating', 0)
|
|
|
|
|
|
wins = game_data.get('wins', 0)
|
|
|
|
|
|
losses = game_data.get('losses', 0)
|
|
|
|
|
|
draws = game_data.get('draws', 0)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
# Format rating change
|
|
|
|
|
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-11-12 23:20:01 +03:00
|
|
|
|
# Get game type name (capitalize first letter)
|
|
|
|
|
|
game_type_name = game_type.title()
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
|
|
|
|
|
# Точность в шапке имеет смысл только там, где нет построчного разбора партий
|
|
|
|
|
|
# ниже (week) — для today/yesterday она была бы избыточна рядом со строками партий.
|
|
|
|
|
|
if game_type in ('blitz', 'rapid', 'classical') and period == "week":
|
|
|
|
|
|
mode_accuracy = (accuracy_by_mode.get(game_type) or {}).get('accuracy')
|
|
|
|
|
|
games_text += t('games_section_with_accuracy', lang,
|
|
|
|
|
|
emoji=emoji,
|
|
|
|
|
|
game_type=game_type_name,
|
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
|
rating_change=rating_change_str,
|
|
|
|
|
|
rating=rating,
|
|
|
|
|
|
wins=wins,
|
|
|
|
|
|
losses=losses,
|
|
|
|
|
|
draws=draws,
|
|
|
|
|
|
accuracy=StatsFormatter._format_accuracy(mode_accuracy)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
games_text += t('games_section', lang,
|
|
|
|
|
|
emoji=emoji,
|
|
|
|
|
|
game_type=game_type_name,
|
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
|
rating_change=rating_change_str,
|
|
|
|
|
|
rating=rating,
|
|
|
|
|
|
wins=wins,
|
|
|
|
|
|
losses=losses,
|
|
|
|
|
|
draws=draws
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
|
|
|
|
|
|
rows_text = ""
|
|
|
|
|
|
if period in ("today", "yesterday"):
|
|
|
|
|
|
for mode in ("blitz", "rapid", "classical"):
|
|
|
|
|
|
rows = (accuracy_by_mode.get(mode) or {}).get('games') or []
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
continue
|
|
|
|
|
|
emoji = StatsFormatter._get_game_type_emoji(mode)
|
|
|
|
|
|
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=mode.title())
|
|
|
|
|
|
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
|
|
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
# Combine all parts
|
2026-07-03 09:38:51 +00:00
|
|
|
|
result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
result += task_text
|
|
|
|
|
|
result += games_text.rstrip()
|
2026-07-03 09:38:51 +00:00
|
|
|
|
if rows_text:
|
|
|
|
|
|
result += "\n\n" + rows_text.rstrip()
|
|
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-11-12 23:20:01 +03:00
|
|
|
|
def _get_date_range(period: str, lang: str = 'en') -> str:
|
2025-10-26 20:23:26 +03:00
|
|
|
|
"""Get date range string for the period"""
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
today = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
if period == "today":
|
|
|
|
|
|
return today.strftime("%d.%m.%Y")
|
|
|
|
|
|
elif period == "yesterday":
|
|
|
|
|
|
yesterday = today - timedelta(days=1)
|
|
|
|
|
|
return yesterday.strftime("%d.%m.%Y")
|
|
|
|
|
|
elif period == "week":
|
|
|
|
|
|
week_ago = today - timedelta(days=7)
|
|
|
|
|
|
return f"{week_ago.strftime('%d.%m.%Y')}–{today.strftime('%d.%m.%Y')}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return today.strftime("%d.%m.%Y")
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _get_game_type_emoji(game_type: str) -> str:
|
|
|
|
|
|
"""Get emoji for game type"""
|
|
|
|
|
|
emoji_map = {
|
|
|
|
|
|
'bullet': '⚡️',
|
|
|
|
|
|
'blitz': '🔥',
|
|
|
|
|
|
'rapid': '🐇',
|
|
|
|
|
|
'classical': '♟️',
|
|
|
|
|
|
'correspondence': '📮'
|
|
|
|
|
|
}
|
|
|
|
|
|
return emoji_map.get(game_type.lower(), '🎯')
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-11-12 23:20:01 +03:00
|
|
|
|
def format_period_notification(username: str, games_data: Optional[Dict], puzzles_data: Optional[Dict], period_minutes: int, lang: str = 'en') -> str:
|
2025-10-26 20:23:26 +03:00
|
|
|
|
"""Format notification for periodic checks"""
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
# Format period text
|
|
|
|
|
|
if period_minutes == 1:
|
2025-11-12 23:20:01 +03:00
|
|
|
|
period_text = t('period_1_minute', lang)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
elif period_minutes in [2, 3, 4]:
|
2025-11-12 23:20:01 +03:00
|
|
|
|
period_text = t('period_2_3_4_minutes', lang, period=period_minutes)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
else:
|
2025-11-12 23:20:01 +03:00
|
|
|
|
period_text = t('period_minutes_text', lang, period=period_minutes)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
2026-07-03 09:38:51 +00:00
|
|
|
|
result = t('period_notification_title', lang, username=StatsFormatter._escape_md(username), period_text=period_text)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
|
|
|
|
|
# Format puzzles first (if available and there's actual activity)
|
2025-11-23 12:51:14 +03:00
|
|
|
|
has_puzzles_data = False
|
|
|
|
|
|
if puzzles_data:
|
|
|
|
|
|
# Check puzzles_in_period on top level first (priority)
|
|
|
|
|
|
top_level_puzzles = puzzles_data.get('puzzles_in_period', 0)
|
|
|
|
|
|
# Also check data.total_attempts
|
|
|
|
|
|
if puzzles_data.get('data'):
|
|
|
|
|
|
puzzles_info = puzzles_data['data']
|
|
|
|
|
|
total_puzzles = puzzles_info.get('total_attempts', 0)
|
|
|
|
|
|
solved = puzzles_info.get('solved', 0)
|
|
|
|
|
|
failed = puzzles_info.get('failed', 0)
|
|
|
|
|
|
|
|
|
|
|
|
effective_puzzles = top_level_puzzles if top_level_puzzles > 0 else total_puzzles
|
|
|
|
|
|
|
|
|
|
|
|
# Only show tasks section if there's actual activity (not all zeros)
|
|
|
|
|
|
if effective_puzzles > 0 or solved > 0 or failed > 0:
|
|
|
|
|
|
has_puzzles_data = True
|
|
|
|
|
|
result += t('period_puzzles_section', lang, total=effective_puzzles, solved=solved, failed=failed)
|
2025-10-26 20:23:26 +03:00
|
|
|
|
|
|
|
|
|
|
# Format games
|
2025-11-23 12:51:14 +03:00
|
|
|
|
has_games_data = False
|
2025-10-26 20:23:26 +03:00
|
|
|
|
if games_data and games_data.get('data'):
|
|
|
|
|
|
games_info = games_data['data']
|
2025-11-23 12:51:14 +03:00
|
|
|
|
# Check games_count on top level first (priority)
|
|
|
|
|
|
top_level_games_count = games_data.get('games_count', 0)
|
|
|
|
|
|
# Also check data.total.games_played
|
2025-10-26 20:23:26 +03:00
|
|
|
|
total_games = games_info.get('total', {}).get('games_played', 0)
|
|
|
|
|
|
|
2025-11-23 12:51:14 +03:00
|
|
|
|
# Use top-level games_count if available, otherwise use total.games_played
|
|
|
|
|
|
effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games
|
|
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
# Show details for each game type if there were games
|
2026-07-03 09:38:51 +00:00
|
|
|
|
rows_text = ""
|
2025-11-23 12:51:14 +03:00
|
|
|
|
if effective_games_count > 0:
|
2025-10-26 20:23:26 +03:00
|
|
|
|
for game_type, game_data in games_info.items():
|
|
|
|
|
|
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
|
2025-11-23 12:51:14 +03:00
|
|
|
|
has_games_data = True # Only set to True if we actually add game data
|
2025-10-26 20:23:26 +03:00
|
|
|
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
|
|
|
|
|
games_count = game_data.get('games_played', 0)
|
|
|
|
|
|
rating_change = game_data.get('rating_change', 0)
|
2026-03-21 22:58:47 +03:00
|
|
|
|
rating = game_data.get('final_rating', game_data.get('rating', 0))
|
2025-10-26 20:23:26 +03:00
|
|
|
|
wins = game_data.get('wins', 0)
|
|
|
|
|
|
losses = game_data.get('losses', 0)
|
|
|
|
|
|
draws = game_data.get('draws', 0)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
2025-11-12 23:20:01 +03:00
|
|
|
|
game_type_name = game_type.title()
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
|
|
|
|
|
if game_type in ('blitz', 'rapid', 'classical'):
|
|
|
|
|
|
# Точность в шапку не выводим — она всегда дублируется построчным
|
|
|
|
|
|
# разбором партий ниже (периодическая проверка — короткий период).
|
|
|
|
|
|
result += t('period_games_section', lang,
|
|
|
|
|
|
emoji=emoji,
|
|
|
|
|
|
game_type=game_type_name,
|
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
|
rating_change=rating_change_str,
|
|
|
|
|
|
rating=rating,
|
|
|
|
|
|
wins=wins,
|
|
|
|
|
|
losses=losses,
|
|
|
|
|
|
draws=draws
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = game_data.get('games') or []
|
|
|
|
|
|
if rows:
|
|
|
|
|
|
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
|
|
|
|
|
|
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
|
|
|
|
|
|
else:
|
|
|
|
|
|
result += t('period_games_section', lang,
|
|
|
|
|
|
emoji=emoji,
|
|
|
|
|
|
game_type=game_type_name,
|
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
|
rating_change=rating_change_str,
|
|
|
|
|
|
rating=rating,
|
|
|
|
|
|
wins=wins,
|
|
|
|
|
|
losses=losses,
|
|
|
|
|
|
draws=draws
|
|
|
|
|
|
)
|
|
|
|
|
|
if rows_text:
|
|
|
|
|
|
result += rows_text
|
|
|
|
|
|
|
2025-11-23 12:51:14 +03:00
|
|
|
|
# If no activity at all
|
|
|
|
|
|
if not has_games_data and not has_puzzles_data:
|
2025-11-12 23:20:01 +03:00
|
|
|
|
result += t('no_activity', lang)
|
2026-07-03 09:38:51 +00:00
|
|
|
|
|
2025-10-26 20:23:26 +03:00
|
|
|
|
return result.rstrip()
|
2025-11-16 12:48:23 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def format_last_year_or_1000(data: Dict[str, Any], username: str, lang: str = 'en') -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Format response for last year or last 1000 games.
|
|
|
|
|
|
Expects GamesOfPeriodResponse-like payload.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
return "📭 No data"
|
|
|
|
|
|
games_count = data.get('games_count', 0)
|
|
|
|
|
|
period_start = data.get('period_start')
|
|
|
|
|
|
period_end = data.get('period_end')
|
|
|
|
|
|
stats = (data.get('data') or {})
|
|
|
|
|
|
# Title and subheader
|
2026-07-03 09:38:51 +00:00
|
|
|
|
escaped_username = StatsFormatter._escape_md(username)
|
2025-11-16 12:48:23 +03:00
|
|
|
|
if games_count >= 1000:
|
2026-07-03 09:38:51 +00:00
|
|
|
|
header = f"📈 {escaped_username}: last 1000 rated games"
|
2025-11-16 13:24:39 +03:00
|
|
|
|
earliest_ts = data.get('earliest_game_ts')
|
|
|
|
|
|
if isinstance(earliest_ts, int):
|
|
|
|
|
|
earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
|
2025-11-16 20:07:52 +03:00
|
|
|
|
header += f"\n\n\nStart of these 1000 games: {earliest}"
|
2025-11-16 12:48:23 +03:00
|
|
|
|
else:
|
2026-07-03 09:38:51 +00:00
|
|
|
|
header = f"📈 {escaped_username}: last year (rated), games: {games_count}"
|
2025-11-16 13:24:39 +03:00
|
|
|
|
# Use earliest actual game date instead of naive 'year ago'
|
|
|
|
|
|
earliest_ts = data.get('earliest_game_ts', period_start)
|
|
|
|
|
|
if isinstance(earliest_ts, int) and isinstance(period_end, int):
|
|
|
|
|
|
start_str = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
|
2025-11-16 12:48:23 +03:00
|
|
|
|
end_str = datetime.fromtimestamp(period_end).strftime("%d.%m.%Y")
|
2025-11-16 20:07:52 +03:00
|
|
|
|
header += f"\n\n\nPeriod: {start_str}–{end_str}"
|
2025-11-16 12:48:23 +03:00
|
|
|
|
# Body per mode
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
for mode in ["bullet", "blitz", "rapid", "classical", "correspondence"]:
|
|
|
|
|
|
mode_stats = stats.get(mode)
|
|
|
|
|
|
if not mode_stats:
|
|
|
|
|
|
continue
|
|
|
|
|
|
games_played = mode_stats.get('games_played', 0)
|
|
|
|
|
|
if games_played == 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
emoji = StatsFormatter._get_game_type_emoji(mode)
|
|
|
|
|
|
wins = mode_stats.get('wins', 0)
|
|
|
|
|
|
losses = mode_stats.get('losses', 0)
|
|
|
|
|
|
draws = mode_stats.get('draws', 0)
|
|
|
|
|
|
rating_change = mode_stats.get('rating_change', 0)
|
|
|
|
|
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
|
|
|
|
|
rating = mode_stats.get('rating')
|
|
|
|
|
|
rating_str = rating if rating is not None else "—"
|
2026-07-03 09:38:51 +00:00
|
|
|
|
line = f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str} ✅ {wins} ❌ {losses} 🤝 {draws}"
|
|
|
|
|
|
if mode in ("blitz", "rapid", "classical"):
|
|
|
|
|
|
line += f" 🎯 {StatsFormatter._format_accuracy(mode_stats.get('accuracy'))}"
|
|
|
|
|
|
lines.append(line)
|
2025-11-16 20:07:52 +03:00
|
|
|
|
# Join lines with newlines between each mode
|
|
|
|
|
|
# Between regular modes: one empty line (\n\n)
|
|
|
|
|
|
# Before last mode: two empty lines (\n\n\n)
|
|
|
|
|
|
if len(lines) == 0:
|
|
|
|
|
|
body = ""
|
|
|
|
|
|
elif len(lines) == 1:
|
|
|
|
|
|
body = lines[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
# All modes except last joined with one empty line
|
|
|
|
|
|
body = "\n\n".join(lines[:-1])
|
|
|
|
|
|
# Add two empty lines before last mode
|
|
|
|
|
|
body += "\n\n\n" + lines[-1]
|
|
|
|
|
|
return f"{header}\n\n{body}"
|