align /getgamers and stats-message tables, unify stats body into one code block
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s

- /getgamers: column-aligned monospace table (username, ratings, period).
- Per-mode stats (rating/wins/losses/draws): column-aligned block, dropped
  the meaningless multi-game average accuracy row.
- format_stats_response/format_period_notification now wrap the whole
  message in a single markdown code block instead of alternating
  plain-text headers and separately-fenced tables.
- Rating row gets an emoji prefix to match the other three rows, so
  Telegram's monospace rendering keeps all values in the same column.
This commit is contained in:
vrubelroman 2026-07-03 13:20:50 +00:00
parent 2a7385290e
commit f154d3d7c3
3 changed files with 113 additions and 76 deletions

View file

@ -1,5 +1,6 @@
from typing import Dict, Any, Optional
from datetime import datetime
import html
from i18n import t
class StatsFormatter:
@ -97,6 +98,71 @@ class StatsFormatter:
)
return "\n".join(lines)
@staticmethod
def _format_mode_stats_block(rating, wins, losses, draws, lang: str) -> str:
"""
Column-aligned monospace block for one game type's rating/wins/losses/draws,
shown under the header line. Caller is expected to wrap the result in a
Markdown ``` code block ``` for guaranteed monospace rendering.
No accuracy row here: an average accuracy across several games isn't a
meaningful stat (unlike the per-game accuracy shown in the game-rows table).
Every label carries exactly one emoji prefix Telegram's monospace font
renders emoji wider than plain text of the same length() count, so a label
with no emoji (unlike the other three) would visibly end up one column off
despite matching character counts.
"""
rows = [
(t('stat_label_rating', lang), str(rating)),
(t('stat_label_wins', lang), str(wins)),
(t('stat_label_losses', lang), str(losses)),
(t('stat_label_draws', lang), str(draws)),
]
label_width = max(len(label) for label, _ in rows)
value_width = max(len(value) for _, value in rows)
return "\n".join(f"{label:<{label_width}} {value:>{value_width}}" for label, value in rows)
@staticmethod
def format_gamers_table(gamers_data: list) -> str:
"""
Column-aligned monospace table for /getgamers: username, then bullet/blitz/rapid
ratings, then the periodic-notification period suffix (e.g. "· 60m").
Column widths are computed from the actual data so every column lines up
character-for-character. Caller is expected to wrap the result in an HTML
<pre> block so Telegram renders it with a monospace font (username can't be
bolded there pre/code entities can't contain other entities).
"""
if not gamers_data:
return ""
rows = []
for g in gamers_data:
username = html.escape(str(g['username']))
bullet = str(g['bullet'])
blitz = str(g['blitz'])
rapid = str(g['rapid'])
period = (g.get('period') or '').strip()
rows.append((username, bullet, blitz, rapid, period))
name_width = max(len(r[0]) for r in rows)
bullet_width = max(len(r[1]) for r in rows)
blitz_width = max(len(r[2]) for r in rows)
rapid_width = max(len(r[3]) for r in rows)
lines = []
for username, bullet, blitz, rapid, period in rows:
line = (
f"{username:<{name_width}} "
f"{bullet:>{bullet_width}} 🔥{blitz:>{blitz_width}} 🐇{rapid:>{rapid_width}}"
)
if period:
line += f" {period}"
lines.append(line)
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:
"""Format statistics response according to the template"""
@ -150,32 +216,16 @@ class StatsFormatter:
# Get game type name (capitalize first letter)
game_type_name = game_type.title()
# Точность в шапке имеет смысл только там, где нет построчного разбора партий
# ниже (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
)
games_text += t('game_type_header', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str
)
games_text += StatsFormatter._format_mode_stats_block(
rating, wins, losses, draws, lang
) + "\n\n"
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
rows_text = ""
@ -186,16 +236,17 @@ class StatsFormatter:
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"
rows_text += StatsFormatter._format_game_rows_block(rows) + "\n\n"
# Combine all parts
result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
result += task_text
result += games_text.rstrip()
# Whole body goes into a single monospace code block so the message doesn't
# alternate between plain-text headers and separately-fenced tables.
body = t('stats_title', lang, username=username, date_range=date_range)
body += task_text
body += games_text.rstrip()
if rows_text:
result += "\n\n" + rows_text.rstrip()
body += "\n\n" + rows_text.rstrip()
return result
return "```\n" + body + "\n```"
@staticmethod
def _get_date_range(period: str, lang: str = 'en') -> str:
@ -240,7 +291,7 @@ class StatsFormatter:
else:
period_text = t('period_minutes_text', lang, period=period_minutes)
result = t('period_notification_title', lang, username=StatsFormatter._escape_md(username), period_text=period_text)
result = t('period_notification_title', lang, username=username, period_text=period_text)
# Format puzzles first (if available and there's actual activity)
has_puzzles_data = False
@ -290,34 +341,21 @@ class StatsFormatter:
rating_change_str = StatsFormatter._format_rating_change(rating_change)
game_type_name = game_type.title()
# Точность в шапку не выводим — она всегда дублируется построчным
# разбором партий ниже (периодическая проверка — короткий период).
result += t('game_type_header', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str
)
result += StatsFormatter._format_mode_stats_block(rating, wins, losses, draws, lang) + "\n\n"
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
)
rows_text += StatsFormatter._format_game_rows_block(rows) + "\n\n"
if rows_text:
result += rows_text
@ -325,7 +363,9 @@ class StatsFormatter:
if not has_games_data and not has_puzzles_data:
result += t('no_activity', lang)
return result.rstrip()
# Whole body goes into a single monospace code block so the message doesn't
# alternate between plain-text headers and separately-fenced tables.
return "```\n" + result.rstrip() + "\n```"
@staticmethod
def format_last_year_or_1000(data: Dict[str, Any], username: str, lang: str = 'en') -> str: