Skip to content

Commit 137ee09

Browse files
feat(timeline): add category filter to Timeline view (#778)
* feat(timeline): add category filter to Timeline view Add a category filter dropdown to the Timeline's filter panel that lets users show only events matching selected categories. Supports multi-select with tag-based display, hierarchical matching (selecting "Work" includes "Work > Programming"), and AFK bucket exclusion. Uses the same categorization logic as the existing swimlane coloring. Closes #620 * fix(lint): rename shadowed variable in Timeline category filter Rename `_` to `_cat` in removeCategory's filter callback to avoid shadowing the lodash import, fixing the no-shadow eslint warning. * refactor(timeline): reuse shared categorization helper
1 parent 22317a1 commit 137ee09

2 files changed

Lines changed: 82 additions & 9 deletions

File tree

src/util/color.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,31 @@ export function getTitleAttr(bucket: { type?: string }, e: IEvent) {
140140
}
141141
}
142142

143-
export function getCategoryColorFromEvent(bucket: IBucket, e: IEvent) {
143+
export function getCategorizationStringFromEvent(bucket: IBucket, e: IEvent): string | null {
144144
if (bucket.type == 'currentwindow') {
145145
// using linebreak and "m" regex flag to make `$` and `^` work
146-
return getCategoryColorFromString(e.data.app + '\n' + e.data.title);
146+
return e.data.app + '\n' + e.data.title;
147147
} else if (bucket.type == 'web.tab.current') {
148148
// same as above
149-
return getCategoryColorFromString(e.data.title + '\n' + e.data.url);
150-
} else if (bucket.type == 'afkstatus') {
151-
return getColorFromString(e.data.status);
149+
return e.data.title + '\n' + e.data.url;
152150
} else if (bucket.type?.startsWith('app.editor')) {
153-
return getCategoryColorFromString(e.data.file);
151+
return e.data.file;
154152
} else if (bucket.type?.startsWith('general.stopwatch')) {
155-
return getCategoryColorFromString(e.data.label);
156-
} else {
157-
return getColorFromString(getTitleAttr(bucket, e));
153+
return e.data.label;
154+
}
155+
156+
return null;
157+
}
158+
159+
export function getCategoryColorFromEvent(bucket: IBucket, e: IEvent) {
160+
const categorizationString = getCategorizationStringFromEvent(bucket, e);
161+
if (categorizationString !== null) {
162+
return getCategoryColorFromString(categorizationString);
158163
}
164+
165+
if (bucket.type == 'afkstatus') {
166+
return getColorFromString(e.data.status);
167+
}
168+
169+
return getColorFromString(getTitleAttr(bucket, e));
159170
}

src/views/Timeline.vue

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ 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 Categories:
44+
td
45+
select(@change="onCategorySelect($event)", :value="''")
46+
option(value="" disabled) {{ filter_categories.length > 0 ? 'Add category...' : 'All' }}
47+
option(v-for="cat in category_options", :key="cat.text", :value="cat.text") {{ cat.text }}
48+
div.mt-1(v-if="filter_categories.length > 0")
49+
span.badge.badge-info.mr-1(v-for="(cat, idx) in filter_categories", :key="idx")
50+
| {{ cat.join(' > ') }}
51+
button.ml-1.close.small(@click="removeCategory(idx)", type="button", style="font-size: 0.8rem") ×
4152
div.d-inline-block.border.rounded.p-2.mr-2(v-if="num_events !== 0")
4253
| Events shown: {{ num_events }}
4354
b-alert.d-inline-block.p-2.mb-0.mt-2(v-if="num_events === 0", variant="warning", show)
@@ -80,6 +91,9 @@ import { useSettingsStore } from '~/stores/settings';
8091
import { useBucketsStore } from '~/stores/buckets';
8192
import { getClient } from '~/util/awclient';
8293
import { canonicalEvents } from '~/queries';
94+
import { useCategoryStore } from '~/stores/categories';
95+
import { matchString } from '~/util/classes';
96+
import { getCategorizationStringFromEvent } from '~/util/color';
8397
import { seconds_to_duration } from '~/util/time';
8498
8599
export default {
@@ -96,6 +110,7 @@ export default {
96110
filter_client: null,
97111
filter_duration: null,
98112
filter_afk: false,
113+
filter_categories: [],
99114
swimlane: null,
100115
updateTimelineWindow: true,
101116
};
@@ -110,6 +125,10 @@ export default {
110125
num_events() {
111126
return _.sumBy(this.buckets, 'events.length');
112127
},
128+
category_options() {
129+
const categoryStore = useCategoryStore();
130+
return categoryStore.allCategoriesSelect;
131+
},
113132
filter_summary() {
114133
const desc = [];
115134
if (this.filter_hostname) {
@@ -124,6 +143,13 @@ export default {
124143
if (this.filter_afk) {
125144
desc.push('AFK filtered');
126145
}
146+
if (this.filter_categories.length > 0) {
147+
desc.push(
148+
this.filter_categories.length +
149+
' categor' +
150+
(this.filter_categories.length === 1 ? 'y' : 'ies')
151+
);
152+
}
127153
128154
if (desc.length > 0) {
129155
return desc.join(', ');
@@ -152,12 +178,28 @@ export default {
152178
this.updateTimelineWindow = false;
153179
this.getBuckets();
154180
},
181+
filter_categories() {
182+
this.updateTimelineWindow = false;
183+
this.getBuckets();
184+
},
155185
swimlane() {
156186
this.updateTimelineWindow = false;
157187
this.getBuckets();
158188
},
159189
},
160190
methods: {
191+
onCategorySelect(event) {
192+
const text = event.target.value;
193+
if (!text) return;
194+
const cat = this.category_options.find(c => c.text === text);
195+
if (cat && !this.filter_categories.some(fc => _.isEqual(fc, cat.value))) {
196+
this.filter_categories = [...this.filter_categories, cat.value];
197+
}
198+
event.target.value = '';
199+
},
200+
removeCategory(idx) {
201+
this.filter_categories = this.filter_categories.filter((_cat, i) => i !== idx);
202+
},
161203
getBuckets: async function () {
162204
if (this.daterange == null) return;
163205
@@ -189,6 +231,26 @@ export default {
189231
}
190232
}
191233
234+
if (this.filter_categories.length > 0) {
235+
const categoryStore = useCategoryStore();
236+
const allCats = categoryStore.classes;
237+
for (const bucket of buckets) {
238+
// Skip AFK buckets — they don't have meaningful categorization
239+
if (bucket.type === 'afkstatus') continue;
240+
bucket.events = _.filter(bucket.events, e => {
241+
const str = getCategorizationStringFromEvent(bucket, e);
242+
if (str === null) return true; // Keep events from unknown bucket types
243+
const matched = matchString(str, allCats);
244+
const eventCat = matched ? matched.name : ['Uncategorized'];
245+
// Check if the event's category matches any selected filter category
246+
// (including parent matches: selecting "Work" also shows "Work > Programming")
247+
return this.filter_categories.some(filterCat =>
248+
_.isEqual(eventCat.slice(0, filterCat.length), filterCat)
249+
);
250+
});
251+
}
252+
}
253+
192254
// AFK filtering: use query engine to filter window events by AFK status
193255
if (this.filter_afk) {
194256
buckets = await this._applyAfkFilter(buckets);

0 commit comments

Comments
 (0)