diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index de88c1c..b6bcf34 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -25,11 +25,16 @@ from ..core.models import ( from . import db -#: The trailing window (days) a channel's recent-activity tally is computed over. One week +#: The trailing window (days) a channel's recent-activity view is computed over. One week #: smooths out the day-to-day lulls of a hobbyist mesh while still going quiet within days -#: of a channel actually dying down, which is the granularity the activity meter shows. +#: of a channel actually dying down, which is the granularity the activity sparkline shows. ACTIVITY_WINDOW_DAYS = 7 +#: How many equal time buckets the activity window is split into — one per column of the +#: channel manager's braille sparkline (two columns per braille cell, so 16 buckets fill +#: its eight characters). At a one-week window each bucket spans ~10.5 hours. +ACTIVITY_BUCKETS = 16 + @dataclass(slots=True) class ChannelStats: @@ -37,15 +42,18 @@ class ChannelStats: Attributes: total: Messages ever stored for the channel, sent and received alike. - recent: Messages within the trailing :data:`ACTIVITY_WINDOW_DAYS` window — the - basis of the channel manager's activity meter. + recent: Messages within the trailing :data:`ACTIVITY_WINDOW_DAYS` window. last_at: When the channel's most recent message was stored, or ``None`` if the stored timestamp can't be parsed. + histogram: The window's messages split into :data:`ACTIVITY_BUCKETS` equal time + buckets, oldest first (so the newest traffic sits at the right edge of the + sparkline drawn from it). ``recent`` is always its sum. """ total: int recent: int last_at: Optional[datetime] + histogram: tuple[int, ...] @dataclass(slots=True) @@ -510,14 +518,16 @@ class Repository: """Aggregate stored channel messages into per-channel statistics. Backs the channel manager's list lanes: each configured channel's row shows its - total message count, an activity meter over the trailing - :data:`ACTIVITY_WINDOW_DAYS`, and the age of its last message. One SQL pass groups - every channel message by the channel's intrinsic identity, so the cost stays flat - no matter how many channels the device carries. + total message count, the age of its last message, and an activity sparkline over + the trailing :data:`ACTIVITY_WINDOW_DAYS` — whose :data:`ACTIVITY_BUCKETS`-column + histogram is built here. Two passes, each grouped/filtered in SQL so the cost + tracks message volume, not channel count: an aggregate for the all-time totals, + then the window's individual timestamps, bucketed in Python (the window holds at + most a week of chatter, so the row set stays small). Timestamps are compared as strings: every ``created_at`` is written by ``utcnow().isoformat()`` (a fixed-width UTC ISO-8601 form), so lexicographic order - *is* chronological order and the recent-window cutoff needs no per-row parsing. + *is* chronological order and the window cutoff needs no per-row parsing. Messages predating identity-keyed history (a ``NULL`` ``channel_id``; see :meth:`backfill_channel_ids`) have no channel to be counted under and are skipped. @@ -525,24 +535,41 @@ class Repository: A mapping of channel identity to its :class:`ChannelStats`. Channels with no stored messages simply have no entry. """ - cutoff = (utcnow() - timedelta(days=ACTIVITY_WINDOW_DAYS)).isoformat() + cutoff = utcnow() - timedelta(days=ACTIVITY_WINDOW_DAYS) + bucket_span = timedelta(days=ACTIVITY_WINDOW_DAYS) / ACTIVITY_BUCKETS rows = self._conn.execute( - "SELECT channel_id, COUNT(*) AS total, MAX(created_at) AS last_at, " - "SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS recent " + "SELECT channel_id, COUNT(*) AS total, MAX(created_at) AS last_at " "FROM messages WHERE is_channel = 1 AND channel_id IS NOT NULL " - "GROUP BY channel_id", - (cutoff,), + "GROUP BY channel_id" ).fetchall() + + histograms: dict[str, list[int]] = {} + recent_rows = self._conn.execute( + "SELECT channel_id, created_at FROM messages " + "WHERE is_channel = 1 AND channel_id IS NOT NULL AND created_at >= ?", + (cutoff.isoformat(),), + ).fetchall() + for row in recent_rows: + try: + created_at = datetime.fromisoformat(row["created_at"]) + idx = min(ACTIVITY_BUCKETS - 1, int((created_at - cutoff) / bucket_span)) + except (TypeError, ValueError): + continue # a malformed/naive stray simply doesn't land in a bucket + histogram = histograms.setdefault(row["channel_id"], [0] * ACTIVITY_BUCKETS) + histogram[max(0, idx)] += 1 + stats: dict[str, ChannelStats] = {} for row in rows: try: last_at = datetime.fromisoformat(row["last_at"]) except (TypeError, ValueError): last_at = None # a malformed stray must not hide the channel's counts + histogram = tuple(histograms.get(row["channel_id"], [0] * ACTIVITY_BUCKETS)) stats[row["channel_id"]] = ChannelStats( total=int(row["total"]), - recent=int(row["recent"] or 0), + recent=sum(histogram), last_at=last_at, + histogram=histogram, ) return stats diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index 20cde6a..a137b34 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -10,8 +10,8 @@ see always reflects the radio. The list is laid out like the config editor: fixed, column-aligned lanes under one header line — name, openness, hash fingerprint, unread badge, total messages, last-message age, -and a small activity meter over the trailing week — so a glance shows not just *which* -channels exist but which ones are alive. The message statistics come from +and a braille sparkline of the trailing week's traffic — so a glance shows not just +*which* channels exist but which ones are alive. The message statistics come from :meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small TTL cache) and the unread counts from the live chat service, and each row is a callable title re-resolved on repaint, so a message arriving while the list sits open updates its @@ -51,6 +51,7 @@ from ..core.channels import ( ) from ..core.connection import Device from ..core.models import Conversation +from ..persistence.repository import ACTIVITY_BUCKETS from .qr import qr_text from .tui import CANCEL, Choice, SelectScreen, Separator from .widgets import _age_seconds, _format_age, channel_glyph @@ -297,7 +298,7 @@ class _LiveStats: The conversation picker's ``_LiveLasts`` pattern applied to :meth:`~meshterm.persistence.repository.Repository.channel_stats`: the list rows read through this on every repaint (their titles are callables), so a message arriving while - the manager sits open updates that channel's counts, age, and activity meter in place — + the manager sits open updates that channel's counts, age, and sparkline in place — but the repository is re-queried at most once per ``ttl`` seconds rather than once per row per repaint, so a full slot table stays cheap at the session's ~1 Hz repaint. """ @@ -333,27 +334,40 @@ _BADGE_WIDTH = 5 _COUNT_WIDTH = 5 #: Width of the right-aligned last-message-age lane (fits ``never``-length ages). _AGE_WIDTH = 5 -#: Weekly message counts at which the activity meter lights its first, second, and third -#: bar — roughly "someone spoke", "a message most days", and "steady daily traffic". -_ACTIVITY_THRESHOLDS = (1, 15, 70) -#: The meter's bars, lit left-to-right as the thresholds are passed. -_ACTIVITY_BARS = "▂▄▆" +#: Width of the activity sparkline in braille characters. Each braille cell packs two +#: dot columns, so eight characters draw all :data:`ACTIVITY_BUCKETS` window buckets. +_SPARK_WIDTH = ACTIVITY_BUCKETS // 2 +#: Braille dot masks for a bar filled bottom-up to height 0–4 in a cell's left column +#: (dots 7, 3, 2, 1 — the Unicode braille block numbers its rows top-down) and right +#: column (dots 8, 6, 5, 4). OR one of each, add to U+2800, and that's the character. +_BRAILLE_LEFT = (0x00, 0x40, 0x44, 0x46, 0x47) +_BRAILLE_RIGHT = (0x00, 0x80, 0xA0, 0xB0, 0xB8) -def _activity_meter(recent: int) -> Text: - """The channel's three-bar activity meter over the trailing week. +def _activity_sparkline(histogram: "tuple[int, ...]") -> Text: + """The channel's braille activity sparkline over the trailing week. - Each bar lights (in the ok green) as ``recent`` passes the matching - :data:`_ACTIVITY_THRESHOLDS` step; unlit positions render as muted dots so the meter - keeps its width and a silent channel still reads as a deliberate ``···``. + Each of the histogram's :data:`ACTIVITY_BUCKETS` buckets becomes one dot column, + packed two per braille character, its bar rising bottom-up through the cell's four + dot rows. Heights are scaled to the row's own busiest bucket — the sparkline shows a + channel's *rhythm*, not a cross-channel volume comparison (the MSGS lane does that) — + and any non-empty bucket keeps at least one dot so a lone message never vanishes. + Time runs left (window start) to right (now), in the ok green; a window with no + traffic at all renders as the same deliberate muted dots as before. """ - lit = sum(recent >= threshold for threshold in _ACTIVITY_THRESHOLDS) - meter = Text() - if lit: - meter.append(_ACTIVITY_BARS[:lit], style="ok") - if lit < len(_ACTIVITY_THRESHOLDS): - meter.append("·" * (len(_ACTIVITY_THRESHOLDS) - lit), style="muted") - return meter + histogram = (tuple(histogram) + (0,) * ACTIVITY_BUCKETS)[:ACTIVITY_BUCKETS] + peak = max(histogram) + if not peak: + return Text("·" * _SPARK_WIDTH, style="muted") + + def height(count: int) -> int: + return 0 if not count else max(1, -(-count * 4 // peak)) # ceil, floored at 1 + + cells = "".join( + chr(0x2800 | _BRAILLE_LEFT[height(left)] | _BRAILLE_RIGHT[height(right)]) + for left, right in zip(histogram[0::2], histogram[1::2]) + ) + return Text(cells, style="ok") def _fit(text: str, width: int) -> str: @@ -392,7 +406,7 @@ def _slot_row( ) -> Callable[[], Text]: """Return a list-row title *callable* the select screen re-renders on each repaint. - The unread badge, counts, age, and activity meter are all read live (see + The unread badge, counts, age, and activity sparkline are all read live (see :class:`_LiveStats`), so a message arriving while the list sits open updates the row on the next repaint — exactly the conversation picker's behavior. """ @@ -405,11 +419,11 @@ def _slot_text( """Build one channel's list row as fixed-width, colour-coded lanes. Alignment carries the readability — glyph, name, openness, hash, unread badge, total - messages, last-message age, and the activity meter each sit in their own lane under the - :func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's + messages, last-message age, and the activity sparkline each sit in their own lane under + the :func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is - red with its count in warn (the conversation picker's language), and the activity bars - light in the ok green. The row is always a Rich :class:`~rich.text.Text` so those spans + red with its count in warn (the conversation picker's language), and the sparkline + draws in the ok green. The row is always a Rich :class:`~rich.text.Text` so those spans survive under the select screen's row highlight. """ st = stats.get(slot.identity) @@ -438,7 +452,7 @@ def _slot_text( age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else "" text.append(f"{age:>{_AGE_WIDTH}}", style="muted") text.append(" ") - text.append_text(_activity_meter(st.recent if st is not None else 0)) + text.append_text(_activity_sparkline(st.histogram if st is not None else ())) return text diff --git a/tests/test_channels.py b/tests/test_channels.py index 6ac8e79..5d89830 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -362,16 +362,27 @@ def test_channel_stats_aggregates_totals_window_and_recency(ctx: AppContext) -> assert ops.recent == 1 # only the fresh message falls inside the window assert ops.last_at is not None assert abs((ops.last_at - fresh).total_seconds()) < 1 + # The histogram spans the window oldest-first: the 5-minute-old message lands in the + # newest (rightmost) bucket and the 30-day-old ones land nowhere. + assert len(ops.histogram) == 16 + assert ops.histogram[-1] == 1 and sum(ops.histogram) == 1 -def test_activity_meter_lights_bars_by_weekly_traffic() -> None: - """The meter lights 0–3 bars as the week's message count passes each threshold.""" - from meshterm.ui.channels import _activity_meter +def test_activity_sparkline_packs_16_buckets_into_braille() -> None: + """The sparkline draws two histogram buckets per braille cell, scaled to the peak.""" + from meshterm.ui.channels import _activity_sparkline - assert _activity_meter(0).plain == "···" - assert _activity_meter(1).plain == "▂··" - assert _activity_meter(15).plain == "▂▄·" - assert _activity_meter(500).plain == "▂▄▆" + assert _activity_sparkline((0,) * 16).plain == "········" # silent window + assert _activity_sparkline(()).plain == "········" # a channel with no stats at all + assert _activity_sparkline((4,) * 16).plain == "⣿" * 8 # flat-out: every dot lit + # A lone message in the newest bucket: full-height bar (it *is* the peak) in the final + # cell's right column; every other column stays empty. + lone = _activity_sparkline((0,) * 15 + (1,)).plain + assert lone == "⠀" * 7 + "⢸" + # Heights scale to the row's own peak, keeping any non-empty bucket at least one dot: + # counts 1/8 of the peak still show, half-peak reaches half height. + ramp = _activity_sparkline((8, 0) * 7 + (1, 4)).plain + assert ramp[-1] == chr(0x2800 | 0x40 | 0xA0) # left col 1 dot, right col 2 dots async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> None: @@ -397,8 +408,9 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non assert "Ops" in plain and "private" in plain and slot.hash in plain assert "● 2" in plain # the unread badge assert "now" in plain # the just-recorded message's age - assert "▂··" in plain # two messages this week light the first bar - assert plain.rstrip().endswith("▂··") # the meter is the final lane + # Both just-recorded messages sit in the sparkline's newest bucket: a full-height bar + # in the final braille cell's right column, with the rest of the window empty. + assert plain.rstrip().endswith("⠀" * 7 + "⢸") # the sparkline is the final lane async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> None: