Skip to content

Commit 38663fb

Browse files
feat(timeline): add 'Merge by app' filter to reduce event flooding (#782)
* feat(timeline): add 'Merge by app' filter to reduce event flooding 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 * fix(timeline): apply AFK filter before merge-similar to prevent discard 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.
1 parent 2d3ce67 commit 38663fb

1 file changed

Lines changed: 60 additions & 0 deletions

File tree

src/views/Timeline.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ div
3838
td
3939
b-form-checkbox(v-model="filter_afk" size="sm" switch)
4040
| Filter AFK
41+
tr
42+
th.pt-2.pr-3
43+
label Merge:
44+
td
45+
b-form-checkbox(v-model="filter_merge_similar" size="sm" switch)
46+
| Merge by app
4147
tr
4248
th.pt-2.pr-3
4349
label Categories:
@@ -110,6 +116,7 @@ export default {
110116
filter_client: null,
111117
filter_duration: null,
112118
filter_afk: false,
119+
filter_merge_similar: false,
113120
filter_categories: [],
114121
swimlane: null,
115122
updateTimelineWindow: true,
@@ -143,6 +150,9 @@ export default {
143150
if (this.filter_afk) {
144151
desc.push('AFK filtered');
145152
}
153+
if (this.filter_merge_similar) {
154+
desc.push('merged by app');
155+
}
146156
if (this.filter_categories.length > 0) {
147157
desc.push(
148158
this.filter_categories.length +
@@ -178,6 +188,10 @@ export default {
178188
this.updateTimelineWindow = false;
179189
this.getBuckets();
180190
},
191+
filter_merge_similar() {
192+
this.updateTimelineWindow = false;
193+
this.getBuckets();
194+
},
181195
filter_categories() {
182196
this.updateTimelineWindow = false;
183197
this.getBuckets();
@@ -256,9 +270,55 @@ export default {
256270
buckets = await this._applyAfkFilter(buckets);
257271
}
258272
273+
// Merge adjacent events by app name for window buckets.
274+
// Runs after AFK filtering so merges operate on already-filtered events.
275+
// Reduces visual clutter from apps that produce many small events (e.g.
276+
// Adobe Illustrator's TAB key toggling UI panels). See: activitywatch#1165
277+
if (this.filter_merge_similar) {
278+
buckets = this._applyMergeSimilar(buckets);
279+
}
280+
259281
this.buckets = buckets;
260282
},
261283
284+
// Merges adjacent events with the same app name within window buckets.
285+
// This collapses rapid title changes (e.g. toggling UI panels) into single
286+
// blocks per app, fixing timeline flooding for apps like Adobe Illustrator.
287+
_applyMergeSimilar: function (buckets) {
288+
return buckets.map(bucket => {
289+
if (bucket.type !== 'currentwindow' || !bucket.events || bucket.events.length <= 1) {
290+
return bucket;
291+
}
292+
293+
const sorted = [...bucket.events].sort(
294+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
295+
);
296+
297+
const merged = [];
298+
let current = { ...sorted[0] };
299+
300+
for (let i = 1; i < sorted.length; i++) {
301+
const next = sorted[i];
302+
const currentEnd = new Date(current.timestamp).getTime() + current.duration * 1000;
303+
const nextStart = new Date(next.timestamp).getTime();
304+
const gap = nextStart - currentEnd;
305+
306+
// Merge if same app and gap is small (< 30 seconds)
307+
if (current.data?.app && current.data.app === next.data?.app && gap < 30000) {
308+
const nextEnd = nextStart + next.duration * 1000;
309+
current.duration =
310+
(Math.max(currentEnd, nextEnd) - new Date(current.timestamp).getTime()) / 1000;
311+
} else {
312+
merged.push(current);
313+
current = { ...next };
314+
}
315+
}
316+
merged.push(current);
317+
318+
return { ...bucket, events: merged };
319+
});
320+
},
321+
262322
// Replaces raw window bucket events with AFK-filtered events via aw query engine.
263323
// Also hides AFK status buckets since they're used for filtering, not display.
264324
_applyAfkFilter: async function (buckets) {

0 commit comments

Comments
 (0)