From fec07ae2cba5d77d1a333462ed913586aa3e2f89 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 01:18:51 +0000 Subject: [PATCH] fix(widget): revert category grouping to top-level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCategories() in the homescreen widget grouped by the full category path ("Work > Programming") since #231, which regressed the widget in two ways reported in #142: 1. Full path is wrong for a widget — it should show top-level "Work" with all subcategories summed, not "Work > Programming" rows. 2. Inflated uncategorized — ["Uncategorized", "Browser"] and ["Uncategorized", "Games"] became separate rows instead of collapsing into one "Uncategorized" bucket. Revert to grouping by $category[0] only, so every "Uncategorized > *" subcategory rolls back into a single "Uncategorized" row. Keeps parseCategories() internal for the unit tests and updates them to assert the top-level rollup, adding a regression test for the uncategorized collapse. The original per-category discrepancy that motivated #231 needs separate investigation — the full-path change was the wrong fix for it. Closes #142 --- .../widget/CategoryTimeWidgetUpdater.kt | 18 ++++----- .../widget/CategoryTimeWidgetUpdaterTest.kt | 40 +++++++++++++------ 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt index 6dd4a7e5..fc59f757 100644 --- a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt +++ b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt @@ -300,11 +300,11 @@ object CategoryTimeWidgetUpdater { /** * Parse category events from the androidQuery response. * - * Groups by the full category path, matching the Activity view: aw-webui builds - * cat_events with `merge_events_by_keys(events, ["$category"])` and labels them - * with `$category.join(" > ")`. Rolling up to the top-level category here would - * make per-category times disagree with the Activity view even though the totals - * match (ActivityWatch/aw-android#142). + * Groups by the top-level category only (`$category[0]`), so subcategories roll + * up into a single row (e.g. "Work > Programming" and "Work > Planning" both + * contribute to "Work") and every "Uncategorized > *" subcategory collapses into + * a single "Uncategorized" row. This matches the widget's compact display; the + * Activity view's finer full-path grouping lives elsewhere. */ internal fun parseCategories(jsonResult: String): List> { val categories = mutableMapOf() @@ -325,11 +325,11 @@ object CategoryTimeWidgetUpdater { val categoryArray = data.optJSONArray("\$category") if (categoryArray == null || categoryArray.length() == 0) continue - // Full category path, e.g. ["Work", "Programming"] -> "Work > Programming" - val categoryPath = (0 until categoryArray.length()) - .joinToString(" > ") { categoryArray.optString(it, "Uncategorized") } + // Get top-level category (first element only) + val topLevelCategory = categoryArray.optString(0, "Uncategorized") - categories[categoryPath] = categories.getOrDefault(categoryPath, 0.0) + duration + // Aggregate time by top-level category + categories[topLevelCategory] = categories.getOrDefault(topLevelCategory, 0.0) + duration } } catch (e: Exception) { Log.e(TAG, "Error parsing categories", e) diff --git a/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt b/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt index 17ce9a76..d298ee28 100644 --- a/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt @@ -14,13 +14,12 @@ class CategoryTimeWidgetUpdaterTest { """[{"cat_events":[${events.joinToString(",")}]}]""" /** - * Regression for ActivityWatch/aw-android#142: the widget rolled every event up to its - * top-level category, so subcategories collapsed together and per-category times - * disagreed with the Activity view (which groups by the full `$category` path) even - * though the totals matched. + * The widget groups by top-level category ($category[0]), so subcategories collapse + * into a single row. This is the widget's compact display; see + * ActivityWatch/aw-android#142 for the discussion. */ @Test - fun parseCategories_keepsSubcategoriesSeparate() { + fun parseCategories_collapsesSubcategoriesIntoTopLevel() { val result = CategoryTimeWidgetUpdater.parseCategories( response( catEvent(60.0, "Work", "Programming"), @@ -28,10 +27,7 @@ class CategoryTimeWidgetUpdaterTest { ) ) - assertEquals( - listOf("Work > Programming" to 60_000L, "Work > Planning" to 30_000L), - result - ) + assertEquals(listOf("Work" to 90_000L), result) } @Test @@ -48,15 +44,15 @@ class CategoryTimeWidgetUpdaterTest { } @Test - fun parseCategories_mergesRepeatsOfTheSamePath() { + fun parseCategories_mergesRepeatsOfTheSameTopLevel() { val result = CategoryTimeWidgetUpdater.parseCategories( response( catEvent(60.0, "Work", "Programming"), - catEvent(15.0, "Work", "Programming") + catEvent(15.0, "Work", "Planning") ) ) - assertEquals(listOf("Work > Programming" to 75_000L), result) + assertEquals(listOf("Work" to 75_000L), result) } @Test @@ -70,11 +66,29 @@ class CategoryTimeWidgetUpdaterTest { ) assertEquals( - listOf("Work > Programming", "Comms", "Media > Video"), + listOf("Work", "Comms", "Media"), result.map { it.first } ) } + /** + * Regression for ActivityWatch/aw-android#142: with full-path grouping, + * ["Uncategorized", "Browser"] and ["Uncategorized", "Games"] became separate rows. + * Top-level grouping collapses all "Uncategorized > *" subcategories back into a + * single "Uncategorized" row. + */ + @Test + fun parseCategories_collapsesUncategorizedSubcategories() { + val result = CategoryTimeWidgetUpdater.parseCategories( + response( + catEvent(20.0, "Uncategorized", "Browser"), + catEvent(10.0, "Uncategorized", "Games") + ) + ) + + assertEquals(listOf("Uncategorized" to 30_000L), result) + } + @Test fun parseCategories_keepsSingleLevelCategoriesUnprefixed() { val result = CategoryTimeWidgetUpdater.parseCategories(