mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
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>
This commit is contained in:
parent
e3c7bbfe23
commit
c195e6b4c1
@ -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
|
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.
|
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
|
## Outages
|
||||||
|
|
||||||
Nothing is ever deleted, and the last day reported is recorded per group.
|
Nothing is ever deleted, and the last day reported is recorded per group.
|
||||||
|
|||||||
@ -64,13 +64,13 @@ def personal_record(facts: DayFacts) -> Highlight | None:
|
|||||||
)
|
)
|
||||||
if previous is None:
|
if previous is None:
|
||||||
continue
|
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(
|
return Highlight(
|
||||||
kind="personal_record",
|
kind="personal_record",
|
||||||
family=RECORD,
|
family=RECORD,
|
||||||
priority=100,
|
priority=100,
|
||||||
magnitude=ratio(best.value, previous.value),
|
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
|
return None
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ def media_record(facts: DayFacts) -> Highlight | None:
|
|||||||
family=RECORD,
|
family=RECORD,
|
||||||
priority=90,
|
priority=90,
|
||||||
magnitude=float(record.value),
|
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ń",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ def _media(count: int) -> str:
|
|||||||
def person_line(row: DayRow, place: int) -> str:
|
def person_line(row: DayRow, place: int) -> str:
|
||||||
"""One person's line: a crown for first place, otherwise a rank."""
|
"""One person's line: a crown for first place, otherwise a rank."""
|
||||||
lead = CROWN if place == 1 else f"{place}."
|
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:
|
def silence_line(rows: list[DayRow], unnamed: int) -> str:
|
||||||
|
|||||||
@ -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."""
|
"""One "label: value (date)" line, or None when there is no record yet."""
|
||||||
if record is None:
|
if record is None:
|
||||||
return 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(
|
def render_records(
|
||||||
@ -91,7 +91,8 @@ def render_records(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Rekordy życiowe:")
|
lines.append("Rekordy życiowe:")
|
||||||
lines.extend(
|
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)
|
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."""
|
"""One person's own numbers."""
|
||||||
lines = [ME_TITLE, "", f"{name} — {text.messages(total)} w historii"]
|
lines = [ME_TITLE, "", f"{name} — {text.messages(total)} w historii"]
|
||||||
if best:
|
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}%")
|
lines.append(f"Udział w rozmowie: {share}%")
|
||||||
if favourite_hour is not None:
|
if favourite_hour is not None:
|
||||||
lines.append(
|
lines.append(
|
||||||
|
|||||||
@ -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."""
|
"""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}."
|
lead = MEDALS[place - 1] if podium and place <= len(MEDALS) else f"{place}."
|
||||||
share = round(row.messages / total * 100) if total else 0
|
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(
|
def render_period(
|
||||||
|
|||||||
@ -11,6 +11,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Final
|
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_START: Final = 12
|
||||||
TEEN_END: Final = 14
|
TEEN_END: Final = 14
|
||||||
FEW_START: Final = 2
|
FEW_START: Final = 2
|
||||||
@ -38,9 +53,19 @@ def plural(count: int, one: str, few: str, many: str) -> str:
|
|||||||
return many
|
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:
|
def messages(count: int) -> str:
|
||||||
"""The count and its noun, agreeing: e.g. 3 wiadomości."""
|
"""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:
|
def characters(count: int) -> str:
|
||||||
@ -50,14 +75,14 @@ def characters(count: int) -> str:
|
|||||||
|
|
||||||
def commands(count: int) -> str:
|
def commands(count: int) -> str:
|
||||||
"""The count and its noun, agreeing: e.g. 4 komendy."""
|
"""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:
|
def people(count: int) -> str:
|
||||||
"""The count and its noun, agreeing: e.g. 2 osoby."""
|
"""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:
|
def days(count: int) -> str:
|
||||||
"""The count and its noun, agreeing: e.g. 3 dni."""
|
"""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')}"
|
||||||
|
|||||||
@ -226,3 +226,12 @@ def test_nothing_to_say_is_an_empty_list() -> None:
|
|||||||
def test_ratio_survives_a_zero_baseline() -> None:
|
def test_ratio_survives_a_zero_baseline() -> None:
|
||||||
assert ratio(5, 0) == 5.0
|
assert ratio(5, 0) == 5.0
|
||||||
assert ratio(10, 5) == 1.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
|
||||||
|
|||||||
@ -120,3 +120,11 @@ def test_a_custom_heading_replaces_the_date() -> None:
|
|||||||
day = rows([("kuhy", 1, 0)])
|
day = rows([("kuhy", 1, 0)])
|
||||||
rendered = render_day("2026-09-01", day, day_totals(day), DayView(heading="Dziś, na razie"))
|
rendered = render_day("2026-09-01", day, day_totals(day), DayView(heading="Dziś, na razie"))
|
||||||
assert rendered.startswith("Dziś, na razie\n")
|
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 📷)")
|
||||||
|
|||||||
@ -127,3 +127,27 @@ def test_me_reports_everything_it_does_know() -> None:
|
|||||||
assert "Rekord życiowy: 12 (1 września)" in rendered
|
assert "Rekord życiowy: 12 (1 września)" in rendered
|
||||||
assert "Najczęściej piszesz między 23:00 a 00:00" in rendered
|
assert "Najczęściej piszesz między 23:00 a 00:00" in rendered
|
||||||
assert "Korona: 2 dni z rzędu" 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
|
||||||
|
|||||||
@ -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")])
|
@pytest.mark.parametrize(("count", "expected"), [(1, "1 dzień"), (2, "2 dni"), (9, "9 dni")])
|
||||||
def test_days_agree(count: int, expected: str) -> None:
|
def test_days_agree(count: int, expected: str) -> None:
|
||||||
assert text.days(count) == expected
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user