diff --git a/src/views/settings/CategoryBuilder.vue b/src/views/settings/CategoryBuilder.vue index 8c04ae0e1..91c404758 100644 --- a/src/views/settings/CategoryBuilder.vue +++ b/src/views/settings/CategoryBuilder.vue @@ -21,14 +21,14 @@ div div small Range: {{ queryOptions.start }} - {{ queryOptions.stop }} div.flex-grow-0 - b-button(variant="outline-dark" @click="show_options = !show_options" size="sm") + b-button(variant="outline-dark" @click="show_options = !show_options" size="sm" :disabled="!bucketsReady") span(v-if="!show_options") Show options span(v-else) Hide options div(v-if="show_options") hr h4 Options - aw-query-options(v-model="queryOptions") + aw-query-options(:query-options="queryOptions" @input="queryOptions = $event") hr @@ -36,9 +36,16 @@ div div(v-if="loading") b-spinner.mr-2(small) span.text-muted Loading... + div(v-else-if="loadError" role="alert") + p.text-danger {{ loadError }} + b-button(size="sm" variant="outline-primary" @click="fetchWords") Retry + div(v-else-if="noActivityBuckets") + p.text-muted.mb-0 + | No activity data is available for this host. + | Select another hostname under #[b Show options]. div(v-else-if="hostnameEmptyKind === 'no-hosts'") p.text-muted.mb-0 - | No host with window/AFK buckets is available. Install + | No host with activity buckets is available. Install | #[a(href="https://docs.activitywatch.net/en/latest/watchers.html") a watcher] | to start collecting data. div(v-else-if="hostnameEmptyKind === 'hostname-unselected'") @@ -137,6 +144,10 @@ export default { data() { return { loading: true, + loadError: '', + bucketsReady: false, + noActivityBuckets: false, + requestId: 0, categoryStore: useCategoryStore(), @@ -150,6 +161,7 @@ export default { show_options: false, queryOptions: { hostname: '', + filter_afk: true, start: moment().subtract(1, 'day').format('YYYY-MM-DD'), stop: moment().add(1, 'day').format('YYYY-MM-DD'), }, @@ -157,7 +169,7 @@ export default { // TODO: Support inspecting a different category than Uncategorized (e.g. to make some category more precise) category: ['Uncategorized'], - words: {}, + words: new Map(), showing_events: [], // TODO: load from settings @@ -213,87 +225,84 @@ export default { }, }, async mounted() { - // Make sure we don't have stale unsaved changes in categoryStore - const bucketsStore = useBucketsStore(); - await bucketsStore.ensureLoaded(); - await this.categoryStore.load(); - const sole = selectSoleKnownHostname(bucketsStore.hosts); - if (sole && !this.queryOptions.hostname) { - this.$set(this.queryOptions, 'hostname', sole); - // Deep watch on queryOptions calls fetchWords. - } else { - await this.fetchWords(); - } + await this.fetchWords(); + }, + beforeDestroy() { + // Ignore results from requests that outlive this view. + this.requestId++; }, methods: { async fetchWords() { + const requestId = ++this.requestId; + const options = { ...this.queryOptions }; this.loading = true; - // Reset pagination so the user sees the top of the new ranking - // after every requery. + this.loadError = ''; + this.noActivityBuckets = false; this.visible_count = this.page_size; - if (!this.queryOptions.hostname) { - // Auto-select only when there is exactly one real hostname. Several - // known hosts (or only "unknown") stay unset so the empty-state copy - // can point at Show options / the hostname picker instead of - // silently querying the first device. - const sole = selectSoleKnownHostname(useBucketsStore().hosts); - if (sole) { - this.$set(this.queryOptions, 'hostname', sole); - // Deep watch re-enters fetchWords with hostname set. + this.showing_events = []; + try { + const bucketsStore = useBucketsStore(); + await bucketsStore.ensureLoaded(); + if (requestId !== this.requestId) return; + this.bucketsReady = true; + + if (!options.hostname) { + const hosts = bucketsStore.hosts.filter(Boolean); + // Keep the explicit choice for multiple known hosts, but allow legacy + // Android installations whose only hostname is "unknown". + const sole = selectSoleKnownHostname(hosts) || (hosts.length === 1 && hosts[0]); + if (sole) { + this.queryOptions.hostname = sole; + // The watcher starts a new request with the selected hostname. + } return; } - this.loading = false; - return; - } - await this.categoryStore.load(); - const awclient = getClient(); - // Hosts without a window/AFK bucket pair (Android, iOS/ScreenTime import) - // need to be queried through their android-style bucket instead, mirroring - // query_android in the activity store (which also prefers the ScreenTime - // bucket when both exist for a host). - const bucketsStore = useBucketsStore(); - const hostname = this.queryOptions.hostname; - const windowAvail = - bucketsStore.bucketsWindow(hostname).length > 0 && - bucketsStore.bucketsAFK(hostname).length > 0; - const androidBuckets = bucketsStore.bucketsAndroid(hostname); - let bucketParams; - if (!windowAvail && androidBuckets.length > 0) { - const screentimeBucket = androidBuckets.find(id => id.startsWith('aw-import-screentime')); - bucketParams = { - bid_android: screentimeBucket || androidBuckets[0], - // ScreenTime (iOS) events carry a "title" key; aw-watcher-android events do not. - // Pass isIos so canonicalEvents uses the correct merge keys and titles are preserved. - isIos: !!screentimeBucket, - }; - } else { - bucketParams = { - bid_window: 'aw-watcher-window_' + hostname, - bid_afk: 'aw-watcher-afk_' + hostname, - filter_afk: this.queryOptions.filter_afk, - }; - } - - const query = - canonicalEvents({ - ...bucketParams, - categories: this.categoryStore.classes_for_query, - filter_categories: [this.category], - }) + 'RETURN = limit_events(sort_by_duration(events), 1000);'; - const data = await awclient.query( - [ - { - start: new Date(this.queryOptions.start), - end: new Date(this.queryOptions.stop), - }, - ], - query.split('\n') - ); + const windowBuckets = bucketsStore.bucketsWindow(options.hostname); + const afkBuckets = bucketsStore.bucketsAFK(options.hostname); + const windowAvail = windowBuckets.length > 0 && afkBuckets.length > 0; + const androidBuckets = bucketsStore.bucketsAndroid(options.hostname); + let bucketParams; + if (windowAvail) { + bucketParams = { + bid_window: windowBuckets[0], + bid_afk: afkBuckets[0], + filter_afk: options.filter_afk, + }; + } else if (androidBuckets.length > 0) { + const screentimeBucket = androidBuckets.find(id => id.startsWith('aw-import-screentime')); + bucketParams = { + bid_android: screentimeBucket || androidBuckets[0], + // ScreenTime events have titles; Android events do not. + isIos: !!screentimeBucket, + }; + } else { + this.noActivityBuckets = true; + return; + } - const events = data[0]; - this.words = findCommonPhrases(events, this.ignored_words); - this.loading = false; + // Make sure we don't query with stale unsaved category changes. + await this.categoryStore.load(); + if (requestId !== this.requestId) return; + const query = + canonicalEvents({ + ...bucketParams, + categories: this.categoryStore.classes_for_query, + filter_categories: [this.category], + }) + 'RETURN = limit_events(sort_by_duration(events), 1000);'; + const data = await getClient().query( + [{ start: new Date(options.start), end: new Date(options.stop) }], + query.split('\n') + ); + if (requestId !== this.requestId) return; + this.words = findCommonPhrases(data[0], this.ignored_words); + } catch (error) { + if (requestId !== this.requestId) return; + console.error('Could not load category builder words', error); + this.loadError = 'Could not load uncategorized words. Please try again.'; + } finally { + if (requestId === this.requestId) this.loading = false; + } }, showEvents(word) { // If already showing events, hide them and return diff --git a/test/unit/CategoryBuilder.test.js b/test/unit/CategoryBuilder.test.js new file mode 100644 index 000000000..fc74bbccf --- /dev/null +++ b/test/unit/CategoryBuilder.test.js @@ -0,0 +1,208 @@ +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import { createTestingPinia } from '@pinia/testing'; +import { PiniaVuePlugin } from 'pinia'; +import CategoryBuilder from '~/views/settings/CategoryBuilder.vue'; +import QueryOptions from '~/components/QueryOptions.vue'; +import { useBucketsStore } from '~/stores/buckets'; +import { useCategoryStore } from '~/stores/categories'; +import { getClient } from '~/util/awclient'; + +jest.mock('~/util/awclient', () => ({ getClient: jest.fn() })); + +const localVue = createLocalVue(); +localVue.use(PiniaVuePlugin); +localVue.component('aw-query-options', QueryOptions); +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const androidBucket = { + id: 'aw-watcher-android-legacy', + hostname: 'unknown', + type: 'currentwindow', + data: {}, +}; +const events = app => [[{ data: { app }, duration: 120 }]]; +const deferred = () => { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe('CategoryBuilder loading', () => { + let wrapper, pinia, buckets, categories, query; + + beforeEach(() => { + pinia = createTestingPinia({ createSpy: jest.fn }); + buckets = useBucketsStore(); + buckets.buckets = [androidBucket]; + categories = useCategoryStore(); + query = jest.fn().mockResolvedValue(events('Firefox')); + getClient.mockReturnValue({ query }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + wrapper?.destroy(); + jest.restoreAllMocks(); + }); + + function mountBuilder(realOptions = false) { + wrapper = (realOptions ? mount : shallowMount)(CategoryBuilder, { + localVue, + pinia, + stubs: { + 'aw-query-options': !realOptions, + 'b-button': true, + 'b-spinner': true, + 'b-modal': true, + 'b-form': true, + 'b-form-group': true, + 'b-form-select': true, + 'b-form-select-option': true, + 'b-form-input': true, + 'b-form-checkbox': true, + }, + }); + return wrapper; + } + + test('loads an Android-only unknown host without opening options or querying AFK', async () => { + mountBuilder(); + await flush(); + expect(wrapper.vm.queryOptions.hostname).toBe('unknown'); + expect(query).toHaveBeenCalledTimes(1); + const code = query.mock.calls[0][1].join('\n'); + expect(code).toContain(androidBucket.id); + expect(code).not.toContain('aw-watcher-afk'); + expect(wrapper.text()).toContain('Firefox'); + expect(wrapper.text()).not.toContain('Loading...'); + }); + + test.each(['buckets', 'categories', 'query'])( + 'recovers from a %s failure using Retry', + async stage => { + // A known host also exercises failures independently of unknown-host selection. + buckets.buckets = [{ ...androidBucket, hostname: 'phone' }]; + const action = { buckets: buckets.ensureLoaded, categories: categories.load, query }[stage]; + action.mockRejectedValueOnce(new Error('Server unavailable')); + mountBuilder(); + await flush(); + expect(wrapper.text()).not.toContain('Loading...'); + expect(wrapper.find('[role="alert"]').text()).toContain('Could not load'); + const retry = wrapper.findAll('b-button-stub').wrappers.find(w => w.text() === 'Retry'); + await retry.vm.$emit('click'); + await flush(); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Firefox'); + } + ); + + test('stops loading when there are no buckets', async () => { + buckets.buckets = []; + mountBuilder(); + await flush(); + expect(query).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('No host'); + expect(wrapper.text()).not.toContain('Loading...'); + }); + + test('waits for a choice when multiple known hosts exist', async () => { + buckets.buckets = ['phone', 'tablet'].map(hostname => ({ ...androidBucket, hostname })); + mountBuilder(); + await flush(); + expect(query).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Select a hostname'); + expect(wrapper.text()).not.toContain('Loading...'); + }); + + test('does not query nonexistent desktop buckets for an unsupported host', async () => { + buckets.buckets = [{ ...androidBucket, id: 'browser-only', type: 'web.tab.current' }]; + mountBuilder(); + await flush(); + expect(query).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('No activity data is available for this host.'); + expect(wrapper.text()).not.toContain('Loading...'); + }); + + test('uses the actual desktop bucket IDs and enables AFK filtering by default', async () => { + buckets.buckets = [ + { ...androidBucket, id: 'custom-window', hostname: 'desktop' }, + { ...androidBucket, id: 'custom-afk', hostname: 'desktop', type: 'afkstatus' }, + ]; + mountBuilder(); + await flush(); + const code = query.mock.calls[0][1].join('\n'); + expect(code).toContain('custom-window'); + expect(code).toContain('custom-afk'); + expect(code).toContain('filter_period_intersect'); + expect(wrapper.text()).toContain('Firefox'); + }); + + test('prefers ScreenTime and preserves its title merge key', async () => { + buckets.buckets.push({ ...androidBucket, id: 'aw-import-screentime-phone', type: 'app' }); + mountBuilder(); + await flush(); + const code = query.mock.calls[0][1].join('\n'); + expect(code).toContain('aw-import-screentime-phone'); + expect(code).not.toContain(androidBucket.id); + expect(code).toContain('merge_events_by_keys(events, ["app", "title"])'); + }); + + test('preserves the chosen host and dates when reopening options', async () => { + buckets.buckets = ['phone', 'tablet'].map(hostname => ({ ...androidBucket, hostname })); + mountBuilder(true); + await flush(); + await wrapper.setData({ + queryOptions: { + hostname: 'tablet', + start: '2026-01-01', + stop: '2026-01-02', + filter_afk: false, + }, + }); + await flush(); + const chosen = { ...wrapper.vm.queryOptions }; + for (let i = 0; i < 2; i++) { + await wrapper.setData({ show_options: true }); + await flush(); + expect(wrapper.findComponent(QueryOptions).vm.queryOptionsData).toEqual(chosen); + expect(wrapper.vm.queryOptions).toEqual(chosen); + await wrapper.setData({ show_options: false }); + } + }); + + test('an old completion cannot hide the spinner for a newer pending query', async () => { + const old = deferred(); + const current = deferred(); + query.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + mountBuilder(); + await flush(); + wrapper.vm.queryOptions.start = '2026-01-01'; + await flush(); + old.resolve(events('StaleApp')); + await flush(); + expect(wrapper.text()).toContain('Loading...'); + current.resolve(events('Firefox')); + await flush(); + expect(wrapper.text()).toContain('Firefox'); + expect(wrapper.text()).not.toContain('Loading...'); + }); + + test.each(['resolve', 'reject'])('ignores an old query that later %ss', async completion => { + buckets.buckets = [{ ...androidBucket, hostname: 'phone' }]; + const old = deferred(); + query.mockReturnValueOnce(old.promise); + mountBuilder(); + await flush(); + wrapper.vm.queryOptions.start = '2026-01-01'; + await flush(); + expect(wrapper.text()).toContain('Firefox'); + if (completion === 'resolve') old.resolve(events('StaleApp')); + else old.reject(new Error('Old query failed')); + await flush(); + expect(wrapper.text()).toContain('Firefox'); + expect(wrapper.text()).not.toContain('StaleApp'); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + }); +});