Skip to content

feat(categorization): add category sets — named rule profiles with multi-set combining#804

Open
TimeToBuildBob wants to merge 4 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/category-sets
Open

feat(categorization): add category sets — named rule profiles with multi-set combining#804
TimeToBuildBob wants to merge 4 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/category-sets

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Picks up #369 (Erik's 2022 WIP) and extends it with an updated data model and a complete implementation against the current codebase.

What changed since #369

The codebase has evolved significantly since 2022: classes is now stored in the settings store (which syncs to aw-server) rather than directly in localStorage. This PR adapts the design accordingly.

What's new

src/util/classes.ts

  • CategorySet type: { id: string; categories: Category[] }
  • mergeCategorySets(sets): merges sets in priority order (first = highest priority); more specific rules still win within a set — set order only breaks ties at equal specificity
  • saveCategories / loadCategories: persist sets + active IDs to the settings store, keeping the legacy classes field in sync for backwards compatibility with external readers

src/stores/settings.ts

  • New fields: category_sets: CategorySet[] + active_set_ids: string[], persisted alongside the existing classes field

src/stores/categories.ts

  • Full CRUD for sets: createSet, deleteSet, switchToSet, setActiveSets, renameSet
  • classes always reflects the merged effective categories from all active sets
  • Migration path: on first load with no sets defined, existing classes are wrapped in a "default" set automatically
  • save() syncs current edits back to the primary active set before persisting all sets

src/views/settings/CategorizationSettings.vue

  • Set switcher dropdown above the category editor
  • "New set" and "Delete set" buttons
  • Export now uses { id, categories } format (with set ID in filename); import handles both legacy { categories } and new { id, categories } formats

Design decisions

  • active_set_ids: string[] (not a single string) — the data model already supports combining multiple sets in priority order; this PR's UI exposes single-set switching. Multi-set combining UI can follow as a small incremental change.
  • Backwards compatible: the legacy classes field in the settings store is always kept in sync, so external tools reading settings are unaffected.
  • Single-set editing: edits always go to the primary active set. When multiple sets are active (future UI), edits go to the first set in the priority list.

Tests

All 43 tests that ran on master still pass. The 3 test suite failures (categories.test, activity.test, views.test) are pre-existing — they fail with vueDemi.hasInjectionContext is not a function, unrelated to this PR.

Closes / supersedes #369

…lti-set combining

Implements category sets from ActivityWatch#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#369
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 15, 2026

Codecov Report

❌ Patch coverage is 22.85714% with 81 lines in your changes missing coverage. Please review.
✅ Project coverage is 29.06%. Comparing base (5529031) to head (02a523d).

Files with missing lines Patch % Lines
src/stores/categories.ts 23.61% 46 Missing and 9 partials ⚠️
src/util/classes.ts 23.07% 19 Missing and 1 partial ⚠️
src/stores/settings.ts 14.28% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #804      +/-   ##
==========================================
- Coverage   29.96%   29.06%   -0.90%     
==========================================
  Files          32       32              
  Lines        1829     1920      +91     
  Branches      317      351      +34     
==========================================
+ Hits          548      558      +10     
- Misses       1260     1284      +24     
- Partials       21       78      +57     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 15, 2026

Greptile Summary

This PR picks up and completes the category-sets feature (#369), adding named rule profiles with multi-set merging, full CRUD store actions, a set-switcher UI, and a migration path that wraps legacy flat classes into a "default" set. The previously flagged P1 issues (discard flow preserving edits, syncToPrimarySet corruption in multi-set mode, import overwriting active set) are all addressed in the current revision.

Confidence Score: 5/5

Safe to merge — all prior P1 issues are resolved and remaining findings are P2.

The three P1 issues from previous review rounds are confirmed fixed: discardChanges() is wired correctly, syncToPrimarySet early-returns for multi-set state, and the active-set import path uses discardChanges() to avoid overwriting imported data. Remaining findings are a redundant double-persist and an edge-case import dedup collision, both P2 and non-blocking.

src/stores/categories.ts (redundant saveClasses call in save()), src/views/settings/CategorizationSettings.vue (import dedup while-loop edge case)

Important Files Changed

Filename Overview
src/stores/categories.ts Core store refactored to support multiple named category sets with CRUD actions; syncToPrimarySet correctly no-ops on multi-set state; one redundant saveClasses call in save() triggers a second concurrent persist.
src/util/classes.ts Adds CategorySet type, mergeCategorySets (first-set-wins dedup), saveCategories/loadCategories with migration path; logic is clean and saveCategories already handles the legacy classes field.
src/stores/settings.ts Adds category_sets/active_set_ids fields to state and correctly registers them as JSON-serialized keys in the localStorage load path.
src/views/settings/CategorizationSettings.vue Adds set-switcher UI and import/export for the new format; discard/switch flow is correctly wired through discardChanges(); the import dedup while-loop has an edge case where a renamed setId can accidentally collide with the active set.

Sequence Diagram

sequenceDiagram
    participant UI as CategorizationSettings
    participant CS as categoriesStore
    participant SS as settingsStore

    Note over UI,SS: Switch sets (with unsaved changes)
    UI->>CS: discardChanges()
    CS->>CS: classes = computeEffectiveClasses(sets, activeIds)
    UI->>CS: switchToSet(id)
    CS->>CS: syncToPrimarySet() [no-op: classes already clean]
    CS->>CS: active_set_ids = [id]
    CS->>CS: classes = computeEffectiveClasses(sets, [id])

    Note over UI,SS: Save
    UI->>CS: save()
    CS->>CS: syncToPrimarySet() → primarySet.categories = classes.map(clean)
    CS->>SS: saveCategories(sets, activeIds) [writes category_sets + active_set_ids + classes]
    SS->>SS: $patch + save() → HTTP POST per changed key

    Note over UI,SS: Load (migration path)
    UI->>CS: load()
    CS->>SS: loadCategories()
    SS-->>CS: sets=[{id:'default', categories: legacyClasses}], activeIds=['default']
    CS->>CS: computeEffectiveClasses → createMissingParents + assignIds
Loading

Reviews (3): Last reviewed commit: "fix(categorization): fix P2 bugs in impo..." | Re-trigger Greptile

Comment on lines +214 to +222
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Discard changes" flow silently preserves and re-persists the discarded edits

When the user confirms "Changes will be discarded", switchToSet is called, which immediately invokes syncToPrimarySet — copying state.classes (the unsaved edits) back into the primary set's in-memory categories array. The edits are not discarded; they live in the in-memory store. If the user later saves from any set, save() calls syncToPrimarySet again and then saveCategories which persists all sets — including the "discarded" changes from the old primary set.

The fix is to not call syncToPrimarySet during a discard, e.g. by adding a discard: boolean parameter:

// In categories.ts — switchToSet
switchToSet(this: State, id: string, discard = false) {
  if (!this.category_sets.find(s => s.id === id)) { ... }
  if (!discard) syncToPrimarySet(this);
  this.active_set_ids = [id];
  this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
  this.classes_unsaved_changes = false;
},

And in the Vue component:

Suggested change
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);
this.categoryStore.switchToSet(setId, true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c9273ad: added a discardChanges() action to the store that resets state.classes from the stored sets without calling syncToPrimarySet. The onSetChange discard path now calls discardChanges() before switchToSet(), so unsaved edits are wiped from memory before the set switch happens.

Comment thread src/stores/categories.ts Outdated
Comment on lines +67 to +75
/** 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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 syncToPrimarySet corrupts primary set when multiple sets are active

When active_set_ids has more than one entry, state.classes holds the merged result of all active sets. Writing this merged list back to only the primary set permanently injects every category from secondary sets into it. The next save() call then persists this corruption.

setActiveSets is a public store action, so the multi-set path is reachable today even without a UI. Any caller that does store.setActiveSets(['a','b']) → user edits → store.save() will silently merge all of set B's categories into set A.

The fix is to strip secondary-set categories before syncing:

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) return;

  const secondaryIds = new Set(state.active_set_ids.slice(1));
  const secondaryNames = new Set<string>();
  for (const s of state.category_sets.filter(s => secondaryIds.has(s.id))) {
    for (const c of s.categories) secondaryNames.add(JSON.stringify(c.name));
  }

  primarySet.categories = state.classes
    .map(cleanCategory)
    .filter(c => !secondaryNames.has(JSON.stringify(c.name)));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c9273ad: syncToPrimarySet now early-returns when active_set_ids.length > 1. In multi-set mode, state.classes is the merged result of all active sets, so writing it back to only the primary set would corrupt data. The function is now a no-op in that mode until proper de-merge logic is added.

Comment on lines +196 to +205
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Newly created set silently lost on navigation

After createSet(name) + switchToSet(name), switchToSet sets classes_unsaved_changes = false. The new (empty) set exists only in memory and is never persisted until the user explicitly saves. But because classes_unsaved_changes is false, the beforeunload guard won't fire and there's no "unsaved changes" banner — so navigating away silently drops the new set.

Consider marking classes_unsaved_changes = true after the switch since the set list itself has changed:

Suggested change
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);
this.categoryStore.createSet(name);
this.categoryStore.switchToSet(name);
this.categoryStore.classes_unsaved_changes = true;

Comment on lines +184 to +194
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');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Direct store mutation bypasses abstraction in importCategories

Lines 188 and 191 mutate Pinia store state directly from the component (category_sets.push(...) and classes_unsaved_changes = true). This is inconsistent with using createSet() elsewhere and makes it harder to add validation/side-effects to the set-creation path in the future. The classes_unsaved_changes = true assignment is also immediately overwritten by the subsequent switchToSet call.

Consider routing through the store action instead:

this.categoryStore.createSet(setId);
const set = this.categoryStore.category_sets.find(s => s.id === setId);
if (set) set.categories = import_obj.categories;

- 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.
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment on lines +184 to +191
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Import into active set silently discards the imported categories

When the imported file's ID matches the currently active set, existing.categories is assigned the imported data (line 186), but the immediately following switchToSet(setId) call invokes syncToPrimarySet, which writes state.classes.map(cleanCategory) — the old in-memory classes — back over the just-assigned import data. The net result is that importing a file for the current active set is a no-op; the imported categories are lost.

Repro: export set "work" → import the same file back → categories unchanged.

The cleanest fix is to set the categories after switchToSet (so the sync runs first) and then recompute classes:

this.categoryStore.switchToSet(setId);
// Overwrite now, after syncToPrimarySet has already run inside switchToSet
const targetSet = this.categoryStore.category_sets.find(s => s.id === setId);
if (targetSet) {
  targetSet.categories = import_obj.categories;
  // Force classes to reflect the newly imported categories
  this.categoryStore.discardChanges(); // recomputes from updated set
}
this.categoryStore.classes_unsaved_changes = true;

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob Get on it

- 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.
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Ready to merge. All P1 and P2 bugs identified by Greptile are fixed:

  • Export: now reads classes_clean (includes unsaved edits) instead of stale set state
  • Import into active set: uses discardChanges() instead of switchToSet() to avoid the syncToPrimarySet overwrite

CI: all green. Greptile: 5/5, safe to merge.

Needs merge by a maintainer — I don't have merge permissions on this repo.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

All clear — CI green (8/8 checks), Greptile 5/5 with all P1 and P2 issues addressed:

  • P1 (discard flow, syncToPrimarySet corruption, import overwrite) — fixed in c9273ad
  • P2 (redundant saveClasses, import dedup edge case) — fixed in 02a523d

Ready to merge whenever you are.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants