From 65f58db40d717717863c0df4db9be63cb8d60a71 Mon Sep 17 00:00:00 2001 From: Brayo Date: Fri, 11 Sep 2026 01:19:56 +0300 Subject: [PATCH 01/10] perf: reuse timeline event requests across filter changes --- src/util/interfaces.ts | 1 + src/views/Timeline.vue | 129 ++++++++++++++--------------- src/visualizations/VisTimeline.vue | 3 + test/unit/Timeline.test.js | 84 +++++++++++++++++++ 4 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 test/unit/Timeline.test.js diff --git a/src/util/interfaces.ts b/src/util/interfaces.ts index bde00fd3b..65febc742 100644 --- a/src/util/interfaces.ts +++ b/src/util/interfaces.ts @@ -6,6 +6,7 @@ export interface IEvent { export interface IBucket { id: string; + client?: string; hostname: string; device_id: string; type: string; diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 06cb4a842..7db7b1300 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -79,7 +79,7 @@ div title="Display options" aria-label="Display options" ) - template(v-slot:button-content) + template(v-slot:button-content="") icon(name="ellipsis-v") b-dropdown-header Swimlanes b-dropdown-item-button( @@ -146,6 +146,17 @@ export default { updateTimelineWindow: true, }; }, + created() { + // Keep request promises outside Vue's reactive event graph. Retain only the + // current range; revisiting a range refreshes edits and newly imported data. + this.eventRequests = new Map(); + this.requestRange = ''; + this.requestGeneration = 0; + }, + beforeDestroy() { + this.requestGeneration++; + this.eventRequests.clear(); + }, computed: { ...mapState(useSettingsStore, ['always_active_pattern']), timeintervalDefaultDuration() { @@ -220,8 +231,7 @@ export default { this.updateTimelineWindow = false; this.getBuckets(); }, - swimlane() { - this.updateTimelineWindow = false; + always_active_pattern() { this.getBuckets(); }, }, @@ -241,28 +251,56 @@ export default { getBuckets: async function () { if (this.daterange == null) return; - this.all_buckets = Object.freeze( - await useBucketsStore().getBucketsWithEvents({ - start: this.daterange[0].format(), - end: this.daterange[1].format(), + const generation = ++this.requestGeneration; + const start = this.daterange[0].format(); + const end = this.daterange[1].format(); + const range = `${start}/${end}`; + if (range !== this.requestRange) { + this.eventRequests.clear(); + this.requestRange = range; + } + const store = useBucketsStore(); + await store.ensureLoaded(); + if (generation !== this.requestGeneration) return; + this.hosts = [...new Set(store.buckets.map(b => b.hostname))]; + this.clients = [...new Set(store.buckets.map(b => b.client))]; + const selected = store.buckets.filter( + b => + (!this.filter_hostname || b.hostname === this.filter_hostname) && + (!this.filter_client || b.client === this.filter_client) + ); + const filterAfk = this.filter_afk; + const pattern = this.always_active_pattern; + const raw = await Promise.all( + selected.map(bucket => { + const afkId = + filterAfk && bucket.type === 'currentwindow' && bucket.hostname + ? store.bucketsAFK(bucket.hostname)[0] + : null; + if (filterAfk && bucket.type === 'afkstatus') return null; + const key = JSON.stringify([range, bucket.id, afkId, afkId ? pattern : null]); + if (!this.eventRequests.has(key)) { + const request = afkId + ? this._queryAfkFilteredEvents(bucket.id, afkId, start, end, pattern).then( + events => ({ ...bucket, events }) + ) + : store.getBucketWithEvents({ id: bucket.id, start, end }); + this.eventRequests.set(key, request); + request.catch(() => { + if (this.eventRequests.get(key) === request) this.eventRequests.delete(key); + }); + } + return this.eventRequests.get(key).catch(error => { + if (!afkId) throw error; + console.warn('AFK filter query failed, falling back to raw events:', error); + return store.getBucketWithEvents({ id: bucket.id, start, end }); + }); }) ); - - this.hosts = this.all_buckets - .map(a => a.hostname) - .filter((value, index, array) => array.indexOf(value) === index); - this.clients = this.all_buckets - .map(a => a.client) - .filter((value, index, array) => array.indexOf(value) === index); - - let buckets = this.all_buckets; - if (this.filter_hostname) { - buckets = _.filter(buckets, b => b.hostname == this.filter_hostname); - } - if (this.filter_client) { - buckets = _.filter(buckets, b => b.client == this.filter_client); - } - + if (generation !== this.requestGeneration) return; + this.all_buckets = Object.freeze(raw.filter(Boolean)); + // Filters replace arrays on copies, so clearing a filter restores raw events. + let buckets = this.all_buckets.map(bucket => ({ ...bucket })); if (this.filter_duration > 0) { for (const bucket of buckets) { bucket.events = _.filter(bucket.events, e => e.duration >= this.filter_duration); @@ -289,11 +327,6 @@ export default { } } - // 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. // Runs after AFK filtering so merges operate on already-filtered events. // Reduces visual clutter from apps that produce many small events (e.g. @@ -343,57 +376,21 @@ export default { }); }, - // 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) { - const bucketsStore = useBucketsStore(); - const result = []; - - for (const bucket of buckets) { - // Hide AFK status buckets when AFK filtering is active - if (bucket.type === 'afkstatus') { - continue; - } - - // For window buckets, replace events with AFK-filtered query results - if (bucket.type === 'currentwindow' && bucket.hostname) { - const afkBucketIds = bucketsStore.bucketsAFK(bucket.hostname); - if (afkBucketIds.length > 0) { - try { - const filteredEvents = await this._queryAfkFilteredEvents(bucket.id, afkBucketIds[0]); - // Create a copy with filtered events to avoid mutating frozen all_buckets - result.push({ ...bucket, events: filteredEvents }); - continue; - } catch (e) { - console.warn('AFK filter query failed, falling back to raw events:', e); - } - } - } - - // Keep other buckets unchanged - result.push(bucket); - } - - return result; - }, - // Runs a canonicalEvents query to get window events filtered by AFK status, // respecting the user's always_active_pattern setting. - _queryAfkFilteredEvents: async function (windowBucketId, afkBucketId) { + _queryAfkFilteredEvents: async function (windowBucketId, afkBucketId, start, end, pattern) { const queryCode = canonicalEvents({ bid_window: windowBucketId, bid_afk: afkBucketId, filter_afk: true, - always_active_pattern: this.always_active_pattern || undefined, + always_active_pattern: pattern || undefined, categories: [], filter_categories: null, }) + '\nRETURN = events;'; const queryArray = querystr_to_array(queryCode); - const start = this.daterange[0].format(); - const end = this.daterange[1].format(); const timeperiods = [`${start}/${end}`]; const data = await getClient().query(timeperiods, queryArray); diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index 90501f76d..dbcfd0219 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -170,6 +170,9 @@ export default { }, }, watch: { + swimlane() { + this.update(); + }, buckets() { // For some reason, an object is passed here, after which the correct array arrives if (this.buckets.length === undefined) { diff --git a/test/unit/Timeline.test.js b/test/unit/Timeline.test.js new file mode 100644 index 000000000..c44376a98 --- /dev/null +++ b/test/unit/Timeline.test.js @@ -0,0 +1,84 @@ +import Timeline from '~/views/Timeline.vue'; +import { useBucketsStore } from '~/stores/buckets'; + +jest.mock('~/stores/buckets', () => ({ useBucketsStore: jest.fn() })); + +const event = duration => ({ timestamp: '2026-01-01T10:00:00Z', duration, data: { app: 'A' } }); +function setup() { + const store = { + buckets: [ + { id: 'a', hostname: 'one' }, + { id: 'b', hostname: 'two' }, + ], + ensureLoaded: jest.fn().mockResolvedValue(), + getBucketWithEvents: jest.fn(({ id }) => + Promise.resolve({ id, events: [event(5), event(20)] }) + ), + bucketsAFK: jest.fn(() => ['afk']), + }; + useBucketsStore.mockReturnValue(store); + const vm = { ...Timeline.data(), ...Timeline.methods }; + vm.daterange = [{ format: () => '2026-01-01' }, { format: () => '2026-01-02' }]; + Timeline.created.call(vm); + return { vm, store }; +} + +test('local filters reuse raw events and clearing them restores all events', async () => { + const { vm, store } = setup(); + await vm.getBuckets(); + vm.filter_duration = 10; + await vm.getBuckets(); + expect(vm.buckets[0].events).toHaveLength(1); + vm.filter_duration = null; + await vm.getBuckets(); + expect(vm.buckets[0].events).toHaveLength(2); + expect(store.getBucketWithEvents).toHaveBeenCalledTimes(2); +}); + +test('fetches selected hosts only and refreshes when range changes', async () => { + const { vm, store } = setup(); + vm.filter_hostname = 'one'; + await vm.getBuckets(); + expect(store.getBucketWithEvents).toHaveBeenCalledTimes(1); + expect(store.getBucketWithEvents.mock.calls[0][0].id).toBe('a'); + vm.daterange[1] = { format: () => '2026-01-03' }; + await vm.getBuckets(); + expect(store.getBucketWithEvents).toHaveBeenCalledTimes(2); +}); + +test('deduplicates in-flight requests and ignores an older range response', async () => { + const { vm, store } = setup(); + vm.filter_hostname = 'one'; + let resolveOld; + store.getBucketWithEvents.mockImplementationOnce( + () => + new Promise(resolve => { + resolveOld = resolve; + }) + ); + const old = vm.getBuckets(); + await Promise.resolve(); + const duplicate = vm.getBuckets(); + await Promise.resolve(); + expect(store.getBucketWithEvents).toHaveBeenCalledTimes(1); + vm.daterange[1] = { format: () => '2026-01-03' }; + await vm.getBuckets(); + resolveOld({ id: 'old', events: [] }); + await Promise.all([old, duplicate]); + expect(vm.buckets[0].id).toBe('a'); +}); + +test('applies duration filtering after AFK results and reuses the query', async () => { + const { vm, store } = setup(); + store.buckets = [{ id: 'a', hostname: 'one', type: 'currentwindow' }]; + vm.filter_afk = true; + vm._queryAfkFilteredEvents = jest.fn().mockResolvedValue([event(5), event(20)]); + vm.filter_duration = 10; + await vm.getBuckets(); + expect(vm.buckets[0].events).toHaveLength(1); + vm.filter_duration = null; + await vm.getBuckets(); + expect(vm.buckets[0].events).toHaveLength(2); + expect(vm._queryAfkFilteredEvents).toHaveBeenCalledTimes(1); + expect(store.getBucketWithEvents).not.toHaveBeenCalled(); +}); From 9ae96123e0dd63d11ba347535a4cf6e7c59daf4d Mon Sep 17 00:00:00 2001 From: Brayo Date: Fri, 11 Sep 2026 01:21:06 +0300 Subject: [PATCH 02/10] perf: reuse compiled category rules and selectors --- src/util/classes.ts | 82 +++++++++++++++++++++------------- test/unit/classes.test.node.ts | 37 +++++++++++++++ 2 files changed, 89 insertions(+), 30 deletions(-) diff --git a/src/util/classes.ts b/src/util/classes.ts index 94c72aca0..a4a29539e 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -462,43 +462,65 @@ function pickHighestRanked(categories: Category[]) { return _.maxBy(categories, categoryRank); } +interface CompiledRule { + source: string; + ignoreCase: boolean; + rawKeys: string[] | undefined; + keys: string[] | undefined; + regex: RegExp; +} + +// Rule objects can be edited in place by the category editor. Compare the +// compilation inputs rather than relying only on array/object identity. +const compiledRules = new WeakMap(); +function compileRule(rule: Rule): CompiledRule { + const cached = compiledRules.get(rule); + const rawKeys = rule.select_keys; + if ( + cached && + cached.source === rule.regex && + cached.ignoreCase === !!rule.ignore_case && + cached.rawKeys?.length === rawKeys?.length && + (rawKeys || []).every((key, i) => key === cached.rawKeys[i]) + ) { + return cached; + } + const compiled = { + source: rule.regex, + ignoreCase: !!rule.ignore_case, + rawKeys: rawKeys?.slice(), + keys: normalizeSelectKeys(rawKeys), + regex: new RegExp(rule.regex, (rule.ignore_case ? 'i' : '') + 'm'), + }; + compiledRules.set(rule, compiled); + return compiled; +} + export function matchString( str: string, categories: Category[] | null, event?: IEvent ): Category | null { - if (!categories) { - console.log( - 'Categories not passed, loading... (if you see this outside of a test, you should probably pass them)' - ); - categories = loadClasses(); - } - - // Compile regexes - const regexes: [Category, RegExp][] = categories - .filter(c => c.rule.type == 'regex') - .map(c => { - // using 'm' flag to make `$` and `^` in rules work - const re = RegExp(c.rule.regex, (c.rule.ignore_case ? 'i' : '') + 'm'); - return [c, re]; - }); - - // Find the matching category. - // If several categories match, explicit priority wins; otherwise depth wins. - const matchingCats: [Category, RegExp][] = regexes.filter(([category, re]) => { - const selectKeys = normalizeSelectKeys(category.rule.select_keys); - if (event && selectKeys) { - return selectKeys.some(key => { - const value = event.data[key]; - return typeof value === 'string' && re.test(value); - }); + categories = categories || loadClasses(); + let best: Category | null = null; + let bestRank = -Infinity; + for (const category of categories) { + if (category.rule.type !== 'regex') continue; + const { regex, keys } = compileRule(category.rule); + const matches = + event && keys + ? keys.some(key => typeof event.data[key] === 'string' && regex.test(event.data[key])) + : regex.test(str); + if (matches) { + const rank = categoryRank(category); + // Strictly greater preserves the original first-match tie breaking. + if (rank > bestRank) { + best = category; + bestRank = rank; + } } - return re.test(str); - }); - if (matchingCats.length > 0) { - return pickHighestRanked(matchingCats.map(c => c[0])); } - return null; + return best; } // this is used only in tests diff --git a/test/unit/classes.test.node.ts b/test/unit/classes.test.node.ts index 5ec793768..2e91e3354 100644 --- a/test/unit/classes.test.node.ts +++ b/test/unit/classes.test.node.ts @@ -130,3 +130,40 @@ test('normalizeSelectKeys rejects empty lists', () => { expect(classes.normalizeSelectKeys(null)).toBeUndefined(); expect(classes.normalizeSelectKeys(['app', 'title'])).toEqual(['app', 'title']); }); + +describe('compiled category rule reuse', () => { + test('reuses regex objects while respecting in-place rule edits', () => { + const cats: Category[] = [{ name: ['Work'], rule: { type: 'regex', regex: '^vim$' } }]; + const construct = jest.spyOn(global, 'RegExp'); + try { + expect(classes.matchString('vim', cats)).toBe(cats[0]); + expect(classes.matchString('other', cats)).toBeNull(); + expect(construct).toHaveBeenCalledTimes(1); + cats[0].rule.ignore_case = true; + expect(classes.matchString('VIM', cats)).toBe(cats[0]); + cats[0].rule.regex = '^code$'; + expect(classes.matchString('vim', cats)).toBeNull(); + expect(classes.matchString('CODE', cats)).toBe(cats[0]); + expect(construct).toHaveBeenCalledTimes(3); + } finally { + construct.mockRestore(); + } + }); + + test('refreshes edited selectors and ranking without stale matches', () => { + const cats: Category[] = [ + { name: ['First'], rule: { type: 'regex', regex: 'vim', select_keys: ['app'] } }, + { name: ['Second'], rule: { type: 'regex', regex: 'vim' } }, + ]; + const event: IEvent = { timestamp: '', duration: 1, data: { app: 'vim', title: 'other' } }; + expect(classes.matchString('vim', cats, event)).toBe(cats[0]); + cats[0].rule.select_keys[0] = 'title'; + expect(classes.matchString('vim', cats, event)).toBe(cats[1]); + delete cats[0].rule.select_keys; + expect(classes.matchString('vim', cats, event)).toBe(cats[0]); + cats[1].rule.priority = 100; + expect(classes.matchString('vim', cats, event)).toBe(cats[1]); + cats[1].rule.type = 'none'; + expect(classes.matchString('vim', cats, event)).toBe(cats[0]); + }); +}); From 4e5130eeefa3de99a80555d083836f3c869a542e Mon Sep 17 00:00:00 2001 From: Brayo Date: Fri, 11 Sep 2026 01:21:41 +0300 Subject: [PATCH 03/10] perf: release timeline and graph resources on unmount --- src/visualizations/ForceGraph.vue | 4 ++++ src/visualizations/VisTimeline.vue | 5 +++++ test/unit/VisTimeline.test.js | 33 ++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/visualizations/ForceGraph.vue b/src/visualizations/ForceGraph.vue index 28ab7d0ce..b19a31cd6 100644 --- a/src/visualizations/ForceGraph.vue +++ b/src/visualizations/ForceGraph.vue @@ -31,6 +31,10 @@ export default { mounted() { this.drawGraph(this.data); }, + beforeDestroy() { + this.cancelPromise && this.cancelPromise(); + this.cancelPromise = null; + }, methods: { drawGraph({ nodes, links }) { console.log('rendering...'); diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index dbcfd0219..817bd16e2 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -192,6 +192,7 @@ export default { }, mounted() { this.$nextTick(() => { + if (this._isDestroyed || this._isBeingDestroyed) return; const el = this.$el.querySelector('#visualization'); el.addEventListener('wheel', this.onHorizontalWheel, { capture: true, @@ -213,6 +214,10 @@ export default { if (el) { el.removeEventListener('wheel', this.onHorizontalWheel, { capture: true }); } + if (this.timeline) { + this.timeline.destroy(); + this.timeline = null; + } }, methods: { onHorizontalWheel: function (event: WheelEvent) { diff --git a/test/unit/VisTimeline.test.js b/test/unit/VisTimeline.test.js index 924ad3e1c..b16c2d828 100644 --- a/test/unit/VisTimeline.test.js +++ b/test/unit/VisTimeline.test.js @@ -150,3 +150,36 @@ describe('VisTimeline zoom-anchor regression (#847)', () => { }); }); }); + +describe('timeline teardown', () => { + test('destroys the library instance and releases the wheel listener', () => { + const el = { removeEventListener: jest.fn() }; + const destroy = jest.fn(); + const vm = { + $el: { querySelector: () => el }, + timeline: { destroy }, + onHorizontalWheel: jest.fn(), + }; + VisTimeline.beforeDestroy.call(vm); + expect(destroy).toHaveBeenCalledTimes(1); + expect(vm.timeline).toBeNull(); + expect(el.removeEventListener).toHaveBeenCalledWith('wheel', vm.onHorizontalWheel, { + capture: true, + }); + VisTimeline.beforeDestroy.call(vm); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + test('does not create an instance after destruction while nextTick is pending', () => { + let mount; + const vm = { + $nextTick: callback => { + mount = callback; + }, + _isDestroyed: false, + }; + VisTimeline.mounted.call(vm); + vm._isDestroyed = true; + expect(() => mount()).not.toThrow(); + }); +}); From 95498f22340d342129ce900e7269577432ce4c52 Mon Sep 17 00:00:00 2001 From: Brayo Date: Fri, 11 Sep 2026 01:24:56 +0300 Subject: [PATCH 04/10] perf: bound category history caching and load it on demand --- src/stores/activity.ts | 138 +++++++++--------- src/util/awclient.ts | 10 ++ src/util/periodCache.ts | 38 +++++ src/views/activity/Activity.vue | 33 ++++- test/unit/awclient.test.js | 15 ++ .../store/categoryHistoryCache.test.node.ts | 74 ++++++++++ 6 files changed, 239 insertions(+), 69 deletions(-) create mode 100644 src/util/periodCache.ts create mode 100644 test/unit/store/categoryHistoryCache.test.node.ts diff --git a/src/stores/activity.ts b/src/stores/activity.ts index eae1adecb..ecd680742 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -22,6 +22,10 @@ import { useBucketsStore } from '~/stores/buckets'; import { useCategoryStore } from '~/stores/categories'; import { getClient } from '~/util/awclient'; +import { PeriodCache } from '~/util/periodCache'; + +const categoryPeriodCaches = new WeakMap>(); +const categoryRequests = new WeakMap(); import { FullDesktopQueryResult, mergeFullDesktopResults, @@ -95,6 +99,7 @@ export interface QueryOptions { filter_afk?: boolean; include_audible?: boolean; include_stopwatch?: boolean; + include_category_history?: boolean; filter_categories?: string[][]; dont_query_inactive?: boolean; force?: boolean; @@ -337,7 +342,10 @@ export const useActivityStore = defineStore('activity', { } // Perform this last, as it takes the longest - if (this.window.available || this.android.available) { + if ( + query_options.include_category_history !== false && + (this.window.available || this.android.available) + ) { await this.query_category_time_by_period(query_options); } } else { @@ -405,6 +413,8 @@ export const useActivityStore = defineStore('activity', { }, async reset() { + categoryRequests.delete(this); + categoryPeriodCaches.delete(this); getClient().abort(); this.query_window_completed({}); this.query_browser_completed({}); @@ -517,9 +527,9 @@ export const useActivityStore = defineStore('activity', { filter_categories, filter_afk, include_stopwatch, - dontQueryInactive, always_active_pattern, - }: QueryOptions & { dontQueryInactive: boolean }) { + force, + }: QueryOptions) { // TODO: Needs to be adapted for Android let periods: string[]; const count = timeperiod.length[0]; @@ -544,76 +554,67 @@ export const useActivityStore = defineStore('activity', { // Filter out periods that start in the future periods = periods.filter(period => new Date(period.split('/')[0]) < new Date()); + const request = {}; + categoryRequests.set(this, request); const signal = getClient().controller.signal; - let cancelled = false; - signal.onabort = () => { - cancelled = true; - console.debug('Request aborted'); - }; + let cache = categoryPeriodCaches.get(this); + if (!cache) { + cache = new PeriodCache(); + categoryPeriodCaches.set(this, cache); + } + if (force) cache.clear(); - // Query one period at a time, to avoid timeout on slow queries - let data = []; + // Prefer ScreenTime bucket over Android watcher for consistency with query_android + const iosBucketForCategory = this.buckets.android.find((id: string) => + id.startsWith('aw-import-screentime') + ); + const iosOrAndroidBucket = iosBucketForCategory || this.buckets.android[0]; + const isAndroid = iosOrAndroidBucket !== undefined; + // ScreenTime (iOS) buckets carry a "title" key; aw-watcher-android buckets do not. + // Pass isIos so canonicalEvents uses the correct merge keys and titles are preserved. + const isIosForCategory = !!iosBucketForCategory; + const categories = useCategoryStore().classes_for_query; + // TODO: Clean up call, pass QueryParams in fullDesktopQuery as well + // TODO: Unify QueryOptions and QueryParams + const query = queries.categoryQuery({ + bid_browsers: this.buckets.browser, + bid_stopwatch: + include_stopwatch && this.buckets.stopwatch.length > 0 + ? this.buckets.stopwatch[0] + : undefined, + categories, + filter_categories, + filter_afk, + always_active_pattern, + ...(isAndroid + ? { + bid_android: iosOrAndroidBucket, + isIos: isIosForCategory, + } + : { + bid_afk: this.buckets.afk[0], + bid_window: this.buckets.window[0], + }), + }); + const queryKey = JSON.stringify(query); + const data = []; + // Retain sequential requests to avoid long server queries/timeouts. for (const period of periods) { - // Not stable - //signal.throwIfAborted(); - if (cancelled) { - throw signal['reason'] || 'unknown reason'; - } - - // Only query periods with known data from AFK bucket - if (dontQueryInactive && this.active.events.length > 0) { - const start = new Date(period.split('/')[0]); - const end = new Date(period.split('/')[1]); - - // Retrieve active time in period - const period_activity = this.active.events.find((e: IEvent) => { - return start < new Date(e.timestamp) && new Date(e.timestamp) < end; + if (signal.aborted || categoryRequests.get(this) !== request) return; + const key = JSON.stringify([queryKey, period]); + const closed = new Date(period.split('/')[1]).getTime() <= Date.now(); + let result = closed ? cache.get(key) : undefined; + if (result === undefined) { + const revision = cache.version; + const response = await getClient().query([period], query, { + cache: false, // This bounded cache owns freshness and invalidation. + name: 'categoryQuery', }); - - // Check if there was active time - if (!(period_activity && period_activity.duration > 0)) { - data = data.concat([{ cat_events: [] }]); - continue; - } + if (signal.aborted || categoryRequests.get(this) !== request) return; + result = response[0]; + if (closed && result !== undefined && revision === cache.version) cache.set(key, result); } - - // Prefer ScreenTime bucket over Android watcher for consistency with query_android - const iosBucketForCategory = this.buckets.android.find((id: string) => - id.startsWith('aw-import-screentime') - ); - const iosOrAndroidBucket = iosBucketForCategory || this.buckets.android[0]; - const isAndroid = iosOrAndroidBucket !== undefined; - // ScreenTime (iOS) buckets carry a "title" key; aw-watcher-android buckets do not. - // Pass isIos so canonicalEvents uses the correct merge keys and titles are preserved. - const isIosForCategory = !!iosBucketForCategory; - const categories = useCategoryStore().classes_for_query; - // TODO: Clean up call, pass QueryParams in fullDesktopQuery as well - // TODO: Unify QueryOptions and QueryParams - const query = queries.categoryQuery({ - bid_browsers: this.buckets.browser, - bid_stopwatch: - include_stopwatch && this.buckets.stopwatch.length > 0 - ? this.buckets.stopwatch[0] - : undefined, - categories, - filter_categories, - filter_afk, - always_active_pattern, - ...(isAndroid - ? { - bid_android: iosOrAndroidBucket, - isIos: isIosForCategory, - } - : { - bid_afk: this.buckets.afk[0], - bid_window: this.buckets.window[0], - }), - }); - const result = await getClient().query([period], query, { - verbose: true, - name: 'categoryQuery', - }); - data = data.concat(result); + data.push(result); } // Zip periods @@ -763,6 +764,7 @@ export const useActivityStore = defineStore('activity', { // mutations start_loading(this: State, query_options: QueryOptions) { + categoryRequests.delete(this); this.loaded = true; this.query_options = query_options; diff --git a/src/util/awclient.ts b/src/util/awclient.ts index 666534dfa..7eb6c313b 100644 --- a/src/util/awclient.ts +++ b/src/util/awclient.ts @@ -1,4 +1,5 @@ import { AWClient } from 'aw-client'; +import { invalidatePeriodCaches } from './periodCache'; import type { AxiosInstance } from 'axios'; import { useSettingsStore } from '~/stores/settings'; @@ -107,6 +108,15 @@ export function createClient(force?: boolean): AWClient { testing: !production, baseURL, }); + invalidatePeriodCaches(); + _client.req.interceptors.response.use(response => { + const method = (response.config.method || 'get').toLowerCase(); + const path = (response.config.url || '').split('?')[0]; + if (!['get', 'head', 'options'].includes(method) && !/\/query\/?$/.test(path)) { + invalidatePeriodCaches(); + } + return response; + }); applyApiToken(_client, loadApiTokenFromBrowser()); } else { throw 'Tried to instantiate global AWClient twice!'; diff --git a/src/util/periodCache.ts b/src/util/periodCache.ts new file mode 100644 index 000000000..275998a90 --- /dev/null +++ b/src/util/periodCache.ts @@ -0,0 +1,38 @@ +// Shared invalidation for writes made through this UI. A short expiry also +// bounds staleness from external importers/watchers that cannot notify us. +let revision = 0; +export function invalidatePeriodCaches(): void { + revision++; +} + +export class PeriodCache { + private entries = new Map(); + private revision = revision; + constructor(private limit = 256, private ttl = 60_000) {} + + get version(): number { + return revision; + } + + clear(): void { + this.entries.clear(); + this.revision = revision; + } + + get(key: string, now = Date.now()): T | undefined { + if (this.revision !== revision) this.clear(); + const entry = this.entries.get(key); + if (!entry || entry.expires <= now) { + this.entries.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: T, now = Date.now()): void { + if (this.revision !== revision) this.clear(); + this.entries.delete(key); + this.entries.set(key, { value, expires: now + this.ttl }); + while (this.entries.size > this.limit) this.entries.delete(this.entries.keys().next().value); + } +} diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index 557b8a23f..85d1477cd 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -270,6 +270,8 @@ export default { // stopwatch run produced "No data" unless they also flipped the // dev-only "Include manually logged events" checkbox. include_stopwatch: true, + refreshing: false, + refreshGeneration: 0, filter_afk: true, new_view: {}, }; @@ -349,6 +351,9 @@ export default { currentView: function () { return this.views.find(v => v.id == this.$route.params.view_id) || this.views[0]; }, + needsCategoryHistory() { + return !!this.currentView?.elements.some(el => el.type === 'timeline_barchart'); + }, currentViewId: function () { // If localStore is not yet initialized, then currentView can be undefined. In that case, we return an empty string (which should route to the default view) return this.currentView !== undefined ? this.currentView.id : ''; @@ -423,6 +428,16 @@ export default { }, }, watch: { + async needsCategoryHistory(needed) { + if ( + needed && + !this.refreshing && + this.activityStore.query_options?.timeperiod && + (this.activityStore.window.available || this.activityStore.android.available) + ) { + await this.activityStore.query_category_time_by_period(this.activityStore.query_options); + } + }, host: function () { this.refresh(); }, @@ -535,10 +550,26 @@ export default { filter_afk: this.filter_afk, include_audible: this.include_audible, include_stopwatch: this.include_stopwatch, + include_category_history: this.needsCategoryHistory, filter_categories: this.filter_categories, always_active_pattern: this.always_active_pattern, }; - await this.activityStore.ensure_loaded(queryOptions); + const generation = ++this.refreshGeneration; + this.refreshing = true; + try { + await this.activityStore.ensure_loaded(queryOptions); + // A view may finish loading or change while the main queries run. + if ( + generation === this.refreshGeneration && + this.needsCategoryHistory && + !queryOptions.include_category_history && + (this.activityStore.window.available || this.activityStore.android.available) + ) { + await this.activityStore.query_category_time_by_period(queryOptions); + } + } finally { + if (generation === this.refreshGeneration) this.refreshing = false; + } }, load_demo: async function () { diff --git a/test/unit/awclient.test.js b/test/unit/awclient.test.js index 8376fbf28..140ab2105 100644 --- a/test/unit/awclient.test.js +++ b/test/unit/awclient.test.js @@ -1,6 +1,7 @@ jest.mock('aw-client', () => ({ AWClient: jest.fn().mockImplementation(() => ({ req: { + interceptors: { response: { use: jest.fn() } }, defaults: { headers: { common: {}, @@ -60,4 +61,18 @@ describe('awclient auth bootstrap', () => { expect(client.req.defaults.headers.common.Authorization).toBe('Bearer secret'); expect(window.location.search).toBe(''); }); + test('invalidates period caches after writes but not query POSTs', async () => { + const { PeriodCache } = await import('~/util/periodCache'); + const client = createClient(true); + const cache = new PeriodCache(); + const onResponse = client.req.interceptors.response.use.mock.calls[0][0]; + cache.set('period', 42); + onResponse({ config: { method: 'post', url: '/0/query/' } }); + expect(cache.get('period')).toBe(42); + for (const url of ['/0/buckets/window/events', '/0/import']) { + cache.set('period', 42); + onResponse({ config: { method: 'post', url } }); + expect(cache.get('period')).toBeUndefined(); + } + }); }); diff --git a/test/unit/store/categoryHistoryCache.test.node.ts b/test/unit/store/categoryHistoryCache.test.node.ts new file mode 100644 index 000000000..d888fa3d0 --- /dev/null +++ b/test/unit/store/categoryHistoryCache.test.node.ts @@ -0,0 +1,74 @@ +import { createPinia, setActivePinia } from 'pinia'; +import moment from 'moment'; +import { useActivityStore } from '~/stores/activity'; +import { useCategoryStore } from '~/stores/categories'; +import { useSettingsStore } from '~/stores/settings'; +import { useBucketsStore } from '~/stores/buckets'; +import { createClient } from '~/util/awclient'; +import { invalidatePeriodCaches, PeriodCache } from '~/util/periodCache'; + +function setup() { + setActivePinia(createPinia()); + const client = createClient(true); + const query = jest.spyOn(client, 'query').mockResolvedValue([{ cat_events: [] }]); + const store = useActivityStore(); + store.buckets.window = ['window']; + store.buckets.afk = ['afk']; + const options = { + host: 'test', + timeperiod: { start: moment('2020-01-01').format(), length: [2, 'day'] as [number, string] }, + }; + return { store, query, options }; +} + +test('reuses closed periods including empty results, invalidates on query changes and writes', async () => { + const { store, query, options } = setup(); + await store.query_category_time_by_period(options); + await store.query_category_time_by_period(options); + expect(query).toHaveBeenCalledTimes(2); + useCategoryStore().classes = [{ name: ['Work'], rule: { type: 'regex', regex: 'vim' } }]; + await store.query_category_time_by_period(options); + expect(query).toHaveBeenCalledTimes(4); + invalidatePeriodCaches(); + await store.query_category_time_by_period(options); + expect(query).toHaveBeenCalledTimes(6); + await store.query_category_time_by_period({ ...options, force: true }); + expect(query).toHaveBeenCalledTimes(8); + expect(query.mock.calls[0][2]).toMatchObject({ cache: false }); +}); + +test('refreshes the open period while reusing completed hours', async () => { + const { store, query } = setup(); + const options = { + host: 'test', + timeperiod: { start: moment().startOf('day').format(), length: [1, 'day'] as [number, string] }, + }; + await store.query_category_time_by_period(options); + const first = query.mock.calls.length; + await store.query_category_time_by_period(options); + expect(query).toHaveBeenCalledTimes(first + 1); +}); + +test('expired entries and bounded eviction trigger a fresh lookup', () => { + const cache = new PeriodCache(2, 100); + cache.set('a', 1, 0); + cache.set('b', 2, 0); + cache.set('c', 3, 0); + expect(cache.get('a', 50)).toBeUndefined(); + expect(cache.get('c', 50)).toBe(3); + expect(cache.get('c', 100)).toBeUndefined(); +}); + +test('skips history for views that do not consume it', async () => { + const { store, options } = setup(); + jest.spyOn(useSettingsStore(), 'ensureLoaded').mockResolvedValue(); + jest.spyOn(useBucketsStore(), 'ensureLoaded').mockResolvedValue(); + jest.spyOn(store, 'get_buckets').mockResolvedValue(); + jest.spyOn(store, 'query_desktop_full').mockResolvedValue(); + jest.spyOn(store, 'query_active_history').mockResolvedValue(); + const history = jest.spyOn(store, 'query_category_time_by_period').mockResolvedValue(); + await store.ensure_loaded({ ...options, include_category_history: false }); + expect(history).not.toHaveBeenCalled(); + await store.ensure_loaded({ ...options, include_category_history: true }); + expect(history).toHaveBeenCalledTimes(1); +}); From eaff613efa834e2a11367fefe6db76163a3ec959 Mon Sep 17 00:00:00 2001 From: Brayo Date: Fri, 11 Sep 2026 01:29:49 +0300 Subject: [PATCH 05/10] perf: prepare timeline items by viewport and update them incrementally --- src/util/color.ts | 9 +- src/util/timelineIndex.ts | 64 ++++++++ src/visualizations/VisTimeline.vue | 215 ++++++++++++--------------- test/unit/VisTimeline.test.js | 41 +++++ test/unit/timelineIndex.test.node.ts | 61 ++++++++ 5 files changed, 270 insertions(+), 120 deletions(-) create mode 100644 src/util/timelineIndex.ts create mode 100644 test/unit/timelineIndex.test.node.ts diff --git a/src/util/color.ts b/src/util/color.ts index 431f0f4ef..7465f120d 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import { Category, matchString, loadClasses } from './classes'; import Color from 'color'; import * as d3 from 'd3'; -import { IEvent, IBucket } from './interfaces'; +import { IEvent } from './interfaces'; // See here for examples: // https://bl.ocks.org/pstuffa/3393ff2711a53975040077b7453781a9 @@ -140,7 +140,10 @@ export function getTitleAttr(bucket: { type?: string }, e: IEvent) { } } -export function getCategorizationStringFromEvent(bucket: IBucket, e: IEvent): string | null { +export function getCategorizationStringFromEvent( + bucket: { type?: string }, + e: IEvent +): string | null { if (bucket.type == 'currentwindow') { // using linebreak and "m" regex flag to make `$` and `^` work return e.data.app + '\n' + e.data.title; @@ -156,7 +159,7 @@ export function getCategorizationStringFromEvent(bucket: IBucket, e: IEvent): st return null; } -export function getCategoryColorFromEvent(bucket: IBucket, e: IEvent) { +export function getCategoryColorFromEvent(bucket: { type?: string }, e: IEvent) { const categorizationString = getCategorizationStringFromEvent(bucket, e); if (categorizationString !== null) { const allCats = loadClasses(); diff --git a/src/util/timelineIndex.ts b/src/util/timelineIndex.ts new file mode 100644 index 000000000..5797f3415 --- /dev/null +++ b/src/util/timelineIndex.ts @@ -0,0 +1,64 @@ +import { IEvent } from './interfaces'; + +export interface IndexedEvent { + id: string; + bucket: { id: string; type?: string }; + event: IEvent & { id?: number }; + start: number; + end: number; +} + +export function indexTimelineEvents(buckets, filterShort = true) { + const entries: IndexedEvent[] = []; + for (const bucket of buckets) { + (bucket.events || []).forEach((event, index) => { + if (filterShort && event.duration <= 1) return; + const start = new Date(event.timestamp).getTime(); + const end = start + event.duration * 1000; + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + entries.push({ + id: JSON.stringify([bucket.id, event.id ?? [event.timestamp, index]]), + bucket, + event, + start, + end, + }); + }); + } + entries.sort((a, b) => a.start - b.start); + let maxEnd = -Infinity; + const ends = entries.map(e => (maxEnd = Math.max(maxEnd, e.end))); + return { entries, ends, groups: new Set(entries.map(item => item.bucket.id)) }; +} + +// Prefix maximum ends keep events that begin before the viewport but overlap it. +export function visibleTimelineEvents( + index: ReturnType, + start: number, + end: number +): IndexedEvent[] { + let low = 0; + let high = index.ends.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (index.ends[mid] < start) low = mid + 1; + else high = mid; + } + const result: IndexedEvent[] = []; + for (let i = low; i < index.entries.length && index.entries[i].start <= end; i++) { + if (index.entries[i].end >= start) result.push(index.entries[i]); + } + return result; +} + +// DataSet updates notify vis-timeline. Avoid notifications for unchanged items. +export function syncTimelineData(dataset, next: Record[]): void { + const ids = new Set(next.map(item => item.id)); + const removed = dataset.getIds().filter(id => !ids.has(id)); + if (removed.length) dataset.remove(removed); + const changed = next.filter(item => { + const previous = dataset.get(item.id); + return !previous || Object.keys(item).some(key => item[key] !== previous[key]); + }); + if (changed.length) dataset.update(changed); +} diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index 817bd16e2..0c505c39d 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -53,12 +53,18 @@ div#visualization {