diff --git a/DOCS-changelog.md b/DOCS-changelog.md index 21f01ab..615d1f0 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -38,6 +38,13 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about `/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can reach, and it was rightly called out. +## 0.1.1 — Pius I + +- komendy mówią teraz, co pokazują — `!korona` na pustych danych tłumaczy, + co się w niej pojawi, zamiast samego "za mało danych" +- `!ja` pisze "między 12:00 a 13:00" zamiast "o 12:00" — bo to godzina, a + nie moment + ## 0.1.0 — Hygin - podsumowanie liczy teraz zdjęcia i filmy — zdjęcie bez podpisu było diff --git a/pyproject.toml b/pyproject.toml index 72157f7..d6febe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "signal-bot" -version = "0.1.0" +version = "0.1.1" description = "A Signal group bot: image commands and daily message tallies." readme = "README.md" license = "MIT" diff --git a/signal_bot/stats/commands.py b/signal_bot/stats/commands.py index 47b1e9d..118fd70 100644 --- a/signal_bot/stats/commands.py +++ b/signal_bot/stats/commands.py @@ -32,6 +32,7 @@ from signal_bot.stats.queries_period import ( ) from signal_bot.stats.render_day import DayView, render_day from signal_bot.stats.render_extra import ( + ME_TITLE, favourite_hour, render_crown, render_me, @@ -130,6 +131,7 @@ async def all_records(ctx: CommandContext) -> None: records.most_media_day(store.events, ctx.group_id, exclude=exclude), records.longest_silence(history, periods.today()) if history else None, personal, + store.events.first_day(ctx.group_id), ) ) @@ -140,14 +142,26 @@ async def crown(ctx: CommandContext) -> None: winners = streaks.daily_winners( store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id) ) - await ctx.reply(render_crown(winners, store.events.people(ctx.group_id))) + await ctx.reply( + render_crown( + winners, + store.events.people(ctx.group_id), + store.events.first_day(ctx.group_id), + ) + ) async def weekdays(ctx: CommandContext) -> None: """`!dni` -- the Monday-to-Sunday picture.""" store = ctx.store history = every_day(store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id)) - await ctx.reply(render_weekdays(weekday_buckets(history), periods.weekday_of(periods.today()))) + await ctx.reply( + render_weekdays( + weekday_buckets(history), + periods.weekday_of(periods.today()), + store.events.first_day(ctx.group_id), + ) + ) async def me(ctx: CommandContext) -> None: @@ -162,7 +176,7 @@ async def me(ctx: CommandContext) -> None: ) mine = next((row for row in everyone if row.uuid == uuid), None) if mine is None: - await ctx.reply("Nie mam jeszcze od ciebie ani jednej wiadomości.") + await ctx.reply(f"{ME_TITLE}\n\nNie mam jeszcze od ciebie ani jednej wiadomości.") return total = sum(row.messages for row in everyone) winners = streaks.daily_winners(store.events, ctx.group_id, exclude=exclude) diff --git a/signal_bot/stats/render_day.py b/signal_bot/stats/render_day.py index 01c946e..27becaa 100644 --- a/signal_bot/stats/render_day.py +++ b/signal_bot/stats/render_day.py @@ -62,7 +62,9 @@ def summary_line(totals: DayTotals, *, approximate: bool = False) -> str: """ if not totals.messages: return "" - parts = [] if approximate else [f"średnio {text.characters(totals.average_length)}"] + 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: diff --git a/signal_bot/stats/render_extra.py b/signal_bot/stats/render_extra.py index e6b0b74..d3a1a22 100644 --- a/signal_bot/stats/render_extra.py +++ b/signal_bot/stats/render_extra.py @@ -22,7 +22,42 @@ CROWN: Final = "\U0001f451" CALENDAR: Final = "\U0001f4c5" CAMERA: Final = "\U0001f4f7" QUIET: Final = "\U0001f92b" -NOTHING_YET: Final = "Za mało danych — wróć za kilka dni." +HOURS_IN_A_DAY: Final = 24 +# Every command says what it is, whether or not it has data yet. Somebody +# typing !korona on day one used to get "za mało danych" and nothing else, +# which tells them neither what the command does nor when it will work. +TITLES: Final = { + "crown": f"{CROWN} Korona — kto danego dnia napisał najwięcej wiadomości", + "records": f"{TROPHY} Rekordy — najlepsze i najgorsze dni w historii grupy", + "weekdays": f"{CALENDAR} Dni tygodnia — ile średnio piszecie w każdy z nich", +} +# What each will show once there is enough history, so the empty answer is +# still an answer. +PROMISES: Final = { + "crown": ( + "Pokażę: kto ma koronę teraz, jak długą serię, kto miał najdłuższą " + "w historii i ile dni każdy ją nosił." + ), + "records": ( + "Pokażę: najgadatliwszy dzień, najcichszy, dzień z największą liczbą " + "zdjęć, najdłuższą ciszę i rekord życiowy każdego z was." + ), + "weekdays": ( + "Pokażę: średnią liczbę wiadomości w poniedziałki, wtorki i tak dalej " + "— czyli czy weekendy są gadatliwsze od środy." + ), +} + + +def nothing_yet(what: str, since: str | None = None) -> str: + """The empty answer, which still says what the command is for. + + A bare "za mało danych" tells somebody neither what they asked for nor + when it will work, which is exactly what happened within minutes of + 0.1.0 going out. + """ + started = f"Liczę od {pl_day_month(since)}. " if since else "" + return "\n".join([TITLES[what], "", f"{started}Jeszcze za mało danych.", PROMISES[what]]) def _record_line(label: str, record: DayRecord | None, unit: str) -> str | None: @@ -37,9 +72,10 @@ def render_records( media: DayRecord | None, silence: records.Silence | None, personal: list[tuple[str, DayRecord]], + since: str | None = None, ) -> str: """Every all-time record the group has set.""" - lines = [f"{TROPHY} Rekordy"] + lines = [TITLES["records"]] candidates = [ _record_line("Najwięcej w jeden dzień", records.busiest_day(history), "wiadomości"), _record_line("Najciszej (ale ktoś pisał)", records.quietest_day(history), "wiadomości"), @@ -57,16 +93,17 @@ def render_records( lines.extend( f" {name} — {record.value} ({pl_day_month(record.day)})" for name, record in personal ) - return "\n".join(lines) if len(lines) > 1 else NOTHING_YET + return "\n".join(lines) if len(lines) > 1 else nothing_yet("records", since) def render_crown( winners: dict[str, list[tuple[str, str]]], people: list[Person], + since: str | None = None, ) -> str: """Who holds the crown now, who held it longest, and who has held it most.""" if not winners: - return NOTHING_YET + return nothing_yet("crown", since) # Both are guaranteed once winners is non-empty, which the guard above # already established: the latest day always has at least one holder, and # a day with a holder is a run of at least one. @@ -76,7 +113,7 @@ def render_crown( longest = streaks.longest_ever(winners) or current[0] names = " i ".join(s.display_name for s in current if s.length == current[0].length) lines = [ - f"{CROWN} Korona", + TITLES["crown"], f"Teraz: {names} — {text.days(current[0].length)} z rzędu", ( f"Najdłuższa seria: {longest.display_name} — {text.days(longest.length)} " @@ -93,11 +130,11 @@ def render_crown( return "\n".join(lines) -def render_weekdays(buckets: dict[int, list[int]], today: int) -> str: +def render_weekdays(buckets: dict[int, list[int]], today: int, since: str | None = None) -> str: """The Monday-to-Sunday picture, with today's weekday called out.""" if not buckets: - return NOTHING_YET - lines = [f"{CALENDAR} Dni tygodnia"] + return nothing_yet("weekdays", since) + lines = [TITLES["weekdays"]] for index in range(len(WEEKDAY_PLURAL)): days = buckets.get(index) if not days: @@ -110,6 +147,9 @@ def render_weekdays(buckets: dict[int, list[int]], today: int) -> str: return "\n".join(lines) +ME_TITLE: Final = "Twoje liczby w tej grupie" + + def render_me( name: str, total: int, @@ -119,12 +159,15 @@ def render_me( streak: int, ) -> str: """One person's own numbers.""" - lines = [f"{name} — {text.messages(total)} w historii"] + 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"Udział w rozmowie: {share}%") if favourite_hour is not None: - lines.append(f"Najczęściej piszesz o {favourite_hour:02d}:00") + lines.append( + f"Najczęściej piszesz między {favourite_hour:02d}:00 a " + f"{(favourite_hour + 1) % HOURS_IN_A_DAY:02d}:00" + ) if streak: lines.append(f"{CROWN} Korona: {text.days(streak)} z rzędu") return "\n".join(lines) diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index afe2610..9f61075 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -28,7 +28,7 @@ PATCHES_PER_MINOR: Final = 8 # Which release this build is. Bump by one per release; everything else, # including the version string in pyproject.toml, follows from it. -RELEASE_ORDINAL: Final = 9 +RELEASE_ORDINAL: Final = 10 _POPES_FILE: Final = Path(__file__).with_name("popes.json") diff --git a/tests/test_stats_commands.py b/tests/test_stats_commands.py index ae12039..9a25ce4 100644 --- a/tests/test_stats_commands.py +++ b/tests/test_stats_commands.py @@ -136,7 +136,7 @@ async def test_me_reports_the_asker(store: Store) -> None: answer = await run(store, "stats_me") assert "kuhy — 6 wiadomości w historii" in answer assert "Udział w rozmowie: 75%" in answer - assert "Najczęściej piszesz o 23:00" in answer + assert "Najczęściej piszesz między 23:00 a 00:00" in answer async def test_me_is_honest_with_somebody_who_never_wrote(store: Store) -> None: diff --git a/tests/test_stats_render_day.py b/tests/test_stats_render_day.py index de1a82a..1bf3caa 100644 --- a/tests/test_stats_render_day.py +++ b/tests/test_stats_render_day.py @@ -51,7 +51,7 @@ def test_the_nightly_block_for_a_real_day() -> None: "6. Cezary Wiśniewski — 15\n" "🏆 Ala 76 — rekord życiowy (poprzedni 71)\n" "Cisza: Marek\n" - "Średnio 47 znaków.\n" + "Średnio 47 znaków na wiadomość.\n" "(liczymy od 30 sierpnia)" ) assert len(rendered.split("\n")) <= MAX_LINES @@ -66,7 +66,7 @@ def test_a_day_with_no_media_shows_no_camera() -> None: def test_commands_are_broken_out() -> None: day = rows([("kuhy", 10, 0)], commands=4) - assert summary_line(day_totals(day)) == "Z tego 4 komendy, średnio 47 znaków." + assert summary_line(day_totals(day)) == "Z tego 4 komendy, średnio 47 znaków na wiadomość." def test_an_approximate_day_claims_no_average_length() -> None: diff --git a/tests/test_stats_render_period.py b/tests/test_stats_render_period.py index e3af347..985d735 100644 --- a/tests/test_stats_render_period.py +++ b/tests/test_stats_render_period.py @@ -9,7 +9,7 @@ from signal_bot.stats.queries_day import DayRow from signal_bot.stats.queries_period import Trend from signal_bot.stats.records import DayRecord, Silence from signal_bot.stats.render_extra import ( - NOTHING_YET, + ME_TITLE, render_crown, render_me, render_records, @@ -60,8 +60,25 @@ def test_medals_only_appear_on_a_podium() -> None: assert "🥇" not in render_period(WEEK, three, Trend(6, 5)) -def test_records_with_nothing_recorded_says_so() -> None: - assert render_records({}, None, None, []) == NOTHING_YET +def test_an_empty_command_still_says_what_it_is_for() -> None: + """A bare "za mało danych" tells nobody what they asked for. + + Reported by the group within minutes of 0.1.0: somebody typed !korona, + got only that line, and learned neither what the command does nor when + it would work. + """ + for rendered in ( + render_records({}, None, None, [], "2026-08-30"), + render_crown({}, [], "2026-08-30"), + render_weekdays({}, 0, "2026-08-30"), + ): + assert "Jeszcze za mało danych." in rendered + assert "Pokażę:" in rendered + assert "Liczę od 30 sierpnia." in rendered + + +def test_an_empty_command_without_a_start_date_omits_it() -> None: + assert "Liczę od" not in render_crown({}, []) def test_records_reports_each_kind_it_has() -> None: @@ -78,10 +95,6 @@ def test_records_reports_each_kind_it_has() -> None: assert "Ala — 9" in rendered -def test_the_crown_with_no_history() -> None: - assert render_crown({}, []) == NOTHING_YET - - def test_the_crown_reports_now_longest_and_totals() -> None: winners = { "2026-09-01": [("u1", "Ala")], @@ -95,10 +108,6 @@ def test_the_crown_reports_now_longest_and_totals() -> None: assert "Ala — 2 dni" in rendered -def test_weekdays_with_no_history() -> None: - assert render_weekdays({}, 0) == NOTHING_YET - - def test_weekdays_skips_days_never_recorded() -> None: rendered = render_weekdays({0: [10, 20], 4: [5]}, 4) assert "poniedziałki: średnio 15" in rendered @@ -110,11 +119,11 @@ def test_weekdays_skips_days_never_recorded() -> None: def test_me_omits_what_it_does_not_know() -> None: """No record, no favourite hour, no streak: three clauses that vanish.""" rendered = render_me("kuhy", 3, None, 10, None, 0) - assert rendered == "kuhy — 3 wiadomości w historii\nUdział w rozmowie: 10%" + assert rendered == f"{ME_TITLE}\n\nkuhy — 3 wiadomości w historii\nUdział w rozmowie: 10%" def test_me_reports_everything_it_does_know() -> None: rendered = render_me("kuhy", 30, DayRecord("2026-09-01", 12), 40, 23, 2) assert "Rekord życiowy: 12 (1 września)" in rendered - assert "Najczęściej piszesz o 23: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