From c0da32b84005598718b06e275313f66287003ed1 Mon Sep 17 00:00:00 2001 From: TimeToBuildBob Date: Wed, 2 Sep 2026 22:27:33 +0000 Subject: [PATCH 1/2] fix(android): don't merge on 'title' key for aw-watcher-android buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aw-watcher-android events carry 'app', 'package', 'classname' but NOT 'title'. merge_events_by_keys skips events missing any requested key, so the 'title' key added in bf0fc84 (for iOS ScreenTime compatibility) silently collapsed all Android Activity data to 0s. Fixes by gating the 'title' merge key on isIos (new optional field on AndroidQueryParams). The appQuery() caller in activity.ts already has iosBucket detection; it now passes isIos=true only for ScreenTime buckets. Verified against live bucket data (ActivityWatch/aw-webui#959): merge on ['app', 'title'] → 0.0 s merge on ['app'] → 4878.8 s (actual data) Regression from bf0fc84 (2026-07-24, iOS ScreenTime patch #917). Not present in v0.14.0b2 (pins aw-webui 749585f, pre-regression). Affects master builds and any aw-webui >= bf0fc84 rendering Android buckets. Closes ActivityWatch/aw-webui#959 Git-Session-Id: b5092fb7-a3f0-5a15-8da3-fd21d8d7f2a8 --- src/queries.ts | 28 ++++++++++++++++++++++++---- src/stores/activity.ts | 7 +++---- test/unit/queries.test.node.ts | 30 +++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/queries.ts b/src/queries.ts index 10b51e6d8..08f6e9799 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -70,6 +70,11 @@ interface DesktopQueryParams extends BaseQueryParams { interface AndroidQueryParams extends BaseQueryParams { bid_android: string; + /** True when the bucket is an aw-import-screentime (iOS) bucket. + * ScreenTime events carry a "title" key; aw-watcher-android events do not. + * Keep false (the default) for regular Android watcher buckets so that + * merge_events_by_keys does not drop every event due to a missing key. */ + isIos?: boolean; } interface MultiQueryParams extends BaseQueryParams { @@ -154,8 +159,17 @@ export function canonicalEvents(params: DesktopQueryParams | AndroidQueryParams) return [ // Fetch window/app events `events = flood(${queryBucket(bid_window)});`, - // On Android, merge events to avoid overload of events - isAndroidParams(params) ? 'events = merge_events_by_keys(events, ["app", "title"]);' : '', + // On Android, merge events to avoid overload of events. + // aw-watcher-android events carry "app"/"package"/"classname" but NOT "title"; + // merge_events_by_keys drops events missing any requested key, so including + // "title" here collapses all Android watcher events to zero duration + // (regression introduced in bf0fc84 to support iOS ScreenTime, which DOES + // carry "title"). Only add "title" when the bucket is an iOS ScreenTime import. + isAndroidParams(params) + ? params.isIos + ? 'events = merge_events_by_keys(events, ["app", "title"]);' + : 'events = merge_events_by_keys(events, ["app"]);' + : '', // Fetch not-afk events isDesktopParams(params) ? `not_afk = flood(${queryBucket(params.bid_afk)}); @@ -225,19 +239,25 @@ const default_limit = 100; // Hardcoded limit per group export function appQuery( appbucket: string, categories: Category[], - filter_categories: string[][] + filter_categories: string[][], + isIos = false ): string[] { appbucket = escape_doublequote(appbucket); const params: AndroidQueryParams = { bid_android: appbucket, categories, filter_categories, + isIos, }; + // aw-watcher-android events have no "title" key; only ScreenTime (iOS) does. + // Merging on "title" when it is absent drops every event (see canonicalEvents). + const titleMergeKeys = isIos ? '["app", "classname", "title"]' : '["app", "classname"]'; + const code = ` ${canonicalEvents(params)} - title_events = sort_by_duration(merge_events_by_keys(events, ["app", "classname", "title"])); + title_events = sort_by_duration(merge_events_by_keys(events, ${titleMergeKeys})); app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"])); cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"])); diff --git a/src/stores/activity.ts b/src/stores/activity.ts index c9816ae17..5f930a90f 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -328,16 +328,15 @@ export const useActivityStore = defineStore('activity', { ); const selectedBucket = iosBucket || this.buckets.android[0]; + const isIos = !!iosBucket; const q = queries.appQuery( selectedBucket, categoryStore.classes_for_query, - filter_categories + filter_categories, + isIos ); const data = await getClient().query(periods, q).catch(this.errorHandler); - // Post-process for iOS compatibility (swap app <-> title) - const isIos = !!iosBucket; - if (isIos && data && data[0] && data[0].title_events) { // Build bundle ID → human name lookup from title_events before modifying them. // title_events has 'app' = bundle ID and 'title' = human-readable name. diff --git a/test/unit/queries.test.node.ts b/test/unit/queries.test.node.ts index 9b15c23ef..15a7cb5a3 100644 --- a/test/unit/queries.test.node.ts +++ b/test/unit/queries.test.node.ts @@ -55,7 +55,7 @@ * (Flatpak app ID retained: 'one.ablaze.floorp') */ -import { browser_appname_regex, querystr_to_array } from '~/queries'; +import { browser_appname_regex, appQuery, querystr_to_array } from '~/queries'; // Convert ActivityWatch (?i) patterns to JS RegExp with i flag for testing. // AW server uses Python-style (?i) inline flag; JS uses RegExp 'i' flag instead. @@ -277,3 +277,31 @@ describe('querystr_to_array', () => { expect(result).toHaveLength(2); }); }); + +// Regression guard for ActivityWatch/aw-webui#959: +// aw-watcher-android events carry "app"/"package"/"classname" but NOT "title". +// merge_events_by_keys skips events missing any requested key, so including +// "title" in the Android (non-iOS) path collapsed all activity to 0s. +describe('appQuery merge key regression', () => { + const categories: any[] = []; + const filter_categories: string[][] = []; + + test('Android watcher path does NOT merge on "title"', () => { + const q = appQuery('aw-watcher-android_device', categories, filter_categories, false); + const joined = q.join('\n'); + // The canonical-events step must not reference "title" for the non-iOS path + expect(joined).not.toContain('merge_events_by_keys(events, ["app", "title"])'); + // The title_events step must merge on "classname" but not "title" + expect(joined).toContain('"app", "classname"'); + expect(joined).not.toContain('"app", "classname", "title"'); + }); + + test('iOS ScreenTime path DOES merge on "title"', () => { + const q = appQuery('aw-import-screentime_device', categories, filter_categories, true); + const joined = q.join('\n'); + // The canonical-events step must reference "title" for the iOS path + expect(joined).toContain('merge_events_by_keys(events, ["app", "title"])'); + // The title_events step must merge on "title" for iOS + expect(joined).toContain('"app", "classname", "title"'); + }); +}); From 08b925c3334a42f8caeddac039ec60e04146a8a1 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 2 Sep 2026 23:21:34 +0000 Subject: [PATCH 2/2] fix(android): pass isIos to categoryQuery and CategoryBuilder ScreenTime callers The primary appQuery path was fixed in the initial commit, but two callers still omitted isIos when passing an aw-import-screentime bucket to categoryQuery (via query_category_time_by_period) and canonicalEvents (via CategoryBuilder). Without isIos=true, canonicalEvents merges iOS events only by ['app'] instead of ['app', 'title'], losing title distinctions before category assignment and phrase extraction. Fix: - activity.ts query_category_time_by_period: detect iosBucketForCategory and pass isIos: !!iosBucketForCategory alongside bid_android in the categoryQuery params - CategoryBuilder.vue: pass isIos: !!screentimeBucket in the bucketParams spread Tests: add categoryQuery merge key regression describe block covering both Android (no title) and iOS (with title) paths. Git-Session-Id: b5092fb7-a3f0-5a15-8da3-fd21d8d7f2a8 --- src/stores/activity.ts | 11 ++++++--- src/views/settings/CategoryBuilder.vue | 7 +++++- test/unit/queries.test.node.ts | 34 +++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/stores/activity.ts b/src/stores/activity.ts index 5f930a90f..3ab694cc7 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -550,10 +550,14 @@ export const useActivityStore = defineStore('activity', { } // Prefer ScreenTime bucket over Android watcher for consistency with query_android - const iosOrAndroidBucket = - this.buckets.android.find((id: string) => id.startsWith('aw-import-screentime')) || - this.buckets.android[0]; + 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 @@ -570,6 +574,7 @@ export const useActivityStore = defineStore('activity', { ...(isAndroid ? { bid_android: iosOrAndroidBucket, + isIos: isIosForCategory, } : { bid_afk: this.buckets.afk[0], diff --git a/src/views/settings/CategoryBuilder.vue b/src/views/settings/CategoryBuilder.vue index 77d25a9b4..8c04ae0e1 100644 --- a/src/views/settings/CategoryBuilder.vue +++ b/src/views/settings/CategoryBuilder.vue @@ -261,7 +261,12 @@ export default { let bucketParams; if (!windowAvail && androidBuckets.length > 0) { const screentimeBucket = androidBuckets.find(id => id.startsWith('aw-import-screentime')); - bucketParams = { bid_android: screentimeBucket || androidBuckets[0] }; + 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, diff --git a/test/unit/queries.test.node.ts b/test/unit/queries.test.node.ts index 15a7cb5a3..0947b1a28 100644 --- a/test/unit/queries.test.node.ts +++ b/test/unit/queries.test.node.ts @@ -55,7 +55,7 @@ * (Flatpak app ID retained: 'one.ablaze.floorp') */ -import { browser_appname_regex, appQuery, querystr_to_array } from '~/queries'; +import { browser_appname_regex, appQuery, categoryQuery, querystr_to_array } from '~/queries'; // Convert ActivityWatch (?i) patterns to JS RegExp with i flag for testing. // AW server uses Python-style (?i) inline flag; JS uses RegExp 'i' flag instead. @@ -305,3 +305,35 @@ describe('appQuery merge key regression', () => { expect(joined).toContain('"app", "classname", "title"'); }); }); + +// Regression guard for the category/Category-Builder ScreenTime caller paths: +// query_category_time_by_period and CategoryBuilder.vue both call categoryQuery/ +// canonicalEvents with bid_android. They were missing isIos, causing ScreenTime +// buckets to lose title distinctions before category assignment. +describe('categoryQuery merge key regression (ScreenTime callers)', () => { + const categories: any[] = []; + const filter_categories: string[][] = []; + + test('Android watcher bucket does NOT merge on "title" in category query', () => { + const q = categoryQuery({ + bid_android: 'aw-watcher-android_device', + categories, + filter_categories, + filter_afk: false, + }); + const joined = q.join('\n'); + expect(joined).not.toContain('merge_events_by_keys(events, ["app", "title"])'); + }); + + test('iOS ScreenTime bucket DOES merge on "title" in category query when isIos=true', () => { + const q = categoryQuery({ + bid_android: 'aw-import-screentime_device', + isIos: true, + categories, + filter_categories, + filter_afk: false, + }); + const joined = q.join('\n'); + expect(joined).toContain('merge_events_by_keys(events, ["app", "title"])'); + }); +});