signal-bot/signal_bot/stats/render_day.py
Krzysztof kuhy Rudnicki c195e6b4c1
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Funny numbers: 69 (nice), 2137 🙏 and five more on every count the stats print
Seven numbers the group finds funny get a marker wherever they show up as
a count: the nightly table, every !stats_* view, !ja, the records and the
highlights. Percentages and shares stay bare (a 69% share is not a 69), and
so does the (N 📷) aside, so one line never carries two jokes. One table,
FUNNY in stats/text.py, and one helper, marked(), so adding a number is one
line. Documented in DOCS-statistics.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:55:33 +02:00

140 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Rendering one day as the block the group reads.
Plain text, because that is all Signal renders. No markdown, and no column
padding: Signal uses a proportional font, so ``ljust`` lines up in a terminal
and on nobody's phone. A separator that does not depend on glyph widths is the
only spelling that looks the same in both places.
The block is capped at MAX_LINES so the nightly post stays something people
read rather than scroll past. When it has to give something up it gives up
speakers, never the highlight -- a leaderboard with the interesting part cut
off is worse than a shorter leaderboard.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from signal_bot.stats import text
from signal_bot.stats.periods import pl_date, pl_day_month
if TYPE_CHECKING:
from signal_bot.stats.queries_day import DayRow, DayTotals
MAX_LINES: Final = 12
CROWN: Final = "\U0001f451"
CAMERA: Final = "\U0001f4f7"
CHART: Final = "\U0001f4ca"
def _media(count: int) -> str:
"""The camera marker, outside any padded column so it cannot misalign."""
return f" ({count} {CAMERA})" if count else ""
def person_line(row: DayRow, place: int) -> str:
"""One person's line: a crown for first place, otherwise a rank."""
lead = CROWN if place == 1 else f"{place}."
return f"{lead} {row.display_name} — {text.marked(row.messages)}{_media(row.media)}"
def silence_line(rows: list[DayRow], unnamed: int) -> str:
"""Everyone who said nothing, in one line, or "" when everybody spoke.
``unnamed`` are group members who have never written here at all. They are
counted rather than named on purpose: the listing knows them only as a raw
uuid or a phone number, and printing either into a group chat is a leak.
"""
names = [row.display_name for row in rows if row.is_silent]
if unnamed:
names.append(f"i {text.people(unnamed)} bez ani jednej wiadomości tutaj")
return "Cisza: " + ", ".join(names) if names else ""
def summary_line(totals: DayTotals, *, approximate: bool = False) -> str:
"""Commands and average length -- the two facts that qualify the count.
``approximate`` drops the length claim. It is set for the one day carried
over from the pre-0.1.0 counter, whose messages have a count but no
lengths: reporting "średnio 0 znaków" would be a measurement nobody took.
"""
if not totals.messages:
return ""
parts = (
[] if approximate else [f"średnio {text.characters(totals.average_length)} na wiadomość"]
)
if totals.commands:
parts.insert(0, f"z tego {text.commands(totals.commands)}")
if not parts:
return ""
return parts[0].capitalize() + (f", {parts[1]}." if len(parts) > 1 else ".")
@dataclass(frozen=True, slots=True)
class DayView:
"""How one day is presented, apart from its numbers.
A value object rather than a widening keyword list: this renderer is
shared by the nightly post and several commands, each wanting a different
combination of these, and they keep accumulating.
"""
since: str | None = None
unnamed: int = 0
highlights: tuple[str, ...] = ()
heading: str | None = None
# The day's messages have counts but no lengths -- see stats.backfill.
approximate: bool = False
# "21:37" when the day is still running and the count stopped there, so
# the block never presents a partial day as a whole one. None for a
# finished day, which needs no caveat.
closed_at: str | None = None
def render_day(
day: str,
rows: list[DayRow],
totals: DayTotals,
view: DayView | None = None,
) -> str:
"""Compose the day block, trimmed to MAX_LINES."""
view = view or DayView()
header = (
view.heading
or f"{CHART} {pl_date(day)} — {text.messages(totals.messages)}{_media(totals.media)}"
)
tail = [
line
for line in (
silence_line(rows, view.unnamed),
summary_line(totals, approximate=view.approximate),
)
if line
]
picks = list(view.highlights)
if view.closed_at:
tail.append(f"(licznik zamknięty o {view.closed_at}; reszta wieczoru wpada do jutra)")
if view.since:
tail.append(f"(liczymy od {pl_day_month(view.since)})")
# Everything except the speakers is mandatory, and at least one highlight
# slot is reserved before any speaker is allowed a line.
reserved = len(tail) + min(len(picks), 1)
spoke = [row for row in rows if not row.is_silent]
# At least one, so the truncation line below always has a line to occupy.
# The tail cannot realistically reach eleven lines, but a floor here is
# cheaper than a defensive branch nothing can ever exercise.
room = max(1, MAX_LINES - 1 - reserved)
people = [person_line(row, i + 1) for i, row in enumerate(spoke[:room])]
if room < len(spoke):
hidden = spoke[room - 1 :]
total = sum(r.messages for r in hidden)
people[-1] = f"…i {text.people(len(hidden))} więcej (razem {text.messages(total)})"
lines = [header, *people]
lines.extend(picks[: max(0, MAX_LINES - len(lines) - len(tail))])
lines.extend(tail)
return "\n".join(lines)