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:
1 parent
96813130d5
commit
ba1ff2eedf
3 files changed
+80
-61
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user