Activity: stack dots that overlap on the day timeline #314

Closed
opened 2026-10-20 20:19:15 +02:00 by agentic-developer · 2 comments
Collaborator

Follow-up of #277 (open question 4, left out of scope there).

On the day timeline of an activity command (Android, ui/ActivityChart.kt / ui/Activity.kt), every shard is a dot on its series' lane at its time. Labels of dots less than 150 minutes apart already alternate above and below the line, but dots only minutes apart still draw on top of each other, and three or more close labels still collide.

Idea

Stack colliding dots vertically within their lane (or offset them), so each event stays visible and tappable, and lay out their time labels so none overlap.

To decide

  • What counts as a collision (dot width at the current scale, or a fixed number of minutes)?
  • Grow the lane height with the stack, or cap it and show "+N"?
  • Does the TUI need anything? (Its day view is the shard list only, so probably not.)

Done when

  • Close events on one lane don't overlap, and none of their labels collide.
  • A screenshot test covers a day with several events minutes apart.
  • R36 and the README describe the behaviour.
Follow-up of #277 (open question 4, left out of scope there). On the day timeline of an activity command (Android, `ui/ActivityChart.kt` / `ui/Activity.kt`), every shard is a dot on its series' lane at its time. Labels of dots less than 150 minutes apart already alternate above and below the line, but **dots only minutes apart still draw on top of each other**, and three or more close labels still collide. ## Idea Stack colliding dots vertically within their lane (or offset them), so each event stays visible and tappable, and lay out their time labels so none overlap. ## To decide - What counts as a collision (dot width at the current scale, or a fixed number of minutes)? - Grow the lane height with the stack, or cap it and show "+N"? - Does the TUI need anything? (Its day view is the shard list only, so probably not.) ## Done when - Close events on one lane don't overlap, and none of their labels collide. - A screenshot test covers a day with several events minutes apart. - R36 and the README describe the behaviour.
Author
Collaborator

Implementation plan

Decided in refinement (supersedes the "To decide" list above):

  • Events too close to tell apart are merged into a cluster: one larger dot with its count, labelled with the time span (06:40–06:52). The lane height stays fixed (64 dp), so no cap or "+N" is needed.
  • "Too close" is measured on screen, not in minutes: two events are merged when their 48 dp touch targets would overlap at the lane's current width. A wide screen merges less than a phone.
  • Dots become tappable. A single dot opens its note at its line (ActivityActions.onOpen(file, line), as a row of Latest shards does). A cluster opens a menu of its shards (06:40 · title per row); choosing one opens that note.
  • The TUI is untouched: its day view is the shard list only.

This is app-only (Android). The core and the bindings already deliver every event of the day (activityView loads them all for ActivityShape.DAY), each with time, file, line and title.

1. Pure layout: ui/ActivityChart.kt

Replace timelineLabels(times) (the fixed 150-minute rule) with a layout that works in dp:

data class TimelineCluster(val shards: List<ActivityShard>, val x: Float /* dp, centre */, val below: Boolean) {
    val first get() = shards.first().time
    val last get() = shards.last().time
    val single get() = shards.size == 1
}
fun timelineClusters(events: List<ActivityShard>, width: Float, target: Float = 48f, label: Float = 38f): List<TimelineCluster>
  • Sort by time. Map each event to x = width * secondOfDay / 86400.
  • Walk left to right and greedily merge: an event joins the current cluster when its x is less than target from the cluster's last event (chained, so a run of close events becomes one cluster). The cluster's x is the midpoint of its first and last event (or the mean; take the midpoint, so the span label is centred).
  • Labels: a single dot's label is HH:mm (~label dp wide); a cluster's label is HH:mm–HH:mm, about twice that. Alternate a cluster's label below when its label would overlap the previous cluster's label on the same side (by their widths at their x, clamped to the lane as now). Otherwise put it above. This replaces the 150-minute rule with one that holds at every width.
  • Keep it free of Compose so it is unit-tested in ActivityChartTest (replacing the test of timelineLabels):
    • events far apart stay single, all labels above
    • events minutes apart at phone width (~358 dp) merge into one cluster with the right span and count
    • the same events at tablet width (~800 dp) stay apart when their targets no longer overlap
    • a chain (06:40, 06:55, 07:10, with each pair close and the ends further apart) becomes one cluster
    • neighbouring clusters whose labels would collide alternate above and below, and a third that is clear goes back above
    • an event at 00:00 or 23:59 keeps its label inside the lane (clamping)

2. Drawing: ActivityTimeline in ui/Activity.kt

  • Take onOpen (pass actions.onOpen from the ActivityShape.DAY branch).
  • Compute the clusters in BoxWithConstraints with maxWidth.value.
  • Canvas: a single event is the 4 dp dot as now. A cluster is a larger dot (about 8 dp radius) in the series colour, with its count centred in it (StreamdTheme.mono, 10 sp, onPrimary-like contrast: pick white or black by the colour's luminance, or draw the count beside it if that is simpler and still readable in light and dark).
  • Labels: as now (StreamdTheme.mono, 10 sp, offset by below), with the span text for clusters.
  • Touch: one Box of 48 dp per cluster, centred at its x on the lane's centre line, clickable. A single dot calls onOpen(file, line). A cluster opens its menu. The semantics move from the label to this box: "<series> 06:45 <title>" for one, "<series> 3 shards 06:40–06:52" for a cluster (a new plural string resource).
  • Cluster menu: draw it the way the ⋮ options overlay is drawn (a Surface in the content, not a platform popup; see AGENTS.md: layoutlib renders no popup at frame 0), anchored below the tapped cluster and clamped to the screen. One row per shard, 06:40 · title (title ellipsized to one line). A row calls onOpen. Tapping outside or Back closes it. The open cluster is state of ActivityContent (series index + first time, rememberSaveable as strings), passed in, so a screenshot test can draw it open.

3. Tests

  • Unit: ActivityChartTest as listed in step 1.
  • Screenshots (ActivityScreenshots.kt): add a day scene with events minutes apart in one lane (e.g. 06:40, 06:45, 06:52 and 18:30 in Run, with Climbing elsewhere) so a cluster and a single dot are both drawn. Add a second scene with that cluster's menu open. Record with updateDebugScreenshotTest, look at every changed image (the existing day scene changes only if its events now merge), and commit them.
  • A Compose UI test is not needed: the click wiring is a direct call, and the screenshots cover the layout.

4. Docs

  • REQUIREMENTS.md R36 (Android activity screen): replace "labels less than 150 minutes apart alternate above/below" with the cluster rule: merged by touch-target distance at the current width, the count and span shown, labels alternate where they would collide, a dot opens its note, and a cluster opens a menu of its shards.
  • README.md (activity screen paragraph): the same in user words.
  • AGENTS.md (Android activity screen bullet): timelineClusters replaces the label alternation, and the cluster menu is a Surface overlay for layoutlib.

Done when

  • Close events on a lane are one cluster with a count and span, and no labels overlap at phone or tablet width.
  • A dot opens its note, and a cluster opens a menu of its shards.
  • Unit and screenshot tests as above; nix build .#streamd-app and nix flake check pass.
  • R36, the README and AGENTS.md describe it.
## Implementation plan Decided in refinement (supersedes the "To decide" list above): - Events too close to tell apart are **merged into a cluster**: one larger dot with its count, labelled with the time span (`06:40–06:52`). The lane height **stays fixed** (64 dp), so no cap or "+N" is needed. - "Too close" is measured **on screen**, not in minutes: two events are merged when their 48 dp touch targets would overlap at the lane's current width. A wide screen merges less than a phone. - Dots become **tappable**. A single dot opens its note at its line (`ActivityActions.onOpen(file, line)`, as a row of Latest shards does). A cluster opens a **menu of its shards** (`06:40 · title` per row); choosing one opens that note. - The TUI is untouched: its day view is the shard list only. This is app-only (Android). The core and the bindings already deliver every event of the day (`activityView` loads them all for `ActivityShape.DAY`), each with `time`, `file`, `line` and `title`. ### 1. Pure layout: `ui/ActivityChart.kt` Replace `timelineLabels(times)` (the fixed 150-minute rule) with a layout that works in dp: ```kotlin data class TimelineCluster(val shards: List<ActivityShard>, val x: Float /* dp, centre */, val below: Boolean) { val first get() = shards.first().time val last get() = shards.last().time val single get() = shards.size == 1 } fun timelineClusters(events: List<ActivityShard>, width: Float, target: Float = 48f, label: Float = 38f): List<TimelineCluster> ``` - Sort by time. Map each event to `x = width * secondOfDay / 86400`. - Walk left to right and greedily merge: an event joins the current cluster when its `x` is less than `target` from the cluster's last event (chained, so a run of close events becomes one cluster). The cluster's `x` is the midpoint of its first and last event (or the mean; take the midpoint, so the span label is centred). - Labels: a single dot's label is `HH:mm` (~`label` dp wide); a cluster's label is `HH:mm–HH:mm`, about twice that. Alternate a cluster's label below when its label would overlap the previous cluster's label **on the same side** (by their widths at their `x`, clamped to the lane as now). Otherwise put it above. This replaces the 150-minute rule with one that holds at every width. - Keep it free of Compose so it is unit-tested in `ActivityChartTest` (replacing the test of `timelineLabels`): - events far apart stay single, all labels above - events minutes apart at phone width (~358 dp) merge into one cluster with the right span and count - the same events at tablet width (~800 dp) stay apart when their targets no longer overlap - a chain (06:40, 06:55, 07:10, with each pair close and the ends further apart) becomes one cluster - neighbouring clusters whose labels would collide alternate above and below, and a third that is clear goes back above - an event at 00:00 or 23:59 keeps its label inside the lane (clamping) ### 2. Drawing: `ActivityTimeline` in `ui/Activity.kt` - Take `onOpen` (pass `actions.onOpen` from the `ActivityShape.DAY` branch). - Compute the clusters in `BoxWithConstraints` with `maxWidth.value`. - Canvas: a single event is the 4 dp dot as now. A cluster is a larger dot (about 8 dp radius) in the series colour, with its count centred in it (`StreamdTheme.mono`, 10 sp, `onPrimary`-like contrast: pick white or black by the colour's luminance, or draw the count beside it if that is simpler and still readable in light and dark). - Labels: as now (`StreamdTheme.mono`, 10 sp, `offset` by `below`), with the span text for clusters. - Touch: one `Box` of 48 dp per cluster, centred at its `x` on the lane's centre line, `clickable`. A single dot calls `onOpen(file, line)`. A cluster opens its menu. The semantics move from the label to this box: `"<series> 06:45 <title>"` for one, `"<series> 3 shards 06:40–06:52"` for a cluster (a new plural string resource). - **Cluster menu:** draw it the way the ⋮ options overlay is drawn (a `Surface` in the content, not a platform popup; see AGENTS.md: layoutlib renders no popup at frame 0), anchored below the tapped cluster and clamped to the screen. One row per shard, `06:40 · title` (title ellipsized to one line). A row calls `onOpen`. Tapping outside or Back closes it. The open cluster is state of `ActivityContent` (series index + first time, `rememberSaveable` as strings), passed in, so a screenshot test can draw it open. ### 3. Tests - Unit: `ActivityChartTest` as listed in step 1. - Screenshots (`ActivityScreenshots.kt`): add a day scene with events minutes apart in one lane (e.g. 06:40, 06:45, 06:52 and 18:30 in Run, with Climbing elsewhere) so a cluster and a single dot are both drawn. Add a second scene with that cluster's menu open. Record with `updateDebugScreenshotTest`, look at every changed image (the existing day scene changes only if its events now merge), and commit them. - A Compose UI test is not needed: the click wiring is a direct call, and the screenshots cover the layout. ### 4. Docs - `REQUIREMENTS.md` R36 (Android activity screen): replace "labels less than 150 minutes apart alternate above/below" with the cluster rule: merged by touch-target distance at the current width, the count and span shown, labels alternate where they would collide, a dot opens its note, and a cluster opens a menu of its shards. - `README.md` (activity screen paragraph): the same in user words. - `AGENTS.md` (Android activity screen bullet): `timelineClusters` replaces the label alternation, and the cluster menu is a `Surface` overlay for layoutlib. ### Done when - Close events on a lane are one cluster with a count and span, and no labels overlap at phone or tablet width. - A dot opens its note, and a cluster opens a menu of its shards. - Unit and screenshot tests as above; `nix build .#streamd-app` and `nix flake check` pass. - R36, the README and AGENTS.md describe it.
Author
Collaborator

Implementation note

Implemented in PR #321 (branch 314_stack-close-activity-dots).

  • Time: about 20 minutes (17:48–18:08), not counting one interrupted session in which the final checks were run again.
  • Tokens: roughly 140k across the run.

Findings

  • The 48 dp rule merges a lot on a phone. At about 358 dp, 48 dp is about 3 h 13 min of the day. Events within roughly three hours of each other in one lane become one cluster (e.g. 06:45 and 08:15 in the existing three-series scenes). On a tablet (800 dp) the threshold is about 86 minutes. This follows the plan, but if it feels too coarse, a smaller target or a horizontally scrollable lane would be the knobs to turn.
  • The cluster menu could not be a scrim + overlay at screen level. Anchoring it there needs the cluster's position from layout, which layoutlib's single frame doesn't give a screenshot. The menu is drawn inside its lane instead, raised with zIndex and unbounded in height (the 64 dp lane first cut it to one row). Taps outside it are caught by a pointer handler on the screen in the initial pass and consumed, since no scrim can lie between the menu and the scrolling content. The tap-outside path is only covered by reading the code. It was not tried on a device or emulator.
  • Label collisions. With the 48 dp merging, cluster centres are at least 48 dp apart, so alternating above and below always clears the labels (singles are 38 dp wide, spans 76 dp). If both sides would collide, the label goes above.
  • Screenshots. The plain day scene changed slightly because labels are now centred on their dot (fixed width, centred text) instead of offset by 18 dp. The three-series scenes show the new cluster.
  • The TUI was not touched.
## Implementation note Implemented in PR #321 (branch `314_stack-close-activity-dots`). - **Time:** about 20 minutes (17:48–18:08), not counting one interrupted session in which the final checks were run again. - **Tokens:** roughly 140k across the run. ### Findings - **The 48 dp rule merges a lot on a phone.** At about 358 dp, 48 dp is about 3 h 13 min of the day. Events within roughly three hours of each other in one lane become one cluster (e.g. 06:45 and 08:15 in the existing three-series scenes). On a tablet (800 dp) the threshold is about 86 minutes. This follows the plan, but if it feels too coarse, a smaller target or a horizontally scrollable lane would be the knobs to turn. - **The cluster menu could not be a scrim + overlay at screen level.** Anchoring it there needs the cluster's position from layout, which layoutlib's single frame doesn't give a screenshot. The menu is drawn inside its lane instead, raised with `zIndex` and unbounded in height (the 64 dp lane first cut it to one row). Taps outside it are caught by a pointer handler on the screen in the initial pass and consumed, since no scrim can lie between the menu and the scrolling content. The tap-outside path is only covered by reading the code. It was not tried on a device or emulator. - **Label collisions.** With the 48 dp merging, cluster centres are at least 48 dp apart, so alternating above and below always clears the labels (singles are 38 dp wide, spans 76 dp). If both sides would collide, the label goes above. - **Screenshots.** The plain day scene changed slightly because labels are now centred on their dot (fixed width, centred text) instead of offset by 18 dp. The three-series scenes show the new cluster. - The TUI was not touched.
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#314
No description provided.