diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index b6bcf34..17e0835 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -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) diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index fdb6684..f47fe8f 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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)) diff --git a/tests/test_channels.py b/tests/test_channels.py index 5d89830..ced5bef 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -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: