Sparkline becomes a 2-hour live pulse, newest at the left

The activity window shrinks from a week to two hours in 24 five-minute
buckets (12 braille cells), bucketed newest-first so "now" sits at the
left edge and traffic slides right as it ages. Bar heights switch from
peak-relative scaling to absolute tiers (1/3/8/21 messages per dot), so
the same traffic draws the same bar on every row, and silent cells drop
to a faint two-bottom-dot baseline — a quiet channel reads as a flatline
rather than a gap. The Reorder row's narrow ↕ arrow gains a second space
so its label aligns with the two-cell glyphs of its sibling rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-10 04:01:59 -04:00
1 parent 96813130d5
commit ba1ff2eedf
3 files changed
+80 -61

No files matched your search

+19 -15
View File
@@ -25,15 +25,15 @@ from ..core.models import (
from . import db
#: 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 sparkline shows.
ACTIVITY_WINDOW_DAYS = 7
#: The trailing window a channel's recent-activity view is computed over. Two hours is
#: "what's happening right now" territory: the activity sparkline reads as a live pulse of
#: the mesh rather than a long-term traffic log (the MSGS lane covers all-time volume).
ACTIVITY_WINDOW = timedelta(hours=2)
#: 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
#: channel manager's braille sparkline (two columns per braille cell, so 24 buckets fill
#: its twelve characters). At a two-hour window each bucket spans five minutes.
ACTIVITY_BUCKETS = 24
@dataclass(slots=True)
@@ -42,12 +42,13 @@ class ChannelStats:
Attributes:
total: Messages ever stored for the channel, sent and received alike.
recent: Messages within the trailing :data:`ACTIVITY_WINDOW_DAYS` window.
recent: Messages within the trailing :data:`ACTIVITY_WINDOW` 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.
buckets, *newest first* — bucket 0 is the current five minutes, so the
sparkline drawn from it reads now→past left-to-right and traffic slides right
as it ages. ``recent`` is always its sum.
"""
total: int
@@ -519,11 +520,11 @@ class Repository:
Backs the channel manager's list lanes: each configured channel's row shows its
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
the trailing :data:`ACTIVITY_WINDOW` — 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).
most two hours 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
@@ -535,8 +536,9 @@ 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)
bucket_span = timedelta(days=ACTIVITY_WINDOW_DAYS) / ACTIVITY_BUCKETS
now = utcnow()
cutoff = now - ACTIVITY_WINDOW
bucket_span = ACTIVITY_WINDOW / ACTIVITY_BUCKETS
rows = self._conn.execute(
"SELECT channel_id, COUNT(*) AS total, MAX(created_at) AS last_at "
"FROM messages WHERE is_channel = 1 AND channel_id IS NOT NULL "
@@ -552,7 +554,9 @@ class Repository:
for row in recent_rows:
try:
created_at = datetime.fromisoformat(row["created_at"])
idx = min(ACTIVITY_BUCKETS - 1, int((created_at - cutoff) / bucket_span))
# Bucket by *age* so the histogram comes out newest-first (bucket 0 holds
# the current five minutes) — the order the sparkline draws it in.
idx = min(ACTIVITY_BUCKETS - 1, int((now - created_at) / 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)
+33 -23
View File
@@ -10,7 +10,7 @@ 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 braille sparkline of the trailing week's traffic — so a glance shows not just
and a braille sparkline of the trailing two hours' 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
@@ -27,6 +27,7 @@ from __future__ import annotations
import asyncio
import time
from bisect import bisect_right
from dataclasses import dataclass
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
@@ -337,40 +338,45 @@ _BADGE_WIDTH = 5
_COUNT_WIDTH = 5
#: Width of the right-aligned last-message-age lane (fits ``never``-length ages).
_AGE_WIDTH = 5
#: 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)
#: Message counts a bucket must reach for each extra dot of bar height (Fibonacci-ish, so
#: each dot roughly means "a conversation tier up"): 1 message lights one dot, 3 light two,
#: 8 light three, and 21 or more max the column out.
_ACTIVITY_LEVELS = (1, 3, 8, 21)
#: A braille cell with just its two bottom dots (7 and 8) lit — the resting baseline a
#: silent pair of buckets draws, so a quiet channel still shows a flatline, not a gap.
_SPARK_BASELINE = chr(0x2800 | 0x40 | 0x80)
def _activity_sparkline(histogram: "tuple[int, ...]") -> Text:
"""The channel's braille activity sparkline over the trailing week.
"""The channel's braille activity sparkline over the trailing two hours.
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.
Each of the histogram's :data:`ACTIVITY_BUCKETS` five-minute buckets becomes one dot
column, packed two per braille character, its bar rising bottom-up through the cell's
four dot rows. Heights are absolute, stepped at :data:`_ACTIVITY_LEVELS`, so the same
traffic draws the same bar on every row and a lone message never vanishes. Time runs
*newest first*: "now" is the leftmost column (matching the histogram's order) and a
burst of traffic slides right as it ages. Cells with traffic draw in the ok green; a
silent cell drops to a faint two-bottom-dot baseline, so a quiet stretch reads as a
flatline under the green spikes rather than a hole in the row.
"""
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
return bisect_right(_ACTIVITY_LEVELS, count)
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")
text = Text()
for left, right in zip(histogram[0::2], histogram[1::2]):
lh, rh = height(left), height(right)
if lh or rh:
text.append(chr(0x2800 | _BRAILLE_LEFT[lh] | _BRAILLE_RIGHT[rh]), style="ok")
else:
text.append(_SPARK_BASELINE, style="faint")
return text
def _fit(text: str, width: int) -> str:
@@ -426,7 +432,8 @@ def _slot_text(
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 sparkline
draws in the ok green. The row is always a Rich :class:`~rich.text.Text` so those spans
draws in the ok green over a faint flatline. 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)
@@ -480,7 +487,10 @@ def _menu_items(
if len(slots) > 1:
items.append(Separator("── Organize ──", style="accent"))
items.append(Choice(title="↕ Reorder channels", value=_REORDER))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs.
items.append(Choice(title="↕ Reorder channels", value=_REORDER))
items.append(Separator("── Add a channel ──", style="accent"))
items.append(Choice(title="+ New private channel (random key)", value=_CREATE))
+28 -23
View File
@@ -337,13 +337,13 @@ async def test_reorder_keeps_history_because_key_is_intrinsic(ctx: AppContext) -
def test_channel_stats_aggregates_totals_window_and_recency(ctx: AppContext) -> None:
"""Per-channel stats count all messages, the trailing-week slice, and the last time."""
"""Per-channel stats count all messages, the trailing-2h slice, and the last time."""
from datetime import timedelta
from meshterm.core.models import ChatMessage, utcnow
stale = utcnow() - timedelta(days=30) # outside the 7-day activity window
fresh = utcnow() - timedelta(minutes=5)
stale = utcnow() - timedelta(days=30) # outside the two-hour activity window
fresh = utcnow() - timedelta(minutes=2)
for text, when in (("old a", stale), ("old b", stale), ("new", fresh)):
ctx.repo.record_chat_message(
ChatMessage(text=text, is_channel=True, channel_id="ops", created_at=when)
@@ -362,27 +362,31 @@ 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
# The histogram spans the window newest-first: the 2-minute-old message lands in the
# "now" (first) bucket and the 30-day-old ones land nowhere.
assert len(ops.histogram) == 24
assert ops.histogram[0] == 1 and sum(ops.histogram) == 1
def test_activity_sparkline_packs_16_buckets_into_braille() -> None:
"""The sparkline draws two histogram buckets per braille cell, scaled to the peak."""
def test_activity_sparkline_packs_24_buckets_into_braille() -> None:
"""The sparkline draws two buckets per cell at absolute, thresholded heights."""
from meshterm.ui.channels import _activity_sparkline
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
assert _activity_sparkline((0,) * 24).plain == "⣀" * 12 # silent window: a flatline
assert _activity_sparkline(()).plain == "⣀" * 12 # a channel with no stats at all
assert _activity_sparkline((21,) * 24).plain == "⣿" * 12 # flat-out: every dot lit
# A lone message in the newest bucket: one dot in the *first* cell's left column (time
# runs newest→oldest), the rest of the window on the faint baseline.
lone = _activity_sparkline((1,) + (0,) * 23)
assert lone.plain == "⡀" + "⣀" * 11
styles = [span.style for span in lone.spans]
assert styles[0] == "ok" and set(styles[1:]) == {"faint"}
# Heights step at the absolute thresholds 1 / 3 / 8 / 21 messages per bucket.
steps = _activity_sparkline((1, 2, 3, 7, 8, 20, 21, 999) + (0,) * 16).plain
assert steps[:4] == "".join(
chr(0x2800 | left | right)
for left, right in ((0x40, 0x80), (0x44, 0xA0), (0x46, 0xB0), (0x47, 0xB8))
)
async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> None:
@@ -408,9 +412,10 @@ 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
# 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
# Both just-recorded messages sit in the sparkline's newest bucket — the *first* cell's
# left column (one dot: two messages is still tier one) — with the rest of the window
# on the faint flatline baseline.
assert plain.rstrip().endswith("⡀" + "⣀" * 11) # the sparkline is the final lane
async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> None: