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
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:
parent
2a7385290e
commit
f154d3d7c3
3 changed files with 113 additions and 76 deletions
|
|
@ -784,16 +784,8 @@ class LichessBot:
|
||||||
})
|
})
|
||||||
logger.info(f"Added gamer {gamer['username']} with N/A ratings due to error")
|
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"<b>{gamer['username']}</b> "
|
|
||||||
f"⚡ {gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"getgamers: prepared {len(gamers_data)} gamers for display")
|
logger.info(f"getgamers: prepared {len(gamers_data)} gamers for display")
|
||||||
|
|
||||||
# Check if we have any gamers to display
|
# Check if we have any gamers to display
|
||||||
if not gamers_data:
|
if not gamers_data:
|
||||||
logger.warning(f"getgamers: No gamers data prepared, but gamers list was not empty. This should not happen.")
|
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
|
pass
|
||||||
await update.message.reply_text(t('no_gamers', lang))
|
await update.message.reply_text(t('no_gamers', lang))
|
||||||
return
|
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) + "<pre>" + gamers_table + "</pre>"
|
||||||
|
|
||||||
logger.info(f"getgamers: message length: {len(gamers_text)} characters")
|
logger.info(f"getgamers: message length: {len(gamers_text)} characters")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import html
|
||||||
from i18n import t
|
from i18n import t
|
||||||
|
|
||||||
class StatsFormatter:
|
class StatsFormatter:
|
||||||
|
|
@ -97,6 +98,71 @@ class StatsFormatter:
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
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
|
@staticmethod
|
||||||
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en', accuracy_extra: Optional[Dict[str, Any]] = None) -> str:
|
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"""
|
"""Format statistics response according to the template"""
|
||||||
|
|
@ -150,32 +216,16 @@ class StatsFormatter:
|
||||||
# Get game type name (capitalize first letter)
|
# Get game type name (capitalize first letter)
|
||||||
game_type_name = game_type.title()
|
game_type_name = game_type.title()
|
||||||
|
|
||||||
# Точность в шапке имеет смысл только там, где нет построчного разбора партий
|
games_text += t('game_type_header', lang,
|
||||||
# ниже (week) — для today/yesterday она была бы избыточна рядом со строками партий.
|
emoji=emoji,
|
||||||
if game_type in ('blitz', 'rapid', 'classical') and period == "week":
|
game_type=game_type_name,
|
||||||
mode_accuracy = (accuracy_by_mode.get(game_type) or {}).get('accuracy')
|
games_count=games_count,
|
||||||
games_text += t('games_section_with_accuracy', lang,
|
rating_change=rating_change_str
|
||||||
emoji=emoji,
|
)
|
||||||
game_type=game_type_name,
|
|
||||||
games_count=games_count,
|
games_text += StatsFormatter._format_mode_stats_block(
|
||||||
rating_change=rating_change_str,
|
rating, wins, losses, draws, lang
|
||||||
rating=rating,
|
) + "\n\n"
|
||||||
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)
|
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
|
||||||
rows_text = ""
|
rows_text = ""
|
||||||
|
|
@ -186,16 +236,17 @@ class StatsFormatter:
|
||||||
continue
|
continue
|
||||||
emoji = StatsFormatter._get_game_type_emoji(mode)
|
emoji = StatsFormatter._get_game_type_emoji(mode)
|
||||||
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=mode.title())
|
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
|
# Whole body goes into a single monospace code block so the message doesn't
|
||||||
result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
|
# alternate between plain-text headers and separately-fenced tables.
|
||||||
result += task_text
|
body = t('stats_title', lang, username=username, date_range=date_range)
|
||||||
result += games_text.rstrip()
|
body += task_text
|
||||||
|
body += games_text.rstrip()
|
||||||
if rows_text:
|
if rows_text:
|
||||||
result += "\n\n" + rows_text.rstrip()
|
body += "\n\n" + rows_text.rstrip()
|
||||||
|
|
||||||
return result
|
return "```\n" + body + "\n```"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_date_range(period: str, lang: str = 'en') -> str:
|
def _get_date_range(period: str, lang: str = 'en') -> str:
|
||||||
|
|
@ -240,7 +291,7 @@ class StatsFormatter:
|
||||||
else:
|
else:
|
||||||
period_text = t('period_minutes_text', lang, period=period_minutes)
|
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)
|
# Format puzzles first (if available and there's actual activity)
|
||||||
has_puzzles_data = False
|
has_puzzles_data = False
|
||||||
|
|
@ -290,34 +341,21 @@ class StatsFormatter:
|
||||||
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
game_type_name = game_type.title()
|
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'):
|
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 []
|
rows = game_data.get('games') or []
|
||||||
if rows:
|
if rows:
|
||||||
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
|
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"
|
rows_text += StatsFormatter._format_game_rows_block(rows) + "\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:
|
if rows_text:
|
||||||
result += rows_text
|
result += rows_text
|
||||||
|
|
||||||
|
|
@ -325,7 +363,9 @@ class StatsFormatter:
|
||||||
if not has_games_data and not has_puzzles_data:
|
if not has_games_data and not has_puzzles_data:
|
||||||
result += t('no_activity', lang)
|
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
|
@staticmethod
|
||||||
def format_last_year_or_1000(data: Dict[str, Any], username: str, lang: str = 'en') -> str:
|
def format_last_year_or_1000(data: Dict[str, Any], username: str, lang: str = 'en') -> str:
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,11 @@ TRANSLATIONS = {
|
||||||
'no_data': "📭 No data",
|
'no_data': "📭 No data",
|
||||||
'stats_title': "📊 Statistics {username} • {date_range}\n\n",
|
'stats_title': "📊 Statistics {username} • {date_range}\n\n",
|
||||||
'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\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",
|
'game_type_header': "{emoji} {game_type} — {games_count} games • {rating_change}\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",
|
'stat_label_rating': "⭐ Rating:",
|
||||||
|
'stat_label_wins': "✅ Wins:",
|
||||||
|
'stat_label_losses': "❌ Losses:",
|
||||||
|
'stat_label_draws': "🤝 Draws:",
|
||||||
'game_rows_heading': "{emoji} {game_type} games:\n",
|
'game_rows_heading': "{emoji} {game_type} games:\n",
|
||||||
|
|
||||||
# Set period
|
# Set period
|
||||||
|
|
@ -112,7 +115,6 @@ TRANSLATIONS = {
|
||||||
'period_minutes_text': "for {period} minutes",
|
'period_minutes_text': "for {period} minutes",
|
||||||
'period_notification_title': "📊 Statistics {username} • {period_text}\n\n",
|
'period_notification_title': "📊 Statistics {username} • {period_text}\n\n",
|
||||||
'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\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",
|
'no_activity': "📭 No activity for this period",
|
||||||
|
|
||||||
# Last year or 1000 games
|
# Last year or 1000 games
|
||||||
|
|
@ -233,8 +235,11 @@ TRANSLATIONS = {
|
||||||
'no_data': "📭 Нет данных",
|
'no_data': "📭 Нет данных",
|
||||||
'stats_title': "📊 Статистика {username} • {date_range}\n\n",
|
'stats_title': "📊 Статистика {username} • {date_range}\n\n",
|
||||||
'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\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",
|
'game_type_header': "{emoji} {game_type} — {games_count} игр • {rating_change}\n",
|
||||||
'games_section_with_accuracy': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n🎯 Точность: {accuracy}\n\n",
|
'stat_label_rating': "⭐ Рейтинг:",
|
||||||
|
'stat_label_wins': "✅ Побед:",
|
||||||
|
'stat_label_losses': "❌ Поражений:",
|
||||||
|
'stat_label_draws': "🤝 Ничьих:",
|
||||||
'game_rows_heading': "{emoji} Партии {game_type}:\n",
|
'game_rows_heading': "{emoji} Партии {game_type}:\n",
|
||||||
|
|
||||||
# Set period
|
# Set period
|
||||||
|
|
@ -251,7 +256,6 @@ TRANSLATIONS = {
|
||||||
'period_minutes_text': "за {period} минут",
|
'period_minutes_text': "за {period} минут",
|
||||||
'period_notification_title': "📊 Статистика {username} • {period_text}\n\n",
|
'period_notification_title': "📊 Статистика {username} • {period_text}\n\n",
|
||||||
'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\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': "📭 Нет активности за этот период",
|
'no_activity': "📭 Нет активности за этот период",
|
||||||
|
|
||||||
# Last year or 1000 games
|
# Last year or 1000 games
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue