diff --git a/DOCS-statistics.md b/DOCS-statistics.md index d37e06b..d97ffe4 100644 --- a/DOCS-statistics.md +++ b/DOCS-statistics.md @@ -50,6 +50,13 @@ Only people whose display name has been seen are named. Somebody who has never written is counted, not named — the group listing knows them only as a raw uuid or a phone number, and printing either into a chat is a leak. +Seven numbers are funny to this group and get a marker wherever they appear +as a *count* — `69 (nice)`, `2137 🙏`, `1410 ⚔️`, `1916 🇵🇱`, `420 🌿`, +`314 🥧`, `67 🤷` — in the nightly report, every `!stats_*` and `!ja`, and the +highlights. Percentages, shares and the `(N 📷)` aside stay bare: a 69% share +is not a 69, and marking the photo count next to an already-marked message +count would be two jokes on one line. The table is `FUNNY` in `stats/text.py`. + ## Outages Nothing is ever deleted, and the last day reported is recorded per group. diff --git a/signal_bot/stats/curiosities.py b/signal_bot/stats/curiosities.py index bfef950..b3fe5af 100644 --- a/signal_bot/stats/curiosities.py +++ b/signal_bot/stats/curiosities.py @@ -64,13 +64,13 @@ def personal_record(facts: DayFacts) -> Highlight | None: ) if previous is None: continue - beaten = f" (poprzedni {previous.value}, {pl_day_month(previous.day)})" + beaten = f" (poprzedni {text.marked(previous.value)}, {pl_day_month(previous.day)})" return Highlight( kind="personal_record", family=RECORD, priority=100, magnitude=ratio(best.value, previous.value), - text=f"{TROPHY} {row.display_name} {best.value} — rekord życiowy{beaten}", + text=f"{TROPHY} {row.display_name} {text.marked(best.value)} — rekord życiowy{beaten}", ) return None @@ -100,7 +100,7 @@ def media_record(facts: DayFacts) -> Highlight | None: family=RECORD, priority=90, magnitude=float(record.value), - text=f"{CAMERA} rekord zdjęć: {record.value} w jeden dzień", + text=f"{CAMERA} rekord zdjęć: {text.marked(record.value)} w jeden dzień", ) diff --git a/signal_bot/stats/render_day.py b/signal_bot/stats/render_day.py index 8cecc3e..83d9323 100644 --- a/signal_bot/stats/render_day.py +++ b/signal_bot/stats/render_day.py @@ -37,7 +37,7 @@ def _media(count: int) -> str: 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} — {row.messages}{_media(row.media)}" + return f"{lead} {row.display_name} — {text.marked(row.messages)}{_media(row.media)}" def silence_line(rows: list[DayRow], unnamed: int) -> str: diff --git a/signal_bot/stats/render_extra.py b/signal_bot/stats/render_extra.py index 8037ba4..1679d27 100644 --- a/signal_bot/stats/render_extra.py +++ b/signal_bot/stats/render_extra.py @@ -64,7 +64,7 @@ def _record_line(label: str, record: DayRecord | None, unit: str) -> str | None: """One "label: value (date)" line, or None when there is no record yet.""" if record is None: return None - return f"{label}: {record.value} {unit} ({pl_day_month(record.day)})" + return f"{label}: {text.marked(record.value)} {unit} ({pl_day_month(record.day)})" def render_records( @@ -91,7 +91,8 @@ def render_records( lines.append("") lines.append("Rekordy życiowe:") lines.extend( - f" {name} — {record.value} ({pl_day_month(record.day)})" for name, record in personal + f" {name} — {text.marked(record.value)} ({pl_day_month(record.day)})" + for name, record in personal ) return "\n".join(lines) if len(lines) > 1 else nothing_yet("records", since) @@ -161,7 +162,9 @@ def render_me( """One person's own numbers.""" lines = [ME_TITLE, "", f"{name} — {text.messages(total)} w historii"] if best: - lines.append(f"{TROPHY} Rekord życiowy: {best.value} ({pl_day_month(best.day)})") + lines.append( + f"{TROPHY} Rekord życiowy: {text.marked(best.value)} ({pl_day_month(best.day)})" + ) lines.append(f"Udział w rozmowie: {share}%") if favourite_hour is not None: lines.append( diff --git a/signal_bot/stats/render_period.py b/signal_bot/stats/render_period.py index 31cba21..23624f8 100644 --- a/signal_bot/stats/render_period.py +++ b/signal_bot/stats/render_period.py @@ -51,7 +51,7 @@ def _row(row: DayRow, place: int, total: int, *, podium: bool) -> str: """One person's line, with a medal or a rank and a share bar.""" lead = MEDALS[place - 1] if podium and place <= len(MEDALS) else f"{place}." share = round(row.messages / total * 100) if total else 0 - return f"{lead} {row.display_name} — {row.messages} {bar(share)} {share}%" + return f"{lead} {row.display_name} — {text.marked(row.messages)} {bar(share)} {share}%" def render_period( diff --git a/signal_bot/stats/text.py b/signal_bot/stats/text.py index d650587..510f039 100644 --- a/signal_bot/stats/text.py +++ b/signal_bot/stats/text.py @@ -11,6 +11,21 @@ from __future__ import annotations from typing import Final +# Numbers the group reacts to whenever one comes up, and what it says. They +# go on counts -- messages, records, days, people -- and never on a share: +# a 69% share of voice is a rounding artefact, not something anybody did. +# The parenthesised photo aside in the day block stays bare too, because +# "(69 (nice) 📷)" nests brackets and reads as a typo. +FUNNY: Final[dict[int, str]] = { + 67: "\U0001f937", + 69: "(nice)", + 314: "\U0001f967", + 420: "\U0001f33f", + 1410: "\u2694\ufe0f", + 1916: "\U0001f1f5\U0001f1f1", + 2137: "\U0001f64f", +} + TEEN_START: Final = 12 TEEN_END: Final = 14 FEW_START: Final = 2 @@ -38,9 +53,19 @@ def plural(count: int, one: str, few: str, many: str) -> str: return many +def marked(count: int) -> str: + """The count as printed: bare, or with its marker when the group has one. + + The marker sticks to the number ("69 (nice) wiadomości"), so the same + count reads the same whether a noun follows it or not. + """ + marker = FUNNY.get(count) + return f"{count} {marker}" if marker else str(count) + + def messages(count: int) -> str: """The count and its noun, agreeing: e.g. 3 wiadomości.""" - return f"{count} {plural(count, 'wiadomość', 'wiadomości', 'wiadomości')}" + return f"{marked(count)} {plural(count, 'wiadomość', 'wiadomości', 'wiadomości')}" def characters(count: int) -> str: @@ -50,14 +75,14 @@ def characters(count: int) -> str: def commands(count: int) -> str: """The count and its noun, agreeing: e.g. 4 komendy.""" - return f"{count} {plural(count, 'komenda', 'komendy', 'komend')}" + return f"{marked(count)} {plural(count, 'komenda', 'komendy', 'komend')}" def people(count: int) -> str: """The count and its noun, agreeing: e.g. 2 osoby.""" - return f"{count} {plural(count, 'osoba', 'osoby', 'osób')}" + return f"{marked(count)} {plural(count, 'osoba', 'osoby', 'osób')}" def days(count: int) -> str: """The count and its noun, agreeing: e.g. 3 dni.""" - return f"{count} {plural(count, 'dzień', 'dni', 'dni')}" + return f"{marked(count)} {plural(count, 'dzień', 'dni', 'dni')}" diff --git a/tests/test_stats_curiosities.py b/tests/test_stats_curiosities.py index 9656d49..df04633 100644 --- a/tests/test_stats_curiosities.py +++ b/tests/test_stats_curiosities.py @@ -226,3 +226,12 @@ def test_nothing_to_say_is_an_empty_list() -> None: def test_ratio_survives_a_zero_baseline() -> None: assert ratio(5, 0) == 5.0 assert ratio(10, 5) == 1.0 + + +def test_a_record_that_lands_on_a_funny_number_carries_its_marker(store: Store) -> None: + """The count is what is funny; the marker rides on it wherever the day's notice prints it.""" + wrote(store, "2026-09-01", ALA, 69) + wrote(store, "2026-09-02", ALA, 2137) + found = curiosities.personal_record(facts_for(store, "2026-09-02")) + assert found is not None + assert "Ala 2137 🙏 — rekord życiowy (poprzedni 69 (nice), 1 września)" in found.text diff --git a/tests/test_stats_render_day.py b/tests/test_stats_render_day.py index ff88e28..22155cd 100644 --- a/tests/test_stats_render_day.py +++ b/tests/test_stats_render_day.py @@ -120,3 +120,11 @@ def test_a_custom_heading_replaces_the_date() -> None: day = rows([("kuhy", 1, 0)]) rendered = render_day("2026-09-01", day, day_totals(day), DayView(heading="Dziś, na razie")) assert rendered.startswith("Dziś, na razie\n") + + +def test_a_funny_count_is_marked_on_the_person_and_the_total_but_not_the_photos() -> None: + """The (N 📷) aside stays bare; 67 photos is a count too, but marking it doubles the noise.""" + day = rows([("Ala", 69, 67), ("kuhy", 2, 0)]) + rendered = render_day("2026-09-01", day, day_totals(day)) + assert "👑 Ala — 69 (nice) (67 📷)" in rendered + assert rendered.startswith("📊 Wtorek, 1 września — 71 wiadomości (67 📷)") diff --git a/tests/test_stats_render_period.py b/tests/test_stats_render_period.py index 985d735..4e4de83 100644 --- a/tests/test_stats_render_period.py +++ b/tests/test_stats_render_period.py @@ -127,3 +127,27 @@ def test_me_reports_everything_it_does_know() -> None: assert "Rekord życiowy: 12 (1 września)" in rendered assert "Najczęściej piszesz między 23:00 a 00:00" in rendered assert "Korona: 2 dni z rzędu" in rendered + + +def test_funny_counts_are_marked_wherever_a_count_is_printed() -> None: + """The number carries its marker in a row, a record and the me block alike. + + Shares are left alone: the 69% here is the rounding of 2137 / 3106, not + something anybody did. + """ + period = render_period(WEEK, rows([("Ala", 2137), ("kuhy", 969)]), Trend(0, 0)) + assert "Ala — 2137 🙏 " in period + assert "3106 wiadomości" in period + assert "69%" in period + assert "69 (nice)%" not in period + + records = render_records( + {"2026-09-01": 420}, None, None, [("Ala", DayRecord("2026-09-01", 1410))] + ) + assert "Najwięcej w jeden dzień: 420 🌿 wiadomości" in records + assert "Ala — 1410 ⚔️ (1 września)" in records + + me = render_me("kuhy", 1916, DayRecord("2026-09-01", 314), 40, None, 67) + assert "kuhy — 1916 🇵🇱 wiadomości w historii" in me + assert "Rekord życiowy: 314 🥧 (1 września)" in me + assert "Korona: 67 🤷 dni z rzędu" in me diff --git a/tests/test_stats_text.py b/tests/test_stats_text.py index c6c23b8..377249e 100644 --- a/tests/test_stats_text.py +++ b/tests/test_stats_text.py @@ -58,3 +58,34 @@ def test_people_agree(count: int, expected: str) -> None: @pytest.mark.parametrize(("count", "expected"), [(1, "1 dzień"), (2, "2 dni"), (9, "9 dni")]) def test_days_agree(count: int, expected: str) -> None: assert text.days(count) == expected + + +@pytest.mark.parametrize( + ("count", "expected"), + [ + (67, "67 🤷"), + (69, "69 (nice)"), + (314, "314 🥧"), + (420, "420 🌿"), + (1410, "1410 ⚔️"), + (1916, "1916 🇵🇱"), + (2137, "2137 🙏"), + (68, "68"), + (0, "0"), + (21370, "21370"), + ], +) +def test_the_numbers_the_group_reacts_to_carry_their_marker(count: int, expected: str) -> None: + assert text.marked(count) == expected + + +def test_the_marker_sticks_to_the_number_in_front_of_its_noun() -> None: + assert text.messages(69) == "69 (nice) wiadomości" + assert text.commands(2137) == "2137 🙏 komend" + assert text.people(67) == "67 🤷 osób" + assert text.days(1410) == "1410 ⚔️ dni" + + +def test_an_average_is_not_a_count_and_stays_bare() -> None: + """ "średnio 69 znaków" is a mean: nobody wrote it, so nothing is marked.""" + assert text.characters(69) == "69 znaków"