mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
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>
140 lines
5.2 KiB
Python
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)
|