fix(task-view-customizer): persist sort, group, and filter settings to localStorage

Resolves issue #6095 where Task View Customizer settings (sort, group, filter)
were being reset on app restart or day change.

Changes:
- Add localStorage keys for sort/group/filter settings
- Initialize signals from localStorage with default fallbacks
- Add effects to auto-persist signal changes to localStorage
- Add 7 comprehensive unit tests for persistence behavior

Settings now persist across app restarts, work context changes, and day boundaries.
Invalid localStorage data gracefully falls back to defaults.
This commit is contained in:
Johannes Millan 2026-01-21 19:41:43 +01:00
parent 623971eacd
commit 337afed482
3 changed files with 208 additions and 4 deletions

View file

@ -55,6 +55,11 @@ export enum LS {
NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED',
NAV_SIDEBAR_WIDTH = 'SUP_NAV_SIDEBAR_WIDTH',
RIGHT_PANEL_WIDTH = 'SUP_RIGHT_PANEL_WIDTH',
// Task view customizer
TASK_VIEW_CUSTOMIZER_SORT = 'SUP_TASK_VIEW_CUSTOMIZER_SORT',
TASK_VIEW_CUSTOMIZER_GROUP = 'SUP_TASK_VIEW_CUSTOMIZER_GROUP',
TASK_VIEW_CUSTOMIZER_FILTER = 'SUP_TASK_VIEW_CUSTOMIZER_FILTER',
}
// SESSION STORAGE

View file

@ -27,6 +27,7 @@ import {
} from './types';
import { DateAdapter } from '@angular/material/core';
import { DEFAULT_FIRST_DAY_OF_WEEK } from 'src/app/core/locale.constants';
import { LS } from '../../core/persistence/storage-keys.const';
describe('TaskViewCustomizerService', () => {
let service: TaskViewCustomizerService;
@ -114,6 +115,9 @@ describe('TaskViewCustomizerService', () => {
];
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
mockWorkContextService = {
activeWorkContextId: null,
activeWorkContextType: null,
@ -498,4 +502,176 @@ describe('TaskViewCustomizerService', () => {
expect(service.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter);
});
});
describe('localStorage persistence', () => {
it('should initialize with default values when localStorage is empty', () => {
expect(service.selectedSort()).toEqual(DEFAULT_OPTIONS.sort);
expect(service.selectedGroup()).toEqual(DEFAULT_OPTIONS.group);
expect(service.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter);
});
it('should restore sort option from localStorage on initialization', () => {
const savedSort: SortOption = {
type: SORT_OPTION_TYPE.name,
order: SORT_ORDER.ASC,
label: 'Name',
};
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_SORT, JSON.stringify(savedSort));
// Reset TestBed to create a new service instance
TestBed.resetTestingModule();
const dateAdapter = jasmine.createSpyObj<DateAdapter<Date>>('DateAdapter', [], {
getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK,
});
TestBed.configureTestingModule({
providers: [
TaskViewCustomizerService,
{ provide: DateAdapter, useValue: dateAdapter },
{ provide: WorkContextService, useValue: mockWorkContextService },
{ provide: ProjectService, useValue: { update: projectUpdateSpy } },
{ provide: TagService, useValue: { updateTag: tagUpdateSpy } },
provideMockStore({
selectors: [
{ selector: selectAllProjects, value: mockProjects },
{ selector: selectAllTags, value: mockTags },
],
}),
],
});
const newService = TestBed.inject(TaskViewCustomizerService);
(newService as any)._allProjects = mockProjects;
(newService as any)._allTags = mockTags;
expect(newService.selectedSort()).toEqual(savedSort);
});
it('should restore group option from localStorage on initialization', () => {
const savedGroup: GroupOption = { type: GROUP_OPTION_TYPE.tag, label: 'Tag' };
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_GROUP, JSON.stringify(savedGroup));
TestBed.resetTestingModule();
const dateAdapter = jasmine.createSpyObj<DateAdapter<Date>>('DateAdapter', [], {
getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK,
});
TestBed.configureTestingModule({
providers: [
TaskViewCustomizerService,
{ provide: DateAdapter, useValue: dateAdapter },
{ provide: WorkContextService, useValue: mockWorkContextService },
{ provide: ProjectService, useValue: { update: projectUpdateSpy } },
{ provide: TagService, useValue: { updateTag: tagUpdateSpy } },
provideMockStore({
selectors: [
{ selector: selectAllProjects, value: mockProjects },
{ selector: selectAllTags, value: mockTags },
],
}),
],
});
const newService = TestBed.inject(TaskViewCustomizerService);
(newService as any)._allProjects = mockProjects;
(newService as any)._allTags = mockTags;
expect(newService.selectedGroup()).toEqual(savedGroup);
});
it('should restore filter option from localStorage on initialization', () => {
const savedFilter: FilterOption = {
type: FILTER_OPTION_TYPE.tag,
preset: 'Tag A',
label: 'Tag',
};
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_FILTER, JSON.stringify(savedFilter));
TestBed.resetTestingModule();
const dateAdapter = jasmine.createSpyObj<DateAdapter<Date>>('DateAdapter', [], {
getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK,
});
TestBed.configureTestingModule({
providers: [
TaskViewCustomizerService,
{ provide: DateAdapter, useValue: dateAdapter },
{ provide: WorkContextService, useValue: mockWorkContextService },
{ provide: ProjectService, useValue: { update: projectUpdateSpy } },
{ provide: TagService, useValue: { updateTag: tagUpdateSpy } },
provideMockStore({
selectors: [
{ selector: selectAllProjects, value: mockProjects },
{ selector: selectAllTags, value: mockTags },
],
}),
],
});
const newService = TestBed.inject(TaskViewCustomizerService);
(newService as any)._allProjects = mockProjects;
(newService as any)._allTags = mockTags;
expect(newService.selectedFilter()).toEqual(savedFilter);
});
it('should persist sort option to localStorage when changed', (done) => {
const newSort: SortOption = {
type: SORT_OPTION_TYPE.name,
order: SORT_ORDER.ASC,
label: 'Name',
};
service.setSort(newSort);
// Wait for effect to run
setTimeout(() => {
const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_SORT);
expect(stored).toBeTruthy();
expect(JSON.parse(stored!)).toEqual(newSort);
done();
}, 50);
});
it('should persist group option to localStorage when changed', (done) => {
const newGroup: GroupOption = { type: GROUP_OPTION_TYPE.tag, label: 'Tag' };
service.setGroup(newGroup);
setTimeout(() => {
const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_GROUP);
expect(stored).toBeTruthy();
expect(JSON.parse(stored!)).toEqual(newGroup);
done();
}, 50);
});
it('should persist filter option to localStorage when changed', (done) => {
const newFilter: FilterOption = {
type: FILTER_OPTION_TYPE.tag,
preset: 'Tag A',
label: 'Tag',
};
service.setFilter(newFilter);
setTimeout(() => {
const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_FILTER);
expect(stored).toBeTruthy();
expect(JSON.parse(stored!)).toEqual(newFilter);
done();
}, 50);
});
it('should fallback to defaults when localStorage contains invalid JSON', () => {
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_SORT, 'invalid json{');
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_GROUP, '{broken');
localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_FILTER, 'not json');
const newService = TestBed.inject(TaskViewCustomizerService);
(newService as any)._allProjects = mockProjects;
(newService as any)._allTags = mockTags;
expect(newService.selectedSort()).toEqual(DEFAULT_OPTIONS.sort);
expect(newService.selectedGroup()).toEqual(DEFAULT_OPTIONS.group);
expect(newService.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter);
});
});
});

View file

@ -1,4 +1,4 @@
import { Injectable, signal, inject } from '@angular/core';
import { Injectable, signal, inject, effect } from '@angular/core';
import { Observable, animationFrameScheduler, combineLatest } from 'rxjs';
import { map, observeOn, take } from 'rxjs/operators';
import { TaskWithSubTasks } from '../tasks/task.model';
@ -28,6 +28,8 @@ import {
FILTER_COMMON,
} from './types';
import { DateAdapter } from '@angular/material/core';
import { lsGetJSON, lsSetJSON } from '../../util/ls-util';
import { LS } from '../../core/persistence/storage-keys.const';
@Injectable({ providedIn: 'root' })
export class TaskViewCustomizerService {
@ -37,9 +39,18 @@ export class TaskViewCustomizerService {
private _projectService = inject(ProjectService);
private _tagService = inject(TagService);
public selectedSort = signal<SortOption>(DEFAULT_OPTIONS.sort);
public selectedGroup = signal<GroupOption>(DEFAULT_OPTIONS.group);
public selectedFilter = signal<FilterOption>(DEFAULT_OPTIONS.filter);
public selectedSort = signal<SortOption>(
lsGetJSON<SortOption>(LS.TASK_VIEW_CUSTOMIZER_SORT, DEFAULT_OPTIONS.sort) ??
DEFAULT_OPTIONS.sort,
);
public selectedGroup = signal<GroupOption>(
lsGetJSON<GroupOption>(LS.TASK_VIEW_CUSTOMIZER_GROUP, DEFAULT_OPTIONS.group) ??
DEFAULT_OPTIONS.group,
);
public selectedFilter = signal<FilterOption>(
lsGetJSON<FilterOption>(LS.TASK_VIEW_CUSTOMIZER_FILTER, DEFAULT_OPTIONS.filter) ??
DEFAULT_OPTIONS.filter,
);
isCustomized = computed(() => {
return [
@ -52,6 +63,18 @@ export class TaskViewCustomizerService {
constructor() {
this._initProjects();
this._initTags();
effect(() => {
lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_SORT, this.selectedSort());
});
effect(() => {
lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_GROUP, this.selectedGroup());
});
effect(() => {
lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_FILTER, this.selectedFilter());
});
}
private _allProjects: Project[] = [];