From d18d09f42bdf128d13f1ebd48a521abd589e4d81 Mon Sep 17 00:00:00 2001 From: Guracc Date: Thu, 22 Jan 2026 04:35:53 +0100 Subject: [PATCH 1/2] feat(ios): add support for iOS data visualization - buckets: detect 'aw-import-screentime' as android-compatible buckets - queries: preserve 'title' attribute during android event merging - activity: add post-processing to support iOS attributes (mapping 'title' to 'app' for readability) - visualization: add 'Bundle IDs' view for technical detail while keeping 'Top Apps' human-readable --- src/components/SelectableVisualization.vue | 12 +++++++- src/queries.ts | 4 +-- src/stores/activity.ts | 33 ++++++++++++++++++++++ src/stores/buckets.ts | 9 ++++-- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 5d674676..70463b1b 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -33,6 +33,11 @@ div :namefunc="e => e.data.title", :colorfunc="e => e.data.title", with_limit) + div(v-if="type == 'top_bundle_ids'") + aw-summary(:fields="activityStore.window.top_titles", + :namefunc="e => e.data.classname", + :colorfunc="e => e.data.app", + with_limit) div(v-if="type == 'top_domains'") aw-summary(:fields="activityStore.browser.top_domains", :namefunc="e => e.data.$domain", @@ -143,6 +148,7 @@ export default { types: [ 'top_apps', 'top_titles', + 'top_bundle_ids', 'top_domains', 'top_urls', 'top_browser_titles', @@ -189,7 +195,11 @@ export default { }, top_titles: { title: 'Top Window Titles', - available: this.activityStore.window.available, + available: this.activityStore.window.available || this.activityStore.android.available, + }, + top_bundle_ids: { + title: 'Bundle IDs', + available: this.activityStore.window.available || this.activityStore.android.available, }, top_domains: { title: 'Top Browser Domains', diff --git a/src/queries.ts b/src/queries.ts index f0691209..c1413bcc 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -118,7 +118,7 @@ export function canonicalEvents(params: DesktopQueryParams | AndroidQueryParams) // Fetch window/app events `events = flood(query_bucket(find_bucket("${bid_window}")));`, // On Android, merge events to avoid overload of events - isAndroidParams(params) ? 'events = merge_events_by_keys(events, ["app"]);' : '', + isAndroidParams(params) ? 'events = merge_events_by_keys(events, ["app", "title"]);' : '', // Fetch not-afk events isDesktopParams(params) ? `not_afk = flood(query_bucket(find_bucket("${params.bid_afk}"))); @@ -200,7 +200,7 @@ export function appQuery( const code = ` ${canonicalEvents(params)} - title_events = sort_by_duration(merge_events_by_keys(events, ["app", "classname"])); + title_events = sort_by_duration(merge_events_by_keys(events, ["app", "classname", "title"])); 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 6a277467..8759cfb1 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -317,6 +317,39 @@ export const useActivityStore = defineStore('activity', { filter_categories ); const data = await getClient().query(periods, q).catch(this.errorHandler); + + // Post-process for iOS compatibility (swap app <-> title) + const androidBucket = this.buckets.android[0]; + const isIos = androidBucket && androidBucket.startsWith('aw-import-screentime'); + + if (isIos && data && data[0] && data[0].title_events) { + data[0].title_events.forEach((e: IEvent) => { + // iOS events have 'app' (bundleID) and 'title'. We swap them. + // Check if title exists to avoid overwriting with undefined + if (e.data.title) { + const originalApp = e.data.app; + e.data.classname = originalApp; // Bundle ID (e.g. com.google.ios.youtube) + e.data.app = e.data.title; // Human Name (e.g. YouTube) + } + }); + + // Re-aggregate app_events from the modified title_events + const new_app_events_map: Record = {}; + data[0].title_events.forEach((e: IEvent) => { + const app = e.data.app; + if (!new_app_events_map[app]) { + // Clone event to avoid reference issues + new_app_events_map[app] = { ...e, duration: 0, data: { ...e.data } }; + // Ensure we only keep the 'app' key for app_events to match standard structure + new_app_events_map[app].data = { app: app, $category: e.data.$category }; + } + new_app_events_map[app].duration += e.duration; + }); + + // Sort by duration desc + data[0].app_events = _.orderBy(_.values(new_app_events_map), ['duration'], ['desc']); + } + this.query_window_completed(data[0]); }, diff --git a/src/stores/buckets.ts b/src/stores/buckets.ts index 0aa078f0..a05ef67b 100644 --- a/src/stores/buckets.ts +++ b/src/stores/buckets.ts @@ -98,10 +98,15 @@ export const useBucketsStore = defineStore('buckets', { ); }, bucketsAndroid(): (host: string) => string[] { - return host => - this.bucketsByType(host, 'currentwindow').filter((id: string) => + return host => { + const android = this.bucketsByType(host, 'currentwindow').filter((id: string) => id.startsWith('aw-watcher-android') ); + const ios = this.bucketsByType(host, 'app').filter((id: string) => + id.startsWith('aw-import-screentime') + ); + return [...android, ...ios]; + }; }, bucketsEditor(): (host: string) => string[] { // fallback to a bucket with 'unknown' host, if one exists. From 0e48c85a34aa1a32abca5606cd9079fc465eff35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 24 Jan 2026 23:34:19 +0100 Subject: [PATCH 2/2] Apply suggestion from @ErikBjare --- src/components/SelectableVisualization.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 70463b1b..7dabb7b6 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -33,7 +33,7 @@ div :namefunc="e => e.data.title", :colorfunc="e => e.data.title", with_limit) - div(v-if="type == 'top_bundle_ids'") + div(v-if="type == 'top_bundle_ids' && activityStore.android.available") aw-summary(:fields="activityStore.window.top_titles", :namefunc="e => e.data.classname", :colorfunc="e => e.data.app",