From 746672346ad85ad9ea45e6328eb1c05306e4fcbf Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 15 Apr 2026 14:29:00 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(categorization):=20add=20category=20se?= =?UTF-8?q?ts=20=E2=80=94=20named=20rule=20profiles=20with=20multi-set=20c?= =?UTF-8?q?ombining?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements category sets from ActivityWatch/aw-webui#369 (Erik's 2022 WIP) with an extended data model supporting priority-ordered combining of multiple active sets. ## What's new - **CategorySet type** (`src/util/classes.ts`): `{ id: string; categories: Category[] }` - **mergeCategorySets()**: merges sets in priority order (first = highest); more specific rules still win within a set, set order breaks ties - **Settings store**: new `category_sets: CategorySet[]` + `active_set_ids: string[]` fields persisted alongside the legacy `classes` field (backwards compatible) - **Categories store**: full CRUD for sets (`createSet`, `deleteSet`, `switchToSet`, `setActiveSets`, `renameSet`); `classes` always reflects the merged effective categories from all active sets; migration path for existing users (wraps legacy `classes` in a "default" set on first load) - **CategorizationSettings UI**: set switcher dropdown, New/Delete buttons, per-set export ## Design notes - `active_set_ids: string[]` data model supports combining; UI v1 exposes single-set switching — multi-set combining UI can follow in a subsequent PR - Legacy `classes` field kept in sync for backwards compat with external readers - `save()` syncs edits back to the primary active set before persisting all sets - Import handles both legacy `{ categories: [] }` and new `{ id, categories }` formats Closes / supersedes ActivityWatch/aw-webui#369 --- src/stores/categories.ts | 155 ++++++++++++++++-- src/stores/settings.ts | 17 +- src/util/classes.ts | 67 ++++++++ src/views/settings/CategorizationSettings.vue | 107 +++++++++++- 4 files changed, 325 insertions(+), 21 deletions(-) diff --git a/src/stores/categories.ts b/src/stores/categories.ts index feb4ac88..941bd58b 100644 --- a/src/stores/categories.ts +++ b/src/stores/categories.ts @@ -2,12 +2,16 @@ import _ from 'lodash'; import { saveClasses, loadClasses, + saveCategories, + loadCategories, cleanCategory, defaultCategories, build_category_hierarchy, createMissingParents, + mergeCategorySets, annotate, Category, + CategorySet, Rule, } from '~/util/classes'; import { getColorFromCategory } from '~/util/color'; @@ -16,6 +20,10 @@ import { defineStore } from 'pinia'; interface State { classes: Category[]; classes_unsaved_changes: boolean; + // Category sets — named collections of category rules + category_sets: CategorySet[]; + // Ordered list of active set IDs; first entry has highest priority when merging + active_set_ids: string[]; } function getScoreFromCategory(c: Category, allCats: Category[]): number { @@ -45,10 +53,34 @@ function normalizeSegments(cat: string[]): string[] { }); } +function assignIds(classes: Category[]): Category[] { + let i = 0; + return classes.map(c => Object.assign(c, { id: i++ })); +} + +/** Recompute the effective `classes` list from the provided active sets. */ +function computeEffectiveClasses(categorySets: CategorySet[], activeSetIds: string[]): Category[] { + const activeSets = categorySets.filter(s => activeSetIds.includes(s.id)); + const merged = mergeCategorySets(activeSets); + return assignIds(createMissingParents(merged)); +} + +/** Copy current effective classes back into the primary active set (no-op if no sets). */ +function syncToPrimarySet(state: State) { + if (state.active_set_ids.length === 0 || state.category_sets.length === 0) return; + const primaryId = state.active_set_ids[0]; + const primarySet = state.category_sets.find(s => s.id === primaryId); + if (primarySet) { + primarySet.categories = state.classes.map(cleanCategory); + } +} + export const useCategoryStore = defineStore('categories', { state: (): State => ({ classes: [], classes_unsaved_changes: false, + category_sets: [], + active_set_ids: ['default'], }), // getters @@ -140,22 +172,122 @@ export const useCategoryStore = defineStore('categories', { }, actions: { - load(this: State, classes: Category[] = null) { - if (classes === null) { - classes = loadClasses(); + /** + * Load categories into the store. + * + * When called with an explicit `classes` array (e.g. in tests), those categories are loaded + * directly without touching category sets. + * + * When called without arguments, loads from the settings store — including multi-set support. + * Falls back to the legacy flat `classes` setting if no sets are defined yet. + */ + load(this: State, classes?: Category[]) { + if (classes !== undefined) { + // Explicit categories provided (test / programmatic path) + classes = createMissingParents(classes); + this.classes = assignIds(classes); + this.classes_unsaved_changes = false; + return; } - classes = createMissingParents(classes); - let i = 0; - this.classes = classes.map(c => Object.assign(c, { id: i++ })); + // Load sets from settings store + const { sets, activeIds } = loadCategories(); + this.category_sets = sets; + this.active_set_ids = activeIds; + + // Compute effective classes from active sets (merged in priority order) + this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids); this.classes_unsaved_changes = false; }, - save() { - const r = saveClasses(this.classes); + + save(this: State) { + // Sync current classes back to the primary active set before persisting + syncToPrimarySet(this); + saveCategories(this.category_sets, this.active_set_ids); + // Also update legacy flat classes field for backwards compatibility + saveClasses(this.classes); this.classes_unsaved_changes = false; - return r; }, + // ── Category set management ────────────────────────────────────────────── + + /** + * Create a new empty category set. + * The new set is NOT activated automatically — call switchToSet() after if needed. + */ + createSet(this: State, id: string) { + if (this.category_sets.find(s => s.id === id)) { + console.warn('Category set already exists:', id); + return; + } + this.category_sets.push({ id, categories: [] }); + }, + + /** + * Delete a category set by ID. + * The last remaining set cannot be deleted. + */ + deleteSet(this: State, id: string) { + if (this.category_sets.length <= 1) { + console.warn('Cannot delete the last category set'); + return; + } + this.category_sets = this.category_sets.filter(s => s.id !== id); + this.active_set_ids = this.active_set_ids.filter(aid => aid !== id); + if (this.active_set_ids.length === 0) { + this.active_set_ids = [this.category_sets[0].id]; + } + this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids); + this.classes_unsaved_changes = true; + }, + + /** + * Switch to a single active set by ID. + * Saves the current classes to the previously active set first. + */ + switchToSet(this: State, id: string) { + if (!this.category_sets.find(s => s.id === id)) { + console.warn('Category set not found:', id); + return; + } + syncToPrimarySet(this); + this.active_set_ids = [id]; + this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids); + this.classes_unsaved_changes = false; + }, + + /** + * Set multiple active sets (combined in priority order). + * The first ID in the list is the primary set (edits go here). + */ + setActiveSets(this: State, ids: string[]) { + syncToPrimarySet(this); + this.active_set_ids = ids; + this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids); + this.classes_unsaved_changes = true; + }, + + /** + * Rename a category set. + */ + renameSet(this: State, oldId: string, newId: string) { + if (newId === oldId) return; + if (this.category_sets.find(s => s.id === newId)) { + console.warn('A set with that name already exists:', newId); + return; + } + const set = this.category_sets.find(s => s.id === oldId); + if (!set) { + console.warn('Category set not found:', oldId); + return; + } + set.id = newId; + this.active_set_ids = this.active_set_ids.map(id => (id === oldId ? newId : id)); + this.classes_unsaved_changes = true; + }, + + // ── Legacy mutations (operate on the effective `classes` list) ─────────── + // mutations import(this: State, classes: Category[]) { let i = 0; @@ -212,10 +344,7 @@ export const useCategoryStore = defineStore('categories', { this.classes_unsaved_changes = true; }, restoreDefaultClasses(this: State) { - let i = 0; - this.classes = createMissingParents(defaultCategories).map(c => - Object.assign(c, { id: i++ }) - ); + this.classes = assignIds(createMissingParents(defaultCategories)); this.classes_unsaved_changes = true; }, clearAll(this: State) { diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 0d235200..39fb78cc 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia'; import moment, { Moment } from 'moment'; import { getClient } from '~/util/awclient'; -import { Category, defaultCategories, cleanCategory } from '~/util/classes'; +import { Category, CategorySet, defaultCategories, cleanCategory } from '~/util/classes'; import { View, defaultViews } from '~/stores/views'; import { isEqual } from 'lodash'; @@ -37,6 +37,11 @@ interface State { }; always_active_pattern: string; classes: Category[]; + // Named category sets — each set is an independent collection of category rules. + // The active_set_ids list controls which sets are combined (in priority order). + category_sets: CategorySet[]; + // Ordered list of active set IDs. First entry has highest priority when merging. + active_set_ids: string[]; views: View[]; // Whether to show certain WIP features @@ -75,6 +80,8 @@ export const useSettingsStore = defineStore('settings', { always_active_pattern: '', classes: defaultCategories, + category_sets: [], + active_set_ids: ['default'], views: defaultViews, // Developer settings @@ -125,7 +132,13 @@ export const useSettingsStore = defineStore('settings', { //console.debug(`${locstr} ${key}:`, value); // Keys ending with 'Data' are JSON-serialized objects in localStorage - if ((key.endsWith('Data') || key == 'views' || key == 'classes') && !set_in_server) { + const isJsonKey = + key.endsWith('Data') || + key == 'views' || + key == 'classes' || + key == 'category_sets' || + key == 'active_set_ids'; + if (isJsonKey && !set_in_server) { try { value = JSON.parse(value); // Needed due to https://github.com/ActivityWatch/activitywatch/issues/1067 diff --git a/src/util/classes.ts b/src/util/classes.ts index 45b85acf..7a2e73f0 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -24,6 +24,31 @@ export interface Category { children?: Category[]; } +export interface CategorySet { + id: string; + categories: Category[]; +} + +/** + * Merge multiple category sets in priority order (first set = highest priority). + * When the same category name appears in multiple sets, the first occurrence wins. + * Within each set, the standard specificity rule applies (deeper category wins). + */ +export function mergeCategorySets(sets: CategorySet[]): Category[] { + const seen = new Set(); + const merged: Category[] = []; + for (const set of sets) { + for (const cat of set.categories) { + const key = JSON.stringify(cat.name); + if (!seen.has(key)) { + seen.add(key); + merged.push(cat); + } + } + } + return merged; +} + const COLOR_UNCAT = '#CCC'; // The default categories @@ -198,6 +223,48 @@ export function loadClasses(): Category[] { return settingsStore.classes; } +/** + * Persist category sets and active set IDs to the settings store. + * Also updates the legacy `classes` field for backwards compatibility with external readers. + */ +export function saveCategories(sets: CategorySet[], activeIds: string[]) { + if (areWeTesting()) { + console.log('Not saving categories in test mode'); + return; + } + const settingsStore = useSettingsStore(); + const cleanSets = sets.map(s => ({ ...s, categories: s.categories.map(cleanCategory) })); + const effectiveClasses = mergeCategorySets(sets.filter(s => activeIds.includes(s.id))).map( + cleanCategory + ); + settingsStore.update({ + category_sets: cleanSets, + active_set_ids: activeIds, + classes: effectiveClasses, + }); +} + +/** + * Load category sets and active set IDs from the settings store. + * Falls back to the legacy flat `classes` setting if no sets are defined yet. + */ +export function loadCategories(): { sets: CategorySet[]; activeIds: string[] } { + const settingsStore = useSettingsStore(); + const sets: CategorySet[] = settingsStore.category_sets; + const activeIds: string[] = settingsStore.active_set_ids; + + if (sets && sets.length > 0) { + return { sets, activeIds: activeIds && activeIds.length > 0 ? activeIds : [sets[0].id] }; + } + + // Migration path: no sets defined yet — wrap the existing flat classes into a "default" set + const legacyClasses = settingsStore.classes || defaultCategories; + return { + sets: [{ id: 'default', categories: legacyClasses }], + activeIds: ['default'], + }; +} + function pickDeepest(categories: Category[]) { return _.maxBy(categories, c => c.name.length); } diff --git a/src/views/settings/CategorizationSettings.vue b/src/views/settings/CategorizationSettings.vue index 0b40f16e..81044040 100644 --- a/src/views/settings/CategorizationSettings.vue +++ b/src/views/settings/CategorizationSettings.vue @@ -18,6 +18,38 @@ div | You can also find and share categorization rule presets on #[a(href="https://forum.activitywatch.net/c/projects/category-rules") the forum]. | For help on how to write categorization rules, see #[a(href="https://docs.activitywatch.net/en/latest/features/categorization.html") the documentation]. + // Category set switcher + div.my-3.p-3(style="background: var(--bs-light, #f8f9fa); border-radius: 4px;") + div.d-flex.align-items-center.flex-wrap(style="gap: 0.5rem;") + span.font-weight-bold(style="white-space: nowrap") Category set: + b-select( + v-model="activeSetId" + @change="onSetChange" + style="max-width: 220px" + size="sm" + ) + b-select-option( + v-for="set in categoryStore.category_sets" + :key="set.id" + :value="set.id" + ) {{ set.id }} + b-btn( + @click="createSet" + variant="outline-primary" + size="sm" + ) New set + b-btn( + v-if="categoryStore.category_sets.length > 1" + @click="deleteActiveSet" + variant="outline-danger" + size="sm" + ) Delete set + div.mt-1( + v-if="categoryStore.category_sets.length > 1" + style="font-size: 0.85em; color: var(--bs-secondary, #6c757d);" + ) + | {{ categoryStore.category_sets.length }} sets available — switch sets to use different rule profiles. + div.my-4 b-alert(variant="warning" :show="classes_unsaved_changes") | You have unsaved changes! @@ -61,11 +93,22 @@ export default { data: () => ({ categoryStore: useCategoryStore(), editingId: null, + activeSetId: 'default', }), computed: { ...mapState(useCategoryStore, ['classes_unsaved_changes']), ...mapGetters(useCategoryStore, ['classes_hierarchy']), }, + watch: { + 'categoryStore.active_set_ids': { + handler(newIds: string[]) { + if (newIds && newIds.length > 0) { + this.activeSetId = newIds[0]; + } + }, + immediate: true, + }, + }, mounted() { this.categoryStore.load(); @@ -103,11 +146,12 @@ export default { exportClasses: async function () { console.log('Exporting categories...'); - const export_data = { - categories: this.categoryStore.classes, - }; + // Export the active set with its ID — suitable for re-importing as a named set + const activeSetId = this.categoryStore.active_set_ids[0] || 'default'; + const activeSet = this.categoryStore.category_sets.find(s => s.id === activeSetId); + const export_data = activeSet || { id: activeSetId, categories: this.categoryStore.classes }; const text = JSON.stringify(export_data, null, 2); - await downloadFile('aw-category-export.json', text, 'application/json'); + await downloadFile(`aw-category-export-${export_data.id}.json`, text, 'application/json'); }, importCategories: async function (elem) { console.log('Importing categories...'); @@ -123,8 +167,59 @@ export default { const text = await file.text(); const import_obj = JSON.parse(text); - // Set import to categories as unsaved changes - this.categoryStore.import(import_obj.categories); + if (import_obj.categories && !import_obj.id) { + // Legacy format: flat categories array — import into the active set + this.categoryStore.import(import_obj.categories); + } else if (import_obj.id && import_obj.categories) { + // New CategorySet format — create or overwrite set with same ID + let setId = import_obj.id; + while ( + this.categoryStore.category_sets.find( + s => s.id === setId && s.id !== (this.categoryStore.active_set_ids[0] || '') + ) + ) { + // Avoid duplicate IDs (except if overwriting the active set) + setId = setId + '-imported'; + } + const existing = this.categoryStore.category_sets.find(s => s.id === setId); + if (existing) { + existing.categories = import_obj.categories; + } else { + this.categoryStore.category_sets.push({ id: setId, categories: import_obj.categories }); + } + this.categoryStore.switchToSet(setId); + this.categoryStore.classes_unsaved_changes = true; + } else { + console.error('Unrecognized import format'); + } + }, + createSet: function () { + const name = prompt('Name for the new category set:'); + if (!name) return; + if (this.categoryStore.category_sets.find(s => s.id === name)) { + alert(`A set named "${name}" already exists.`); + return; + } + // Create the new set and switch to it (switchToSet syncs current state first) + this.categoryStore.createSet(name); + this.categoryStore.switchToSet(name); + }, + deleteActiveSet: function () { + const id = this.categoryStore.active_set_ids[0]; + if (!id) return; + if (!confirm(`Delete category set "${id}"? This cannot be undone.`)) return; + this.categoryStore.deleteSet(id); + this.categoryStore.save(); + }, + onSetChange: function (setId: string) { + if (this.classes_unsaved_changes) { + if (!confirm('You have unsaved changes. Switch sets anyway? (Changes will be discarded)')) { + // Revert the select back to current active + this.activeSetId = this.categoryStore.active_set_ids[0] || 'default'; + return; + } + } + this.categoryStore.switchToSet(setId); }, beforeUnload: function (e) { if (this.classes_unsaved_changes) { From 2239c9029e923ff9d6f84edc6503a57e6416731c Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 15 Apr 2026 14:30:02 +0000 Subject: [PATCH 2/4] fix(categorization): remove unused loadClasses import --- src/stores/categories.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stores/categories.ts b/src/stores/categories.ts index 941bd58b..68edcdc9 100644 --- a/src/stores/categories.ts +++ b/src/stores/categories.ts @@ -1,7 +1,6 @@ import _ from 'lodash'; import { saveClasses, - loadClasses, saveCategories, loadCategories, cleanCategory, From c9273adc8147896baba070f540e2e14c1eca5a78 Mon Sep 17 00:00:00 2001 From: TimeToBuildBob Date: Thu, 16 Apr 2026 15:15:02 +0000 Subject: [PATCH 3/4] fix(categorization): fix P1 bugs found in Greptile review - syncToPrimarySet: skip when multiple sets are active When active_set_ids.length > 1, state.classes is the merged result of all active sets. Writing it back to only the primary set would absorb all secondary sets' categories into it (data corruption). Now a no-op in multi-set mode until proper de-merge logic is added. - onSetChange discard bypass: call discardChanges() before switchToSet() Without this, confirming "discard" still called switchToSet() which called syncToPrimarySet() first, writing the unsaved edits back into the primary set's in-memory state. Now we explicitly reset in-memory classes from the stored sets before switching. - Add discardChanges() action to categories store Resets state.classes from stored sets without syncing back. Separates the "reload from sets" operation from "switch to set", enabling clean discard semantics. --- src/stores/categories.ts | 24 ++++++++++++++++++- src/views/settings/CategorizationSettings.vue | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/stores/categories.ts b/src/stores/categories.ts index 68edcdc9..7bdefbc4 100644 --- a/src/stores/categories.ts +++ b/src/stores/categories.ts @@ -64,9 +64,19 @@ function computeEffectiveClasses(categorySets: CategorySet[], activeSetIds: stri return assignIds(createMissingParents(merged)); } -/** Copy current effective classes back into the primary active set (no-op if no sets). */ +/** + * Copy current effective classes back into the primary active set. + * + * Only safe when exactly one set is active: with multiple sets `state.classes` + * is the merged result of all active sets and cannot be split back into + * individual sets, so we skip the sync to avoid corrupting secondary sets. + */ function syncToPrimarySet(state: State) { if (state.active_set_ids.length === 0 || state.category_sets.length === 0) return; + // Skip when multiple sets are active — state.classes is a merged result + // and writing it back to only the primary set would absorb all secondary + // sets' categories into it (data corruption). + if (state.active_set_ids.length > 1) return; const primaryId = state.active_set_ids[0]; const primarySet = state.category_sets.find(s => s.id === primaryId); if (primarySet) { @@ -255,6 +265,18 @@ export const useCategoryStore = defineStore('categories', { this.classes_unsaved_changes = false; }, + /** + * Discard unsaved in-memory edits and recompute classes from the stored sets. + * + * Call this before switchToSet() when the user explicitly chooses to discard + * changes. Without this, switchToSet() would call syncToPrimarySet() first — + * writing the discarded edits back into the set's in-memory state. + */ + discardChanges(this: State) { + this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids); + this.classes_unsaved_changes = false; + }, + /** * Set multiple active sets (combined in priority order). * The first ID in the list is the primary set (edits go here). diff --git a/src/views/settings/CategorizationSettings.vue b/src/views/settings/CategorizationSettings.vue index 81044040..c49c7a5b 100644 --- a/src/views/settings/CategorizationSettings.vue +++ b/src/views/settings/CategorizationSettings.vue @@ -218,6 +218,10 @@ export default { this.activeSetId = this.categoryStore.active_set_ids[0] || 'default'; return; } + // User confirmed discard: reset in-memory edits before switching so that + // switchToSet() → syncToPrimarySet() writes back the original (clean) state, + // not the unsaved edits the user just chose to discard. + this.categoryStore.discardChanges(); } this.categoryStore.switchToSet(setId); }, From 02a523db95694121a35620f257b970b9237949b3 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 09:11:15 +0000 Subject: [PATCH 4/4] fix(categorization): fix P2 bugs in import/export identified by Greptile - exportClasses: use classes_clean instead of activeSet.categories so unsaved in-memory edits are included in the export (was silently omitting any edits made since last save/switch) - importCategories (active-set case): call discardChanges() instead of switchToSet() when importing into the currently active set. switchToSet calls syncToPrimarySet first which overwrites the just-imported categories with stale in-memory classes, making the import a silent no-op. discardChanges recomputes classes from the updated set data without the destructive sync. --- src/views/settings/CategorizationSettings.vue | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/views/settings/CategorizationSettings.vue b/src/views/settings/CategorizationSettings.vue index c49c7a5b..002491f5 100644 --- a/src/views/settings/CategorizationSettings.vue +++ b/src/views/settings/CategorizationSettings.vue @@ -146,10 +146,10 @@ export default { exportClasses: async function () { console.log('Exporting categories...'); - // Export the active set with its ID — suitable for re-importing as a named set + // Export the active set with its ID — suitable for re-importing as a named set. + // Use classes_clean (current in-memory state) so unsaved edits are included. const activeSetId = this.categoryStore.active_set_ids[0] || 'default'; - const activeSet = this.categoryStore.category_sets.find(s => s.id === activeSetId); - const export_data = activeSet || { id: activeSetId, categories: this.categoryStore.classes }; + const export_data = { id: activeSetId, categories: this.categoryStore.classes_clean }; const text = JSON.stringify(export_data, null, 2); await downloadFile(`aw-category-export-${export_data.id}.json`, text, 'application/json'); }, @@ -184,10 +184,19 @@ export default { const existing = this.categoryStore.category_sets.find(s => s.id === setId); if (existing) { existing.categories = import_obj.categories; + // If importing into the active set, don't call switchToSet — it would call + // syncToPrimarySet first and overwrite the just-imported categories with + // stale in-memory classes. Use discardChanges to recompute from the updated set. + const isActiveSet = setId === (this.categoryStore.active_set_ids[0] || ''); + if (isActiveSet) { + this.categoryStore.discardChanges(); + } else { + this.categoryStore.switchToSet(setId); + } } else { this.categoryStore.category_sets.push({ id: setId, categories: import_obj.categories }); + this.categoryStore.switchToSet(setId); } - this.categoryStore.switchToSet(setId); this.categoryStore.classes_unsaved_changes = true; } else { console.error('Unrecognized import format');