From 0dd97e122d0b0551b4236659d2d09ae4c9ae93f9 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 18 Apr 2026 22:42:30 +0200 Subject: [PATCH] feat(misc): extend default start page to projects, planner, schedule, boards Widens MiscConfig.defaultStartPage from number to number | string so users can pick any project as their startup screen in addition to Today (0), legacy Inbox (1), Planner (2), Schedule (3), or Boards (4). Builds on #6354. - New StartPageSelectComponent (Formly type start-page-select) lists built-in pages plus all non-archived, non-hidden projects. A legacy Inbox option is rendered only while the stored value is still 1 so existing users see their selection without any silent config write. - Feature-gated options (Planner/Schedule/Boards) are hidden from the dropdown when the corresponding appFeatures flag is off. - DefaultStartPageGuard validates the selection against current state: project must exist and not be archived or hidden; feature pages only route when enabled; empty strings, missing projects, and disabled features all fall back to Today instead of stranding the user. - Deleting a project clears misc.defaultStartPage alongside the existing tasks.defaultProjectId cleanup so a stale pointer can't linger. - Unit tests for DefaultStartPageGuard cover every branch (including empty-string and archived/hidden regressions) and the new effect cleanup paths. --- src/app/app.guard.spec.ts | 166 ++++++++++++++++++ src/app/app.guard.ts | 64 ++++++- .../config/default-start-page.const.ts | 10 ++ .../form-cfgs/misc-settings-form.const.ts | 6 +- .../features/config/global-config.model.ts | 3 +- .../start-page-select.component.html | 39 ++++ .../start-page-select.component.ts | 38 ++++ .../project/store/project.effects.spec.ts | 51 ++++++ .../features/project/store/project.effects.ts | 12 +- src/app/ui/formly-config.module.ts | 7 + 10 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 src/app/app.guard.spec.ts create mode 100644 src/app/features/config/default-start-page.const.ts create mode 100644 src/app/features/config/start-page-select/start-page-select.component.html create mode 100644 src/app/features/config/start-page-select/start-page-select.component.ts diff --git a/src/app/app.guard.spec.ts b/src/app/app.guard.spec.ts new file mode 100644 index 0000000000..74c77b80ed --- /dev/null +++ b/src/app/app.guard.spec.ts @@ -0,0 +1,166 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { DefaultStartPageGuard } from './app.guard'; +import { DataInitStateService } from './core/data-init/data-init-state.service'; +import { GlobalConfigService } from './features/config/global-config.service'; +import { ProjectService } from './features/project/project.service'; +import { TODAY_TAG } from './features/tag/tag.const'; +import { INBOX_PROJECT } from './features/project/project.const'; +import { Project } from './features/project/project.model'; + +describe('DefaultStartPageGuard', () => { + let guard: DefaultStartPageGuard; + let router: Router; + let misc$: BehaviorSubject<{ defaultStartPage?: number | string } | undefined>; + let appFeatures: jasmine.Spy; + let getByIdOnce$: jasmine.Spy; + + const TODAY_URL = `/tag/${TODAY_TAG.id}/tasks`; + + const runGuard = async (): Promise => { + const result = await guard.canActivate({} as any, {} as any).toPromise(); + if (!result) { + throw new Error('guard returned falsy'); + } + return result; + }; + + const fakeProject = (overrides: Partial = {}): Project => + ({ + id: 'p1', + title: 'Project', + isArchived: false, + isHiddenFromMenu: false, + ...overrides, + }) as Project; + + beforeEach(() => { + misc$ = new BehaviorSubject<{ defaultStartPage?: number | string } | undefined>( + undefined, + ); + appFeatures = jasmine.createSpy('appFeatures').and.returnValue({ + isPlannerEnabled: true, + isSchedulerEnabled: true, + isBoardsEnabled: true, + }); + getByIdOnce$ = jasmine.createSpy('getByIdOnce$').and.returnValue(of(undefined)); + + TestBed.configureTestingModule({ + providers: [ + DefaultStartPageGuard, + { + provide: DataInitStateService, + useValue: { isAllDataLoadedInitially$: of(true) }, + }, + { + provide: GlobalConfigService, + useValue: { misc$, appFeatures }, + }, + { + provide: ProjectService, + useValue: { getByIdOnce$ }, + }, + ], + }); + guard = TestBed.inject(DefaultStartPageGuard); + router = TestBed.inject(Router); + }); + + const expectUrl = (tree: UrlTree, path: string): void => { + expect(router.serializeUrl(tree)).toBe(path); + }; + + it('routes undefined → Today', async () => { + misc$.next({ defaultStartPage: undefined }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('routes DefaultStartPage.Today → Today', async () => { + misc$.next({ defaultStartPage: 0 }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('routes legacy Inbox (1) → /project/INBOX_PROJECT/tasks', async () => { + misc$.next({ defaultStartPage: 1 }); + expectUrl(await runGuard(), `/project/${INBOX_PROJECT.id}/tasks`); + }); + + it('routes Planner when feature enabled', async () => { + misc$.next({ defaultStartPage: 2 }); + expectUrl(await runGuard(), '/planner'); + }); + + it('falls back to Today when Planner disabled', async () => { + appFeatures.and.returnValue({ + isPlannerEnabled: false, + isSchedulerEnabled: true, + isBoardsEnabled: true, + }); + misc$.next({ defaultStartPage: 2 }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('falls back to Today when Schedule disabled', async () => { + appFeatures.and.returnValue({ + isPlannerEnabled: true, + isSchedulerEnabled: false, + isBoardsEnabled: true, + }); + misc$.next({ defaultStartPage: 3 }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('falls back to Today when Boards disabled', async () => { + appFeatures.and.returnValue({ + isPlannerEnabled: true, + isSchedulerEnabled: true, + isBoardsEnabled: false, + }); + misc$.next({ defaultStartPage: 4 }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('routes to project when project exists', async () => { + getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1' }))); + misc$.next({ defaultStartPage: 'p1' }); + expectUrl(await runGuard(), '/project/p1/tasks'); + }); + + it('falls back to Today when project is missing', async () => { + getByIdOnce$.and.returnValue(of(undefined)); + misc$.next({ defaultStartPage: 'does-not-exist' }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('falls back to Today when project is archived', async () => { + getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1', isArchived: true }))); + misc$.next({ defaultStartPage: 'p1' }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('falls back to Today when project is hidden from menu', async () => { + getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1', isHiddenFromMenu: true }))); + misc$.next({ defaultStartPage: 'p1' }); + expectUrl(await runGuard(), TODAY_URL); + }); + + it('falls back to Today when getByIdOnce$ errors', async () => { + getByIdOnce$.and.returnValue(throwError(() => new Error('boom'))); + misc$.next({ defaultStartPage: 'p1' }); + expectUrl(await runGuard(), TODAY_URL); + }); + + // Regression: empty-string ids previously threw synchronously in + // ProjectService.getByIdOnce$ and blocked the guard entirely. + it('falls back to Today when defaultStartPage is an empty string', async () => { + misc$.next({ defaultStartPage: '' }); + expectUrl(await runGuard(), TODAY_URL); + expect(getByIdOnce$).not.toHaveBeenCalled(); + }); + + it('falls back to Today when misc config itself is undefined', async () => { + misc$.next(undefined); + expectUrl(await runGuard(), TODAY_URL); + }); +}); diff --git a/src/app/app.guard.ts b/src/app/app.guard.ts index 08e8ac4327..d50d3240a2 100644 --- a/src/app/app.guard.ts +++ b/src/app/app.guard.ts @@ -16,6 +16,7 @@ import { Store } from '@ngrx/store'; import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.selectors'; import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; +import { DefaultStartPage } from './features/config/default-start-page.const'; import { TODAY_TAG } from './features/tag/tag.const'; import { INBOX_PROJECT } from './features/project/project.const'; @@ -107,9 +108,13 @@ export class ValidProjectIdGuard { @Injectable({ providedIn: 'root' }) export class DefaultStartPageGuard { private _globalConfigService = inject(GlobalConfigService); + private _projectService = inject(ProjectService); private _dataInitStateService = inject(DataInitStateService); private _router = inject(Router); + private readonly _todayUrl = (): UrlTree => + this._router.parseUrl(`/tag/${TODAY_TAG.id}/tasks`); + canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot, @@ -117,15 +122,56 @@ export class DefaultStartPageGuard { return this._dataInitStateService.isAllDataLoadedInitially$.pipe( concatMap(() => this._globalConfigService.misc$), take(1), - map((miscCfg) => { - const TODAY = 0; - const INBOX = 1; - if ((miscCfg?.defaultStartPage ?? TODAY) === INBOX) { - return this._router.parseUrl(`/project/${INBOX_PROJECT.id}/tasks`); - } else { - return this._router.parseUrl(`/tag/${TODAY_TAG.id}/tasks`); - } - }), + concatMap((miscCfg) => this._resolve(miscCfg?.defaultStartPage)), ); } + + private _resolve(startPage: number | string | undefined): Observable { + if (typeof startPage === 'string' && startPage.length > 0) { + // Project id. Fall back to Today if the project is missing, archived, + // or hidden from the menu — same cases where the dropdown omits it. + return this._projectService.getByIdOnce$(startPage).pipe( + catchError((err) => { + Log.warn( + `DefaultStartPageGuard: failed to look up project '${startPage}'`, + err, + ); + return of(undefined); + }), + map((project) => + project && !project.isArchived && !project.isHiddenFromMenu + ? this._router.parseUrl(`/project/${startPage}/tasks`) + : this._todayUrl(), + ), + ); + } + + const appFeatures = this._globalConfigService.appFeatures(); + switch (startPage ?? DefaultStartPage.Today) { + case DefaultStartPage.Inbox: + // Legacy numeric value preserved for old configs. + return of(this._router.parseUrl(`/project/${INBOX_PROJECT.id}/tasks`)); + case DefaultStartPage.Planner: + return of( + appFeatures.isPlannerEnabled + ? this._router.parseUrl('/planner') + : this._todayUrl(), + ); + case DefaultStartPage.Schedule: + return of( + appFeatures.isSchedulerEnabled + ? this._router.parseUrl('/schedule') + : this._todayUrl(), + ); + case DefaultStartPage.Boards: + return of( + appFeatures.isBoardsEnabled + ? this._router.parseUrl('/boards') + : this._todayUrl(), + ); + case DefaultStartPage.Today: + default: + return of(this._todayUrl()); + } + } } diff --git a/src/app/features/config/default-start-page.const.ts b/src/app/features/config/default-start-page.const.ts new file mode 100644 index 0000000000..714006bfd4 --- /dev/null +++ b/src/app/features/config/default-start-page.const.ts @@ -0,0 +1,10 @@ +// Numeric IDs for built-in start pages. +// String values of `MiscConfig.defaultStartPage` are treated as project IDs. +// Values are persisted in user config — do not change existing numbers. +export enum DefaultStartPage { + Today = 0, + Inbox = 1, // Legacy — new configs store INBOX_PROJECT.id as a string instead. + Planner = 2, + Schedule = 3, + Boards = 4, +} diff --git a/src/app/features/config/form-cfgs/misc-settings-form.const.ts b/src/app/features/config/form-cfgs/misc-settings-form.const.ts index f4b4b5505c..377b4fc417 100644 --- a/src/app/features/config/form-cfgs/misc-settings-form.const.ts +++ b/src/app/features/config/form-cfgs/misc-settings-form.const.ts @@ -104,14 +104,10 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { : []) as LimitedFormlyFieldConfig[]), { key: 'defaultStartPage', - type: 'select', + type: 'start-page-select', defaultValue: 0, templateOptions: { label: T.GCF.MISC.DEFAULT_START_PAGE, - options: [ - { label: T.G.TODAY_TAG_TITLE, value: 0 }, - { label: T.G.INBOX_PROJECT_TITLE, value: 1 }, - ], }, }, ], diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index fa2d9c4cb9..fc99393715 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -36,7 +36,8 @@ export type MiscConfig = Readonly<{ isTrayShowCurrentCountdown?: boolean; isUseCustomWindowTitleBar?: boolean; customTheme?: string; - defaultStartPage?: number; + // number: one of DefaultStartPage. string: project id. + defaultStartPage?: number | string; unsplashApiKey?: string | null; // @todo: remove deprecated items in future major releases, after giving users time to migrate diff --git a/src/app/features/config/start-page-select/start-page-select.component.html b/src/app/features/config/start-page-select/start-page-select.component.html new file mode 100644 index 0000000000..dc541fc7af --- /dev/null +++ b/src/app/features/config/start-page-select/start-page-select.component.html @@ -0,0 +1,39 @@ + + {{ + T.G.TODAY_TAG_TITLE | translate + }} + + + @if (formControl.value === DefaultStartPage.Inbox) { + {{ + T.G.INBOX_PROJECT_TITLE | translate + }} + } + + @if (appFeatures().isPlannerEnabled) { + {{ + T.MH.PLANNER | translate + }} + } + @if (appFeatures().isSchedulerEnabled) { + {{ + T.MH.SCHEDULE | translate + }} + } + @if (appFeatures().isBoardsEnabled) { + {{ + T.MH.BOARDS | translate + }} + } + + @for (opt of projects(); track opt.id) { + {{ opt.title }} + } + diff --git a/src/app/features/config/start-page-select/start-page-select.component.ts b/src/app/features/config/start-page-select/start-page-select.component.ts new file mode 100644 index 0000000000..1ba59a90bf --- /dev/null +++ b/src/app/features/config/start-page-select/start-page-select.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FieldType } from '@ngx-formly/material'; +import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MatOption, MatSelect } from '@angular/material/select'; + +import { ProjectService } from '../../project/project.service'; +import { GlobalConfigService } from '../global-config.service'; +import { DefaultStartPage } from '../default-start-page.const'; +import { T } from 'src/app/t.const'; + +@Component({ + selector: 'start-page-select', + templateUrl: './start-page-select.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + FormlyModule, + TranslatePipe, + MatSelect, + MatOption, + ], +}) +export class StartPageSelectComponent extends FieldType { + private readonly _projectService = inject(ProjectService); + private readonly _globalConfigService = inject(GlobalConfigService); + + readonly T = T; + readonly DefaultStartPage = DefaultStartPage; + + // Signal-based (CLAUDE.md prefers signals over observables). + // listSortedForUI filters archived + hidden-from-menu projects. + readonly projects = this._projectService.listSortedForUI; + readonly appFeatures = this._globalConfigService.appFeatures; +} diff --git a/src/app/features/project/store/project.effects.spec.ts b/src/app/features/project/store/project.effects.spec.ts index 0e030326f9..5b5ea4f0cd 100644 --- a/src/app/features/project/store/project.effects.spec.ts +++ b/src/app/features/project/store/project.effects.spec.ts @@ -133,6 +133,57 @@ describe('ProjectEffects', () => { }), ); }); + + it('should reset misc.defaultStartPage when deleted project was the start page', (done) => { + globalConfigServiceMock.cfg.and.returnValue({ + ...DEFAULT_GLOBAL_CONFIG, + misc: { + ...DEFAULT_GLOBAL_CONFIG.misc, + defaultStartPage: 'project-1', + }, + }); + + effects.deleteProjectRelatedData.subscribe(() => { + expect(globalConfigServiceMock.updateSection).toHaveBeenCalledWith('misc', { + defaultStartPage: 0, + }); + done(); + }); + + actions$.next( + TaskSharedActions.deleteProject({ + projectId: 'project-1', + noteIds: [], + allTaskIds: [], + }), + ); + }); + + it('should NOT reset misc.defaultStartPage when deleted project does not match', (done) => { + globalConfigServiceMock.cfg.and.returnValue({ + ...DEFAULT_GLOBAL_CONFIG, + misc: { + ...DEFAULT_GLOBAL_CONFIG.misc, + defaultStartPage: 'project-1', + }, + }); + + effects.deleteProjectRelatedData.subscribe(() => { + expect(globalConfigServiceMock.updateSection).not.toHaveBeenCalledWith( + 'misc', + jasmine.any(Object), + ); + done(); + }); + + actions$.next( + TaskSharedActions.deleteProject({ + projectId: 'project-2', + noteIds: [], + allTaskIds: [], + }), + ); + }); }); describe('moveAllProjectToTodayListWhenBacklogIsDisabled$', () => { diff --git a/src/app/features/project/store/project.effects.ts b/src/app/features/project/store/project.effects.ts index 548b899abb..bd2ce9b433 100644 --- a/src/app/features/project/store/project.effects.ts +++ b/src/app/features/project/store/project.effects.ts @@ -33,11 +33,19 @@ export class ProjectEffects { this._actions$.pipe( ofType(TaskSharedActions.deleteProject), tap(({ projectId }) => { - // Clear defaultProjectId if the deleted project was the default const cfg = this._globalConfigService.cfg(); - if (cfg && projectId === cfg.tasks.defaultProjectId) { + if (!cfg) { + return; + } + // Clear defaultProjectId if the deleted project was the default + if (projectId === cfg.tasks.defaultProjectId) { this._globalConfigService.updateSection('tasks', { defaultProjectId: null }); } + // Clear misc.defaultStartPage if it pointed at the deleted project, + // otherwise users land on a dead route next app launch. + if (projectId === cfg.misc.defaultStartPage) { + this._globalConfigService.updateSection('misc', { defaultStartPage: 0 }); + } }), ), { dispatch: false }, diff --git a/src/app/ui/formly-config.module.ts b/src/app/ui/formly-config.module.ts index 61cce5ca08..9ab230566a 100644 --- a/src/app/ui/formly-config.module.ts +++ b/src/app/ui/formly-config.module.ts @@ -23,6 +23,7 @@ import { FormlyTagSelectionComponent } from './formly-tag-selection/formly-tag-s import { FormlyBtnComponent } from './formly-button/formly-btn.component'; import { FormlyImageInputComponent } from './formly-image-input/formly-image-input.component'; import { ColorInputComponent } from '../features/config/color-input/color-input.component'; +import { StartPageSelectComponent } from '../features/config/start-page-select/start-page-select.component'; import { FormlySlideToggleComponent } from './formly-slide-toggle/formly-slide-toggle.component'; import { FormlyDatePickerComponent } from './formly-date-picker/formly-date-picker.component'; @@ -84,6 +85,12 @@ import { FormlyDatePickerComponent } from './formly-date-picker/formly-date-pick extends: 'input', wrappers: ['form-field'], }, + { + name: 'start-page-select', + component: StartPageSelectComponent, + extends: 'input', + wrappers: ['form-field'], + }, { name: 'tag-select', component: FormlyTagSelectionComponent,