# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Carrying the pre-0.1.0 counter into the event log, once. Before 0.1.0 the bot kept one integer per person and deleted it after each nightly post. When the event log arrived there were 319 messages sitting in that counter, accumulated since the last successful post. Throwing them away would have been tidier, but they are real messages and they are the group's only pre-history. What the counter cannot say is *when*. It has no timestamp column, and its window straddles two calendar days -- from the previous night's post to whenever the migration runs. So a backfilled day is filed under the day that holds most of that window, and marked, because a merged 22-hour day pretending to be a normal one would hand somebody a personal record they never set and would drag every "average day" figure with it. Marked days keep their totals and stay out of records and per-hour statistics. ``group_meta`` carries the mark, so this needs no schema change. """ from __future__ import annotations from typing import TYPE_CHECKING, Final from signal_bot.stats.events import MessageEvent if TYPE_CHECKING: from signal_bot.store import Store BACKFILL_KEY: Final = "backfill_day" # -1, because a backfilled event has no hour at all and any value in 0..23 is # a lie that reads as data. This started as 12, on the reasoning that the day # is marked so "nothing that cares about hours will read these" -- and then # favourite_hour did, and told all seven people their favourite hour was # 12:00, because 319 synthetic noons outweighed every real evening. # # The lesson is in the type, not in a comment: an absent value must not be # spellable as a present one. Hour queries filter `local_hour >= 0`, so # forgetting to exclude the backfilled day can no longer invent an answer. UNKNOWN_HOUR: Final = -1 def has_legacy_counter(store: Store) -> bool: """True when this database predates 0.1.0 and still holds the old table.""" row = store.events.connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_counts'" ).fetchone() return row is not None def backfilled_day(store: Store, group_id: str) -> str | None: """The one day whose totals came from the old counter, if any.""" return store.meta(group_id, BACKFILL_KEY) def seed_from_counter(store: Store, day: str) -> dict[str, int]: """Copy every surviving tally into the event log as one marked day. Args: store: The open database, which must still hold ``message_counts``. day: The Warsaw day to file the counts under. Choose the day covering most of the counter's window. Returns: Messages seeded per group id, for reporting. """ rows = store.events.connection.execute( "SELECT group_id, uuid, display_name, count FROM message_counts WHERE count > 0" ).fetchall() seeded: dict[str, int] = {} for row in rows: group_id = row["group_id"] for index in range(row["count"]): # Synthetic, strictly increasing, and unique per person per group, # because (group, uuid, sent_at_ms) is the primary key. They are # not timestamps anybody should read as one, which is what the # day marking is for. store.events.record( MessageEvent( group_id=group_id, uuid=row["uuid"], display_name=row["display_name"], sent_at_ms=index + 1, day=day, local_hour=UNKNOWN_HOUR, ) ) store.events.note_person(group_id, row["uuid"], row["display_name"], "", day) seeded[group_id] = seeded.get(group_id, 0) + row["count"] for group_id in seeded: store.set_meta(group_id, BACKFILL_KEY, day) return seeded