# 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)