diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 28d93d0e..9eff9fef 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -245,7 +245,7 @@ export default { const datasets = buildBarchartDataset( this.activityStore.category.by_period, - this.categoryStore.classes + this.categoryStore.category_set.categories ); // Return dataset if data found, else return null (indicating no data) diff --git a/src/stores/categories.ts b/src/stores/categories.ts index a73564b6..56bca038 100644 --- a/src/stores/categories.ts +++ b/src/stores/categories.ts @@ -2,6 +2,7 @@ import _ from 'lodash'; import { saveClasses, loadClasses, + loadSets, cleanCategory, defaultCategories, build_category_hierarchy, @@ -9,42 +10,46 @@ import { annotate, Category, Rule, + CategorySet, } from '~/util/classes'; import { getColorFromCategory } from '~/util/color'; import { defineStore } from 'pinia'; interface State { - classes: Category[]; - classes_unsaved_changes: boolean; + // The classes of the currently active category set + category_set: CategorySet; + + // A list of IDs for existing category sets + category_sets: CategorySet[]; + + unsaved_changes: boolean; } export const useCategoryStore = defineStore('categories', { state: (): State => ({ - classes: [], - classes_unsaved_changes: false, + category_set: { id: '', categories: [] }, + category_sets: [], + unsaved_changes: false, }), // getters getters: { - classes_clean(): Category[] { - return this.classes.map(cleanCategory); - }, - classes_hierarchy() { - const hier = build_category_hierarchy(_.cloneDeep(this.classes)); + classes_hierarchy(this: State) { + const hier = build_category_hierarchy(_.cloneDeep(this.category_set.categories)); return _.sortBy(hier, [c => c.id || 0]); }, - classes_for_query(): [string[], Rule][] { - return this.classes + classes_for_query(this: State): [string[], Rule][] { + return this.category_set.categories .filter(c => c.rule.type !== null) .map(c => { return [c.name, c.rule]; }); }, - all_categories(): string[][] { + all_categories(this: State): string[][] { // Returns a list of category names (a list of list of strings) return _.uniqBy( _.flatten( - this.classes.map((c: Category) => { + this.category_set.categories.map((c: Category) => { const l = []; for (let i = 1; i <= c.name.length; i++) { l.push(c.name.slice(0, i)); @@ -60,7 +65,7 @@ export const useCategoryStore = defineStore('categories', { if (typeof category_arr === 'string' || category_arr instanceof String) console.error('Passed category was string, expected array. Lookup will fail.'); - const match = this.classes.find(c => _.isEqual(c.name, category_arr)); + const match = this.category_set.categories.find(c => _.isEqual(c.name, category_arr)); if (!match) { if (!_.isEqual(category_arr, ['Uncategorized'])) console.error("Couldn't find category: ", category_arr); @@ -72,12 +77,14 @@ export const useCategoryStore = defineStore('categories', { }, get_category_by_id(this: State) { return (id: number) => { - return annotate(_.cloneDeep(this.classes.find((c: Category) => c.id == id))); + return annotate( + _.cloneDeep(this.category_set.categories.find((c: Category) => c.id == id)) + ); }; }, get_category_color() { return (cat: string[]) => { - return getColorFromCategory(this.get_category(cat), this.classes); + return getColorFromCategory(this.get_category(cat), this.category_set.categories); }; }, category_select() { @@ -101,71 +108,85 @@ export const useCategoryStore = defineStore('categories', { }, actions: { - load(this: State, classes: Category[] = null) { - if (classes === null) { - classes = loadClasses(); + load(this: State, category_set?: CategorySet) { + if (category_set) { + this.category_set = category_set; + this.category_sets = [this.category_set]; + } else { + // loadSets always returns the current set first + this.category_sets = loadSets(); + this.category_set = this.category_sets[0]; } - classes = createMissingParents(classes); - - let i = 0; - this.classes = classes.map(c => Object.assign(c, { id: i++ })); - this.classes_unsaved_changes = false; + this.unsaved_changes = false; }, - save() { - const r = saveClasses(this.classes); - this.classes_unsaved_changes = false; + save(this: State) { + const r = saveClasses(this.category_set); + this.unsaved_changes = false; return r; }, // mutations - import(this: State, classes: Category[]) { - let i = 0; - // overwrite id even if already set - this.classes = classes.map(c => Object.assign(c, { id: i++ })); - this.classes_unsaved_changes = true; + import(this: State, category_set: CategorySet) { + this.category_sets.push(category_set); + this.unsaved_changes = true; }, updateClass(this: State, new_class: Category) { console.log('Updating class:', new_class); - const old_class = this.classes.find((c: Category) => c.id === new_class.id); + const old_class = this.category_set.categories.find((c: Category) => c.id === new_class.id); const old_name = old_class.name; const parent_depth = old_class.name.length; if (new_class.id === undefined || new_class.id === null) { - new_class.id = _.max(_.map(this.classes, 'id')) + 1; - this.classes.push(new_class); + new_class.id = _.max(_.map(this.category_set.categories, 'id')) + 1; + this.category_set.categories.push(new_class); } else { Object.assign(old_class, new_class); } // When a parent category is renamed, we also need to rename the children - _.map(this.classes, c => { + _.map(this.category_set.categories, c => { if (_.isEqual(old_name, c.name.slice(0, parent_depth))) { c.name = new_class.name.concat(c.name.slice(parent_depth)); console.log('Renamed child:', c.name); } }); - this.classes_unsaved_changes = true; + this.unsaved_changes = true; }, addClass(this: State, new_class: Category) { - new_class.id = _.max(_.map(this.classes, 'id')) + 1; - this.classes.push(new_class); - this.classes_unsaved_changes = true; + new_class.id = (_.max(_.map(this.category_set.categories, 'id')) || 0) + 1; + this.category_set.categories.push(annotate(new_class)); + this.unsaved_changes = true; }, removeClass(this: State, classId: number) { - this.classes = this.classes.filter((c: Category) => c.id !== classId); - this.classes_unsaved_changes = true; + this.category_set.categories = this.category_set.categories.filter( + (c: Category) => c.id !== classId + ); + this.unsaved_changes = true; }, restoreDefaultClasses(this: State) { let i = 0; - this.classes = createMissingParents(defaultCategories).map(c => + this.category_set.categories = createMissingParents(defaultCategories).map(c => Object.assign(c, { id: i++ }) ); - this.classes_unsaved_changes = true; + this.unsaved_changes = true; }, clearAll(this: State) { - this.classes = []; - this.classes_unsaved_changes = true; + this.category_set.categories = []; + this.unsaved_changes = true; + }, + createSet(id: string) { + this.category_sets.push({ id, categories: [] }); + this.setCurrentSet(id); + this.unsaved_changes = true; + }, + setCurrentSet(this: State, id: string) { + const new_set = this.category_sets.find(s => s.id === id); + if (!new_set) { + console.error('Could not find set with id', id); + return; + } + this.category_set = new_set; }, }, }); diff --git a/src/util/classes.ts b/src/util/classes.ts index a8aa8efd..a88af779 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -23,6 +23,11 @@ export interface Category { children?: Category[]; } +export type CategorySet = { + id: string; + categories: Category[]; +}; + const COLOR_UNCAT = '#CCC'; // The default categories @@ -92,7 +97,7 @@ export const defaultCategories: Category[] = [ { name: ['Uncategorized'], rule: { type: null }, data: { color: COLOR_UNCAT } }, ]; -export function annotate(c: Category) { +export function annotate(c: Category): Category { const ch = c.name; c.name_pretty = ch.join(level_sep); c.subname = ch.slice(-1)[0]; @@ -160,13 +165,17 @@ function areWeTesting() { return process.env.NODE_ENV === 'test'; } -export function saveClasses(classes: Category[]) { +export function saveClasses(set: CategorySet) { if (areWeTesting()) { console.log('Not saving classes in test mode'); return; } - localStorage.classes = JSON.stringify(classes.map(cleanCategory)); - console.log('Saved classes', localStorage.classes); + const key = 'classes.' + set.id; + localStorage[key] = JSON.stringify({ + ...set, + categories: set.categories.map(cleanCategory), + }); + console.log('Saved classes', localStorage[key]); } export function cleanCategory(cat: Category): Category { @@ -179,13 +188,53 @@ export function cleanCategory(cat: Category): Category { return cat; } -export function loadClasses(): Category[] { - const classes_json = localStorage.classes; +export function loadClasses(name?: string): CategorySet { + const current_set = name || localStorage.current_set || 'default'; + const key = 'classes.' + current_set; + const classes_json = localStorage[key]; + + let set: CategorySet = { id: current_set, categories: [] }; if (classes_json && classes_json.length >= 1) { - return JSON.parse(classes_json).map(cleanCategory); + set = JSON.parse(classes_json); + } else if (current_set == 'default') { + if (localStorage.classes) { + // Fall back to + set.categories = JSON.parse(localStorage.classes); + } else { + set.categories = defaultCategories; + } } else { - return defaultCategories; + console.error('No classes found for category set', current_set); } + + // Clean + set.categories = set.categories.map(cleanCategory); + + // Assign IDs + let i = 0; + set.categories = set.categories.map(c => Object.assign(c, { id: i++ })); + + return set; +} + +export function loadSets(): CategorySet[] { + // Returns a list of category set IDs + // Always returns the current category as the first item + const setIDs = new Set( + [localStorage.current_set, 'default'] + .filter(x => x) + .concat( + Object.keys(localStorage) + .filter(key => key.startsWith('classes.')) + .map(key => key.slice(8)) + ) + ); + console.log(setIDs); + return Array.from(setIDs).map(id => loadClasses(id)); +} + +export function setCurrentSet(name: string) { + localStorage.current_set = name; } function pickDeepest(categories: Category[]) { @@ -197,7 +246,7 @@ export function matchString(str: string, categories: Category[] | null): Categor console.log( 'Categories not passed, loading... (if you see this outside of a test, you should probably pass them)' ); - categories = loadClasses(); + categories = loadClasses().categories; } // Compile regexes diff --git a/src/util/color.ts b/src/util/color.ts index f5357572..aa41e19a 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -88,7 +88,7 @@ export function getColorFromCategory(c: Category, allCats: Category[]): string { // TODO: Move into vuex? export function getCategoryColorFromString(str: string): string { // TODO: Don't load classes on every call - const allCats = loadClasses(); + const allCats = loadClasses().categories; const c = matchString(str, allCats); if (c !== null) { return getColorFromCategory(c, allCats); diff --git a/src/views/activity/ActivityView.vue b/src/views/activity/ActivityView.vue index ffccb6a3..40351dd1 100644 --- a/src/views/activity/ActivityView.vue +++ b/src/views/activity/ActivityView.vue @@ -31,7 +31,7 @@ div(v-if="view") span Edit view - diff --git a/src/visualizations/SunburstCategories.vue b/src/visualizations/SunburstCategories.vue index fae590ad..cc4445d6 100644 --- a/src/visualizations/SunburstCategories.vue +++ b/src/visualizations/SunburstCategories.vue @@ -88,7 +88,7 @@ export default { const categoryStore = useCategoryStore(); const cat = categoryStore.get_category(s.split(SEP)); - const color = getColorFromCategory(cat, categoryStore.classes); + const color = getColorFromCategory(cat, categoryStore.category_set.categories); return color; }, }, diff --git a/test/unit/store/activity.test.node.ts b/test/unit/store/activity.test.node.ts index 816f7467..e72b7781 100644 --- a/test/unit/store/activity.test.node.ts +++ b/test/unit/store/activity.test.node.ts @@ -18,12 +18,12 @@ describe('activity store', () => { test('loads demo data', () => { // Load - expect(categoryStore.classes).toHaveLength(0); + expect(categoryStore.category_set.categories).toHaveLength(0); categoryStore.restoreDefaultClasses(); - expect(categoryStore.classes_unsaved_changes).toBeTruthy(); + expect(categoryStore.unsaved_changes).toBeTruthy(); categoryStore.save(); - expect(categoryStore.classes_unsaved_changes).toBeFalsy(); - expect(categoryStore.classes).not.toHaveLength(0); + expect(categoryStore.unsaved_changes).toBeFalsy(); + expect(categoryStore.category_set.categories).not.toHaveLength(0); // Retrieve class let workCat = categoryStore.get_category(['Work']); diff --git a/test/unit/store/categories.test.node.ts b/test/unit/store/categories.test.node.ts index 06e72485..64d806ad 100644 --- a/test/unit/store/categories.test.node.ts +++ b/test/unit/store/categories.test.node.ts @@ -14,14 +14,14 @@ describe('categories store', () => { test('loads default categories', () => { // Load categories - expect(categoryStore.classes).toHaveLength(0); + expect(categoryStore.category_set.categories).toHaveLength(0); categoryStore.restoreDefaultClasses(); - expect(categoryStore.classes_unsaved_changes).toBeTruthy(); + expect(categoryStore.unsaved_changes).toBeTruthy(); categoryStore.save(); - expect(categoryStore.classes_unsaved_changes).toBeFalsy(); - expect(categoryStore.classes).not.toHaveLength(0); + expect(categoryStore.unsaved_changes).toBeFalsy(); + expect(categoryStore.category_set.categories).not.toHaveLength(0); // Retrieve class let workCat = categoryStore.get_category(['Work']); @@ -40,8 +40,8 @@ describe('categories store', () => { }); test('loads custom categories', () => { - expect(categoryStore.classes).toHaveLength(0); - categoryStore.load([{ name: ['Test'], rule: { type: 'none' } }]); + expect(categoryStore.category_set.categories).toHaveLength(0); + categoryStore.load({ id: 'default', categories: [{ name: ['Test'], rule: { type: 'none' } }] }); expect(categoryStore.all_categories).toHaveLength(1); });