Activity lane becomes a 16-bucket braille sparkline

The three-threshold bar meter compressed a week of traffic into one of
four states; braille cells pack two dot columns of four rows each, so
the same 8-cell lane now draws a real 16-point histogram — each bucket
~10.5 hours — and a channel's rhythm (bursts, lulls, decay) is legible
at a glance. channel_stats() gains the per-bucket histogram, bucketing
the window's timestamps in Python off one filtered query; recent is
now simply the histogram's sum.

Bars scale to the row's own busiest bucket — the sparkline shows shape,
not cross-channel volume (the MSGS lane does that) — with non-empty
buckets floored at one dot so a lone message never vanishes. A silent
window keeps the muted ······· placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-10 02:53:28 -04:00
1 parent df9cdc4f2f
commit 7ed31c8f31
3 files changed
+103 -50

No files matched your search

+42 -15
View File
@@ -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
+40 -26
View File
@@ -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
+21 -9
View File
@@ -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: