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.
This commit is contained in:
Johannes Millan 2026-04-18 22:42:30 +02:00
parent 3ed287516a
commit 0dd97e122d
10 changed files with 379 additions and 17 deletions

166
src/app/app.guard.spec.ts Normal file
View file

@ -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<UrlTree> => {
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> = {}): 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);
});
});

View file

@ -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<UrlTree> {
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());
}
}
}

View file

@ -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,
}

View file

@ -104,14 +104,10 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection<MiscConfig> = {
: []) as LimitedFormlyFieldConfig<MiscConfig>[]),
{
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 },
],
},
},
],

View file

@ -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

View file

@ -0,0 +1,39 @@
<mat-select
[errorStateMatcher]="errorStateMatcher"
[formControl]="formControl"
[formlyAttributes]="field"
[id]="id"
>
<mat-option [value]="DefaultStartPage.Today">{{
T.G.TODAY_TAG_TITLE | translate
}}</mat-option>
<!-- Legacy: render only when the user still has the old numeric Inbox value,
so the dropdown reflects their stored selection. Picking any other
option removes it from the list on the next change detection pass. -->
@if (formControl.value === DefaultStartPage.Inbox) {
<mat-option [value]="DefaultStartPage.Inbox">{{
T.G.INBOX_PROJECT_TITLE | translate
}}</mat-option>
}
@if (appFeatures().isPlannerEnabled) {
<mat-option [value]="DefaultStartPage.Planner">{{
T.MH.PLANNER | translate
}}</mat-option>
}
@if (appFeatures().isSchedulerEnabled) {
<mat-option [value]="DefaultStartPage.Schedule">{{
T.MH.SCHEDULE | translate
}}</mat-option>
}
@if (appFeatures().isBoardsEnabled) {
<mat-option [value]="DefaultStartPage.Boards">{{
T.MH.BOARDS | translate
}}</mat-option>
}
@for (opt of projects(); track opt.id) {
<mat-option [value]="opt.id">{{ opt.title }}</mat-option>
}
</mat-select>

View file

@ -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<FormlyFieldConfig> {
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;
}

View file

@ -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$', () => {

View file

@ -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 },

View file

@ -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,