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..3ab694cc7 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. @@ -551,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 @@ -571,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 9b15c23ef..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, 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. @@ -277,3 +277,63 @@ 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"'); + }); +}); + +// 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"])'); + }); +});