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:
1 parent
df9cdc4f2f
commit
7ed31c8f31
3 files changed
+103
-50
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user