Skip to content

feat(timeline): add 'Merge by app' filter to reduce event flooding#782

Merged
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/timeline-merge-similar
Mar 23, 2026
Merged

feat(timeline): add 'Merge by app' filter to reduce event flooding#782
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/timeline-merge-similar

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Summary

  • Adds a "Merge by app" toggle in the Timeline Filters panel
  • When enabled, merges adjacent currentwindow events from the same application within a 30-second gap into single blocks
  • Reduces visual clutter from apps that produce many small events (e.g. Adobe Illustrator's TAB key toggling UI panels)

Fixes: ActivityWatch/activitywatch#1165

How it works

Client-side merge in _applyMergeSimilar():

  1. For each currentwindow bucket, sort events by timestamp
  2. Walk through events, merging consecutive ones where data.app matches and the gap is < 30 seconds
  3. Applied before AFK filtering so both filters compose correctly

The approach mirrors how _applyAfkFilter already processes window bucket events — same pattern of replacing bucket events with processed versions.

Test plan

  • Lint passes (npx vue-cli-service lint)
  • All 34 existing tests pass
  • Manual test: open Timeline with an app that produces many events, toggle "Merge by app" on/off
  • Verify merged events span the correct time range
  • Verify the filter summary shows "merged by app" when enabled
  • Verify non-window buckets (AFK, editor) are unaffected

Apps like Adobe Illustrator generate hundreds of tiny events when
toggling UI panels (e.g. pressing TAB), flooding the Timeline view.
This adds a "Merge by app" toggle in the Filters panel that merges
adjacent events from the same application within a 30-second gap
window into single blocks.

Only affects `currentwindow` bucket events. Applied before AFK
filtering so the merge and AFK filters compose correctly.

Fixes: ActivityWatch/activitywatch#1165
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 25.92%. Comparing base (137ee09) to head (0dff89d).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #782      +/-   ##
==========================================
+ Coverage   25.71%   25.92%   +0.21%     
==========================================
  Files          30       30              
  Lines        1754     1759       +5     
  Branches      307      321      +14     
==========================================
+ Hits          451      456       +5     
+ Misses       1281     1234      -47     
- Partials       22       69      +47     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR adds a "Merge by app" client-side filter to the Timeline view. When enabled, _applyMergeSimilar walks sorted currentwindow bucket events and collapses adjacent events from the same application when the gap between them is under 30 seconds, returning new (non-mutating) bucket objects. The feature targets apps like Adobe Illustrator that flood the timeline with rapid short events.

Key observations:

  • The merge algorithm is logically sound: it correctly handles overlapping events via Math.max(currentEnd, nextEnd), skips buckets that aren't currentwindow, and guards against events with missing data.app.
  • The implementation correctly runs after _applyAfkFilter, so merges operate only on already-active (non-AFK) events. Note: the PR description states it runs before AFK filtering — this is incorrect and contradicts both the code comment and the implementation.
  • The shallow copy pattern ({ ...sorted[0] }) means the data object inside each merged event shares a reference with the original event in all_buckets. This is safe today since nothing downstream mutates data, but it's worth noting as a future fragility.
  • The 30-second gap threshold is hardcoded as the literal 30000 with no named constant, which reduces readability.
  • The watcher correctly follows the existing filter pattern and calls getBuckets() on toggle changes.

Confidence Score: 4/5

  • Safe to merge with two non-blocking style suggestions remaining.
  • The core merge algorithm is correct and handles edge cases (overlaps, unknown app names, non-window buckets). The implementation is consistent with the existing filter patterns, doesn't mutate frozen data, and the shallow-copy concern is not a practical risk today. The two open comments are P2 style suggestions (named constant, defensive deep copy) that don't affect correctness. The only notable issue is a stale statement in the PR description about filter ordering, which doesn't affect the code.
  • No files require special attention; all changes are contained in src/views/Timeline.vue.

Important Files Changed

Filename Overview
src/views/Timeline.vue Adds filter_merge_similar toggle, watcher, filter-summary entry, and the _applyMergeSimilar method. The merge algorithm is logically correct (handles overlaps, same-app adjacency, and skips non-window buckets), but uses a hardcoded 30-second gap threshold and shallow-copies event objects whose data field is shared with the originals.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[getBuckets called] --> B[Fetch fresh data from store]
    B --> C{filter_hostname?}
    C -- Yes --> D[Filter buckets by hostname]
    C -- No --> E
    D --> E{filter_client?}
    E -- Yes --> F[Filter buckets by client]
    E -- No --> G
    F --> G{filter_duration?}
    G -- Yes --> H[Remove short events in-place]
    G -- No --> I
    H --> I{filter_categories?}
    I -- Yes --> J[Remove non-matching events in-place]
    I -- No --> K
    J --> K{filter_afk?}
    K -- Yes --> L[_applyAfkFilter: server query, returns new bucket objects]
    K -- No --> M
    L --> M{filter_merge_similar?}
    M -- Yes --> N["_applyMergeSimilar: collapse adjacent same-app events, gap < 30s"]
    M -- No --> O
    N --> O[this.buckets = final result]
    O --> P[vis-timeline renders]
Loading

Comments Outside Diff (2)

  1. src/views/Timeline.vue, line 307 (link)

    Hardcoded magic number for gap threshold

    The 30-second gap threshold is embedded directly as 30000 (milliseconds) with no named constant. This makes it harder to understand at a glance and to update consistently if the value ever needs to change.

    Consider extracting it to a named constant near the top of the method or as a component-level constant:

    With a constant defined above the method:

    const MERGE_GAP_MS = 30 * 1000; // 30 seconds — collapses rapid same-app events

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. src/views/Timeline.vue, line 298 (link)

    Shallow copy of data object shared with original events

    { ...sorted[0] } is a shallow copy, so current.data is the same object reference as the original event's data. The same applies to every { ...next } assignment inside the loop. This means the objects pushed into merged share their data property with the events stored inside this.all_buckets.

    In practice this is safe here because nothing downstream mutates event data, and all_buckets is re-fetched on each getBuckets() call. However, if a future change modifies event.data somewhere (e.g. in vis-timeline), it would silently corrupt the cached bucket data. A deep copy of data would make this defensive:

    And similarly inside the loop:

    current = { ...next, data: { ...next.data } };

Reviews (2): Last reviewed commit: "fix(timeline): apply AFK filter before m..." | Re-trigger Greptile

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

src/views/Timeline.vue, line 278
Merge filter is discarded when AFK filter is also active

_applyMergeSimilar runs before _applyAfkFilter (lines 271–278). However, in the success path of _applyAfkFilter, every currentwindow bucket's events are completely replaced with a fresh set of events fetched from the backend (line 340):

const filteredEvents = await this._queryAfkFilteredEvents(bucket.id, afkBucketIds[0]);
result.push({ ...bucket, events: filteredEvents });
continue;

This continue discards whatever merged events were produced by _applyMergeSimilar moments earlier. The result is that toggling both "Filter AFK" and "Merge by app" makes the merge have no visible effect on currentwindow buckets — exactly the bucket type the feature targets.

The PR description says "Applied before AFK filtering so both filters compose correctly" but the composition doesn't work in the intended direction.

Fix: Reverse the filter order so the merge runs after AFK filtering, operating on already-filtered events:

      // AFK filtering: use query engine to filter window events by AFK status
      if (this.filter_afk) {
        buckets = await this._applyAfkFilter(buckets);
      }

      // Merge adjacent events by app name for window buckets.
      // Reduces visual clutter when apps produce many small events (e.g. title
      // changes from toggling UI panels). See: activitywatch#1165
      if (this.filter_merge_similar) {
        buckets = this._applyMergeSimilar(buckets);
      }

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob You are not responsive here? Isn't this being picked up in your project-monitoring?

When both filters are active, _applyAfkFilter replaces currentwindow
bucket events with fresh results from the backend query engine, which
silently discarded any merges performed by _applyMergeSimilar.

Reversing the order ensures merge-similar operates on already-filtered
events, so both filters compose correctly.

Fixes review feedback from Greptile.
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Fixed! Greptile was right — the filter order was backwards.

_applyAfkFilter replaces currentwindow events with a fresh backend query result, so running merge-similar before AFK filtering meant the merges got silently discarded. Reversed the order in 0dff89d so AFK filtering runs first, then merge-similar operates on the already-filtered events.

As for project-monitoring responsiveness — this PR wasn't being picked up because the Greptile review comment was on the PR description, not as a review-request or explicit mention to me. The ping from Erik landed it in scope. Will check if project-monitoring needs to be adjusted to catch Greptile review findings on my own PRs.

@ErikBjare
Copy link
Copy Markdown
Member

@greptileai review

@ErikBjare ErikBjare merged commit 38663fb into ActivityWatch:master Mar 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timeline fills with hundreds of events from a single app (Adobe Illustrator)

2 participants