mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:07:51 +01:00
The queries the statistics are made of, all derived rather than cached -- two sources of truth for one number is how a leaderboard comes to disagree with itself, and this history is small enough that recomputing is free. Periods take an inclusive span of stored Warsaw day strings, never a range of timestamps, so a daylight-saving change cannot move a boundary. Trend reports None rather than 0 when there is no previous period: "nothing to compare against yet" and "no change" are different statements and the group will read them differently. A tie shares the crown and keeps every tied run alive, per kuhy's call. Streaks count *recorded* days, so a day nobody spoke does not end a run -- there was no crown to lose -- and neither does a day the bot was offline for, which it could not have judged. Every record excludes the backfilled day. It is a 22-hour window filed as a day, and leaving it in would hand somebody a personal best they never set. Two bugs found by running the code rather than reading it: - current_streaks reported every streak as zero. Ending somebody's run dropped them from the tally along with the length they had already accumulated, so the surviving dict was empty by the time it returned. Now the run ends and the count it reached is kept. - daily_winners depended on SQLite's grouping order to find a day's leader. It happened to work because uuids sorted conveniently in the fixtures. Ordering by messages DESC makes the first row per day the leader by construction, which is both correct and obviously so. No suppressions: the period queries compare an exclusion parameter against '' instead of pasting half a WHERE clause into an f-string, which is a SQL-injection shape even when every input is a constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
127 lines
4.3 KiB
Python
127 lines
4.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Who holds the crown, and for how long.
|
|
|
|
A tie shares the crown and keeps every tied streak alive. That is a decision,
|
|
not an oversight: breaking somebody's forty-day run on a technicality is the
|
|
kind of thing that starts an argument in a group chat, and "we were both first"
|
|
is what the people involved would say anyway.
|
|
|
|
Streaks count *recorded* days, not calendar days. A day nobody spoke does not
|
|
end a run -- there was no crown to lose -- and neither does a day the bot was
|
|
offline for, which it could not have judged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
from signal_bot.stats.events import EventStore
|
|
|
|
DAILY_COUNTS = """
|
|
SELECT day, uuid, display_name, COUNT(*) AS messages
|
|
FROM message_events
|
|
WHERE group_id = ? AND day <> ?
|
|
GROUP BY day, uuid
|
|
ORDER BY day ASC, messages DESC, display_name ASC
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Streak:
|
|
"""A run of consecutive recorded days holding the crown."""
|
|
|
|
uuid: str
|
|
display_name: str
|
|
length: int
|
|
last_day: str
|
|
|
|
|
|
def daily_winners(
|
|
events: EventStore,
|
|
group_id: str,
|
|
*,
|
|
exclude: str | None = None,
|
|
) -> dict[str, list[tuple[str, str]]]:
|
|
"""Who led each recorded day, as (uuid, display_name), ties included."""
|
|
rows = events.connection.execute(DAILY_COUNTS, (group_id, exclude or "")).fetchall()
|
|
best: dict[str, int] = {}
|
|
winners: dict[str, list[tuple[str, str]]] = {}
|
|
for row in rows:
|
|
day, count = row["day"], row["messages"]
|
|
# Ordered messages-first, so the day's leader is always the row that
|
|
# creates the entry and everything after it either ties or loses.
|
|
if day not in best:
|
|
best[day] = count
|
|
winners[day] = [(row["uuid"], row["display_name"])]
|
|
elif count == best[day]:
|
|
winners[day].append((row["uuid"], row["display_name"]))
|
|
return winners
|
|
|
|
|
|
def current_streaks(winners: dict[str, list[tuple[str, str]]]) -> list[Streak]:
|
|
"""Everyone whose run reaches the most recent recorded day.
|
|
|
|
Sorted longest first, then by name, so two people on the same run are
|
|
reported in a stable order rather than whichever the dict happened to
|
|
yield.
|
|
"""
|
|
if not winners:
|
|
return []
|
|
days = sorted(winners)
|
|
latest = days[-1]
|
|
holders = dict(winners[latest])
|
|
|
|
lengths = dict.fromkeys(holders, 0)
|
|
running = set(holders)
|
|
for day in reversed(days):
|
|
today = {uuid for uuid, _ in winners[day]}
|
|
for uuid in sorted(running):
|
|
if uuid in today:
|
|
lengths[uuid] += 1
|
|
else:
|
|
# Their run ended on this day. What they had already
|
|
# accumulated is kept -- dropping them from the tally, as an
|
|
# earlier spelling did, reported every streak as zero.
|
|
running.discard(uuid)
|
|
if not running:
|
|
break
|
|
|
|
return sorted(
|
|
(Streak(uuid, holders[uuid], length, latest) for uuid, length in lengths.items()),
|
|
key=lambda s: (-s.length, s.display_name),
|
|
)
|
|
|
|
|
|
def longest_ever(winners: dict[str, list[tuple[str, str]]]) -> Streak | None:
|
|
"""The longest crown run anybody has ever put together."""
|
|
running: dict[str, int] = {}
|
|
names: dict[str, str] = {}
|
|
best: Streak | None = None
|
|
|
|
for day in sorted(winners):
|
|
today = dict(winners[day])
|
|
names.update(today)
|
|
running = {uuid: running.get(uuid, 0) + 1 for uuid in today}
|
|
for uuid, length in running.items():
|
|
if (
|
|
best is None
|
|
or length > best.length
|
|
or (length == best.length and names[uuid] < best.display_name)
|
|
):
|
|
best = Streak(uuid, names[uuid], length, day)
|
|
return best
|
|
|
|
|
|
def held_days(winners: dict[str, list[tuple[str, str]]], uuid: str) -> int:
|
|
"""How many days somebody has held the crown in total, ties included."""
|
|
return sum(1 for holders in winners.values() if any(u == uuid for u, _ in holders))
|
|
|
|
|
|
def names_of(holders: Iterable[tuple[str, str]]) -> list[str]:
|
|
"""Display names of a day's crown holders, alphabetical for stability."""
|
|
return sorted(name for _, name in holders)
|