From f154d3d7c3f9c5adf4370f90cd627ef560209be1 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Fri, 3 Jul 2026 13:20:50 +0000 Subject: [PATCH] align /getgamers and stats-message tables, unify stats body into one code block - /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. --- LichessClientTG_bot/bot.py | 15 +-- LichessClientTG_bot/formatters.py | 158 +++++++++++++++++++----------- LichessClientTG_bot/i18n.py | 16 +-- 3 files changed, 113 insertions(+), 76 deletions(-) diff --git a/LichessClientTG_bot/bot.py b/LichessClientTG_bot/bot.py index 5b088d6..f72832f 100644 --- a/LichessClientTG_bot/bot.py +++ b/LichessClientTG_bot/bot.py @@ -784,16 +784,8 @@ class LichessBot: }) logger.info(f"Added gamer {gamer['username']} with N/A ratings due to error") - # Create text message with stats - text_lines = [] - for gamer in gamers_data: - text_lines.append( - f"{gamer['username']} " - f"⚡ {gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}" - ) - logger.info(f"getgamers: prepared {len(gamers_data)} gamers for display") - + # Check if we have any gamers to display if not gamers_data: logger.warning(f"getgamers: No gamers data prepared, but gamers list was not empty. This should not happen.") @@ -803,8 +795,9 @@ class LichessBot: pass await update.message.reply_text(t('no_gamers', lang)) return - - gamers_text = t('select_active_gamer', lang) + "\n".join(text_lines) + + gamers_table = StatsFormatter.format_gamers_table(gamers_data) + gamers_text = t('select_active_gamer', lang) + "
" + gamers_table + "
" logger.info(f"getgamers: message length: {len(gamers_text)} characters") diff --git a/LichessClientTG_bot/formatters.py b/LichessClientTG_bot/formatters.py index 5bed3ed..3a1e36f 100644 --- a/LichessClientTG_bot/formatters.py +++ b/LichessClientTG_bot/formatters.py @@ -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 +
 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:
diff --git a/LichessClientTG_bot/i18n.py b/LichessClientTG_bot/i18n.py
index ef4ec94..ef501fc 100644
--- a/LichessClientTG_bot/i18n.py
+++ b/LichessClientTG_bot/i18n.py
@@ -94,8 +94,11 @@ TRANSLATIONS = {
         'no_data': "📭 No data",
         'stats_title': "📊 Statistics {username} • {date_range}\n\n",
         'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\n\n",
-        'games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
-        'games_section_with_accuracy': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n🎯 Accuracy: {accuracy}\n\n",
+        'game_type_header': "{emoji} {game_type} — {games_count} games • {rating_change}\n",
+        'stat_label_rating': "⭐ Rating:",
+        'stat_label_wins': "✅ Wins:",
+        'stat_label_losses': "❌ Losses:",
+        'stat_label_draws': "🤝 Draws:",
         'game_rows_heading': "{emoji} {game_type} games:\n",
 
         # Set period
@@ -112,7 +115,6 @@ TRANSLATIONS = {
         'period_minutes_text': "for {period} minutes",
         'period_notification_title': "📊 Statistics {username} • {period_text}\n\n",
         'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\n\n",
-        'period_games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
         'no_activity': "📭 No activity for this period",
 
         # Last year or 1000 games
@@ -233,8 +235,11 @@ TRANSLATIONS = {
         'no_data': "📭 Нет данных",
         'stats_title': "📊 Статистика {username} • {date_range}\n\n",
         'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\n\n",
-        'games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
-        'games_section_with_accuracy': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n🎯 Точность: {accuracy}\n\n",
+        'game_type_header': "{emoji} {game_type} — {games_count} игр • {rating_change}\n",
+        'stat_label_rating': "⭐ Рейтинг:",
+        'stat_label_wins': "✅ Побед:",
+        'stat_label_losses': "❌ Поражений:",
+        'stat_label_draws': "🤝 Ничьих:",
         'game_rows_heading': "{emoji} Партии {game_type}:\n",
 
         # Set period
@@ -251,7 +256,6 @@ TRANSLATIONS = {
         'period_minutes_text': "за {period} минут",
         'period_notification_title': "📊 Статистика {username} • {period_text}\n\n",
         'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\n\n",
-        'period_games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
         'no_activity': "📭 Нет активности за этот период",
 
         # Last year or 1000 games