Activity: computation and range syntax in the core #279

Closed
opened 2026-10-17 08:23:12 +02:00 by agentic-developer · 1 comment
Collaborator

Part of #277. Needs #278.

Goal

The core computes what every host shows of an activity command for a range of days: each day's cell, the statistics in both modes and the matching shards. Plus the range syntax, shared by the CLI, the TUI and the app.

Plan

Range (commands/activity/range.rs, pure)

  1. RangeSpec::parse(text):
    all                      everything tracked
    2026                     a year
    2026-10                  a month
    2026-10-16               a day
    2026-10-03..2026-10-17   an inclusive day range (either order is an error)
    last 30 days             a rolling window ending today (also `last 1 day`)
    
    Errors name the accepted forms. Whole days only.
  2. RangeSpec::resolve(today, tracked_start) -> Range { from, to, unit }: clamped to [start, today], where start is the command's start, else the day of the first matching shard. unit is All | Day | Month | Year | Days(n).
  3. label(): short text: All time, 2026, Oct 2026, 16 Oct 2026, 3–17 Oct (28 Sep–4 Oct, across years 28 Dec 2025–3 Jan 2026). step(±1) moves by the unit (a custom range by its length); can_step is false for All and when the step would lie wholly after today or before the start.
  4. quick_picks(today): Today, Yesterday, Last 7 days, Last 30 days, This month, Last month, This year, Last year, All time, each as a RangeSpec.
  5. Shape::of(range): Day (1 day), Calendar (2–42 days), Grid (more, and All). Every host picks its chart by it.

Computation (commands/activity/compute.rs)

  1. A shard belongs to series i if it is an outermost match of the series' query (query::find_outermost_shard, as an if reminder names its shards, R29), so a placement handed down to sub-headings counts once. Its moment is the shard's moment (LocalizedShard; the file name's unless a marker says otherwise), and its day is that moment's local date in the repository timezone (tz). A shard matching two series counts for both and is reported in shared (a warning, not an error).
  2. Per tracked day: counts[series], the classification None | Single(i) | Mixed(Vec<i>), and how it is drawn:
    • intensity = "count": level 1, 2, 3, 4 for 1, 2, 3, 4+ shards; fixed: always 4.
    • mixed with split: all matching series in list order (a host draws two as diagonal halves, three or more as wedges); first: the first in list order; most: the most shards, a tie to the first in list order. The level of a mixed day comes from the sum of its shards.
    • Days after today are Future, days before the start Untracked. Both are left out of every statistic.
  3. Statistics of the range: shards, shards_per_series, tracked_days, active_days, per_week = shards / (tracked_days / 7); and the Days table in both modes, so a toggle needs no recomputation:
    • inclusive: series_days[i] (mixed included), mixed_days, none_days;
    • exclusive: series_only_days[i], mixed_days, none_days.
      Percentages are of tracked_days (inclusive ones may add up to more than 100 %). A range with 0 tracked days gives zeros, never a division by zero.
  4. The shards: newest first, by day and then by time descending, each as ActivityShard { series: Vec<usize>, moment, day, file, line, summary } (summary is shard_summary), paged: offset, limit, total. day_series(day) per series: count and first/last time, for the day tiles (Run 1 · at 06:45).

Facade

  1. api::activity(project, stream, key, range: &RangeSpec, now, tz, page) -> ActivityReport { range, label, shape, series (name, colour), days: Vec<DayCell>, stats, shards: Page<ActivityShard>, shared }. api::activity_range(text) parses for a host. Errors as CoreError (UnknownCommand, NotAnActivityCommand, a range parse error).

Tests

The sample of the screenshots (Run and Climbing, Oct 2026, today = Sat 17 Oct 2026): 15 shards, 11 of 17 days active, per week 6.2, Run 7 incl. 2 mixed, Climbing 6, Mixed 2, None 6; exclusive mode; start cutting days; a shard on two series; a sub-heading under a matching heading counted once; most tie; fixed; a day at midnight across a DST change; every range form, label, step and clamp; the paging.

Docs

R36 (computation, range syntax), AGENTS.md (the module and the facade functions).

Part of #277. Needs #278. ## Goal The core computes what every host shows of an activity command for a range of days: each day's cell, the statistics in both modes and the matching shards. Plus the range syntax, shared by the CLI, the TUI and the app. ## Plan ### Range (`commands/activity/range.rs`, pure) 1. `RangeSpec::parse(text)`: ``` all everything tracked 2026 a year 2026-10 a month 2026-10-16 a day 2026-10-03..2026-10-17 an inclusive day range (either order is an error) last 30 days a rolling window ending today (also `last 1 day`) ``` Errors name the accepted forms. Whole days only. 2. `RangeSpec::resolve(today, tracked_start) -> Range { from, to, unit }`: clamped to `[start, today]`, where `start` is the command's `start`, else the day of the first matching shard. `unit` is `All | Day | Month | Year | Days(n)`. 3. `label()`: short text: `All time`, `2026`, `Oct 2026`, `16 Oct 2026`, `3–17 Oct` (`28 Sep–4 Oct`, across years `28 Dec 2025–3 Jan 2026`). `step(±1)` moves by the unit (a custom range by its length); `can_step` is false for All and when the step would lie wholly after today or before the start. 4. `quick_picks(today)`: Today, Yesterday, Last 7 days, Last 30 days, This month, Last month, This year, Last year, All time, each as a `RangeSpec`. 5. `Shape::of(range)`: `Day` (1 day), `Calendar` (2–42 days), `Grid` (more, and All). Every host picks its chart by it. ### Computation (`commands/activity/compute.rs`) 6. A shard belongs to series *i* if it is an **outermost** match of the series' `query` (`query::find_outermost_shard`, as an `if` reminder names its shards, R29), so a placement handed down to sub-headings counts once. Its moment is the shard's moment (`LocalizedShard`; the file name's unless a marker says otherwise), and its day is that moment's local date in the repository timezone (`tz`). A shard matching two series counts for both and is reported in `shared` (a warning, not an error). 7. Per tracked day: `counts[series]`, the classification `None | Single(i) | Mixed(Vec<i>)`, and how it is **drawn**: - `intensity = "count"`: level 1, 2, 3, 4 for 1, 2, 3, 4+ shards; `fixed`: always 4. - mixed with `split`: all matching series in list order (a host draws two as diagonal halves, three or more as wedges); `first`: the first in list order; `most`: the most shards, a tie to the first in list order. The level of a mixed day comes from the sum of its shards. - Days after today are `Future`, days before the start `Untracked`. Both are left out of every statistic. 8. Statistics of the range: `shards`, `shards_per_series`, `tracked_days`, `active_days`, `per_week = shards / (tracked_days / 7)`; and the Days table in **both** modes, so a toggle needs no recomputation: - inclusive: `series_days[i]` (mixed included), `mixed_days`, `none_days`; - exclusive: `series_only_days[i]`, `mixed_days`, `none_days`. Percentages are of `tracked_days` (inclusive ones may add up to more than 100 %). A range with 0 tracked days gives zeros, never a division by zero. 9. The shards: newest first, by day and then by time descending, each as `ActivityShard { series: Vec<usize>, moment, day, file, line, summary }` (`summary` is `shard_summary`), paged: `offset`, `limit`, `total`. `day_series(day)` per series: count and first/last time, for the day tiles (`Run 1 · at 06:45`). ### Facade 10. `api::activity(project, stream, key, range: &RangeSpec, now, tz, page) -> ActivityReport { range, label, shape, series (name, colour), days: Vec<DayCell>, stats, shards: Page<ActivityShard>, shared }`. `api::activity_range(text)` parses for a host. Errors as `CoreError` (`UnknownCommand`, `NotAnActivityCommand`, a range parse error). ## Tests The sample of the screenshots (Run and Climbing, Oct 2026, today = Sat 17 Oct 2026): 15 shards, 11 of 17 days active, per week 6.2, Run 7 incl. 2 mixed, Climbing 6, Mixed 2, None 6; exclusive mode; `start` cutting days; a shard on two series; a sub-heading under a matching heading counted once; `most` tie; `fixed`; a day at midnight across a DST change; every range form, label, step and clamp; the paging. ## Docs R36 (computation, range syntax), AGENTS.md (the module and the facade functions).
Author
Collaborator

Implemented in #305.

Time: about 21 minutes (12:43–13:05 CEST), wall clock, including nix flake check and nix build .#streamd-app.
Tokens: about 145k of context used (Claude Opus 5.5).

Findings during the implementation

  • shard_summary needs the text of the shard's file, so api::activity can't work from Stream alone. It takes a StreamStore and reads only the files of the requested page.
  • "Clamped to [start, today]" conflicted with "days after today are Future, before the start Untracked". Resolved this way: a year, a month or a range of days keeps all of its days (so a calendar shows the whole month) and the statistics count only tracked days. Only all is cut, to run from the first tracked day to today, or today alone if nothing is tracked.
  • The first tracked day without start is taken over the whole stream, not just the range. That way can_step and the Untracked days agree across ranges.
  • The level of a mixed day counts distinct shards, so a shard in two series raises the level once. Per-series counts and shards_per_series still count it for each.
  • There were no UnknownCommand / NotAnActivityCommand variants to reuse. The facade's existing ConfigError wording is kept, and only CoreError::InvalidRange is new. Because the bindings keep one variant per CoreError variant, it crosses as StreamdException.InvalidRange, and the app's exhaustive reason() gained one arm. The report's records are left for #283.
  • A time marker has to be written @HHMMSS: @1900 isn't read as a time, which tripped a fixture.
Implemented in #305. **Time:** about 21 minutes (12:43–13:05 CEST), wall clock, including `nix flake check` and `nix build .#streamd-app`. **Tokens:** about 145k of context used (Claude Opus 5.5). **Findings during the implementation** - `shard_summary` needs the text of the shard's file, so `api::activity` can't work from `Stream` alone. It takes a `StreamStore` and reads only the files of the requested page. - "Clamped to [start, today]" conflicted with "days after today are Future, before the start Untracked". Resolved this way: a year, a month or a range of days keeps all of its days (so a calendar shows the whole month) and the statistics count only tracked days. Only `all` is cut, to run from the first tracked day to today, or today alone if nothing is tracked. - The first tracked day without `start` is taken over the whole stream, not just the range. That way `can_step` and the `Untracked` days agree across ranges. - The level of a mixed day counts distinct shards, so a shard in two series raises the level once. Per-series counts and `shards_per_series` still count it for each. - There were no `UnknownCommand` / `NotAnActivityCommand` variants to reuse. The facade's existing `ConfigError` wording is kept, and only `CoreError::InvalidRange` is new. Because the bindings keep one variant per `CoreError` variant, it crosses as `StreamdException.InvalidRange`, and the app's exhaustive `reason()` gained one arm. The report's records are left for #283. - A time marker has to be written `@HHMMSS`: `@1900` isn't read as a time, which tripped a fixture.
Sign in to join this conversation.
No labels
android
cli
epic
planned
tui
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
kfickel/streamd#279
No description provided.