diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 02d1dd75..3951605c 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -38,6 +38,12 @@ div td b-form-checkbox(v-model="filter_afk" size="sm" switch) | Filter AFK + tr + th.pt-2.pr-3 + label Merge: + td + b-form-checkbox(v-model="filter_merge_similar" size="sm" switch) + | Merge by app tr th.pt-2.pr-3 label Categories: @@ -110,6 +116,7 @@ export default { filter_client: null, filter_duration: null, filter_afk: false, + filter_merge_similar: false, filter_categories: [], swimlane: null, updateTimelineWindow: true, @@ -143,6 +150,9 @@ export default { if (this.filter_afk) { desc.push('AFK filtered'); } + if (this.filter_merge_similar) { + desc.push('merged by app'); + } if (this.filter_categories.length > 0) { desc.push( this.filter_categories.length + @@ -178,6 +188,10 @@ export default { this.updateTimelineWindow = false; this.getBuckets(); }, + filter_merge_similar() { + this.updateTimelineWindow = false; + this.getBuckets(); + }, filter_categories() { this.updateTimelineWindow = false; this.getBuckets(); @@ -256,9 +270,55 @@ export default { buckets = await this._applyAfkFilter(buckets); } + // Merge adjacent events by app name for window buckets. + // Runs after AFK filtering so merges operate on already-filtered events. + // Reduces visual clutter from apps that produce many small events (e.g. + // Adobe Illustrator's TAB key toggling UI panels). See: activitywatch#1165 + if (this.filter_merge_similar) { + buckets = this._applyMergeSimilar(buckets); + } + this.buckets = buckets; }, + // Merges adjacent events with the same app name within window buckets. + // This collapses rapid title changes (e.g. toggling UI panels) into single + // blocks per app, fixing timeline flooding for apps like Adobe Illustrator. + _applyMergeSimilar: function (buckets) { + return buckets.map(bucket => { + if (bucket.type !== 'currentwindow' || !bucket.events || bucket.events.length <= 1) { + return bucket; + } + + const sorted = [...bucket.events].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + const merged = []; + let current = { ...sorted[0] }; + + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + const currentEnd = new Date(current.timestamp).getTime() + current.duration * 1000; + const nextStart = new Date(next.timestamp).getTime(); + const gap = nextStart - currentEnd; + + // Merge if same app and gap is small (< 30 seconds) + if (current.data?.app && current.data.app === next.data?.app && gap < 30000) { + const nextEnd = nextStart + next.duration * 1000; + current.duration = + (Math.max(currentEnd, nextEnd) - new Date(current.timestamp).getTime()) / 1000; + } else { + merged.push(current); + current = { ...next }; + } + } + merged.push(current); + + return { ...bucket, events: merged }; + }); + }, + // Replaces raw window bucket events with AFK-filtered events via aw query engine. // Also hides AFK status buckets since they're used for filtering, not display. _applyAfkFilter: async function (buckets) {