Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"]);'
: '',
Comment thread
TimeToBuildBob marked this conversation as resolved.
// Fetch not-afk events
isDesktopParams(params)
? `not_afk = flood(${queryBucket(params.bid_afk)});
Expand Down Expand Up @@ -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"]));

Expand Down
18 changes: 11 additions & 7 deletions src/stores/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -571,6 +574,7 @@ export const useActivityStore = defineStore('activity', {
...(isAndroid
? {
bid_android: iosOrAndroidBucket,
isIos: isIosForCategory,
}
: {
bid_afk: this.buckets.afk[0],
Expand Down
7 changes: 6 additions & 1 deletion src/views/settings/CategoryBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 61 additions & 1 deletion test/unit/queries.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"])');
});
});
Loading