diff --git a/src/app/features/work-context/work-context.service.spec.ts b/src/app/features/work-context/work-context.service.spec.ts index 3070e95ff7..36421a2f37 100644 --- a/src/app/features/work-context/work-context.service.spec.ts +++ b/src/app/features/work-context/work-context.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; -import { WorkContextService } from './work-context.service'; +import { resolveContextTheme, WorkContextService } from './work-context.service'; +import { DEFAULT_TAG_COLOR, WORK_CONTEXT_DEFAULT_THEME } from './work-context.const'; import { TaskWithSubTasks } from '../tasks/task.model'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { provideMockActions } from '@ngrx/effects/testing'; @@ -744,3 +745,88 @@ describe('WorkContextService - isTodayListSignal reactivity', () => { expect(service.isTodayListSignal()).toBe(false); }); }); + +describe('resolveContextTheme()', () => { + const buildCtx = (over: Record = {}): WorkContext => + ({ + id: 'TODAY', + title: 'Today', + type: WorkContextType.TAG, + routerLink: 'tag/TODAY', + taskIds: [], + noteIds: [], + color: null, + theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: '#123456' }, + ...over, + }) as unknown as WorkContext; + + describe('regression #9139: work context persisted with no theme', () => { + // A tag/project entity stored without `theme` propagated `undefined` into + // resolveBackground() and _setColorTheme(), crashing on every launch. + it('returns the default theme instead of undefined', () => { + const ctx = buildCtx(); + delete (ctx as unknown as Record).theme; + + expect(resolveContextTheme(ctx)).toEqual(WORK_CONTEXT_DEFAULT_THEME); + }); + + it('returns a theme the crashing consumers can dereference', () => { + const ctx = buildCtx(); + delete (ctx as unknown as Record).theme; + + const res = resolveContextTheme(ctx); + + // The exact fields whose unguarded deref produced the reported crashes: + // resolveBackground() reads backgroundImage*, _setColorTheme() reads + // isAutoContrast. + expect(res.backgroundImageDark).toBeDefined(); + expect(res.backgroundImageLight).toBeDefined(); + expect(res.isAutoContrast).toBeDefined(); + }); + + it('yields a COMPLETE theme when the tag-color fallback applies', () => { + const ctx = buildCtx({ color: '#abcdef' }); + delete (ctx as unknown as Record).theme; + + const res = resolveContextTheme(ctx); + + // Spreading an undefined theme would have produced just `{ primary }`. + expect(res.primary).toBe('#abcdef'); + expect(res.accent).toBe(WORK_CONTEXT_DEFAULT_THEME.accent); + expect(res.huePrimary).toBe(WORK_CONTEXT_DEFAULT_THEME.huePrimary); + }); + }); + + describe('existing behaviour is preserved', () => { + it('keeps an explicit primary override over tag.color', () => { + const res = resolveContextTheme( + buildCtx({ + theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: '#explicit' }, + color: '#tagcolor', + }), + ); + expect(res.primary).toBe('#explicit'); + }); + + it('falls back to tag.color when primary is still the auto-default', () => { + const res = resolveContextTheme( + buildCtx({ + theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: DEFAULT_TAG_COLOR }, + color: '#tagcolor', + }), + ); + expect(res.primary).toBe('#tagcolor'); + }); + + it('does not apply the tag-color fallback for projects', () => { + const res = resolveContextTheme( + buildCtx({ + type: WorkContextType.PROJECT, + theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: DEFAULT_TAG_COLOR }, + color: '#tagcolor', + }), + ); + expect(res.primary).toBe(DEFAULT_TAG_COLOR); + }); + }); +}); diff --git a/src/app/features/work-context/work-context.service.ts b/src/app/features/work-context/work-context.service.ts index 5ef2c09c7f..647dd404fa 100644 --- a/src/app/features/work-context/work-context.service.ts +++ b/src/app/features/work-context/work-context.service.ts @@ -28,7 +28,7 @@ import { } from 'rxjs/operators'; import { TODAY_TAG } from '../tag/tag.const'; import { Tag } from '../tag/tag.model'; -import { DEFAULT_TAG_COLOR } from './work-context.const'; +import { DEFAULT_TAG_COLOR, WORK_CONTEXT_DEFAULT_THEME } from './work-context.const'; import { TagService } from '../tag/tag.service'; import { ArchiveTask, Task, TaskWithSubTasks } from '../tasks/task.model'; import { @@ -76,6 +76,31 @@ import { Project } from '../project/project.model'; import { Log } from '../../core/log'; import { LOCAL_ACTIONS } from '../../util/local-actions.token'; +/** + * Resolve the theme to apply for a work context. + * + * `WorkContextCommon.theme` is declared required, but persisted data can lack + * it: validation at hydration is non-fatal, so a snapshot holding a theme-less + * tag loads anyway and every consumer then dereferences `undefined` (#9139 — + * this crashed both `resolveBackground` and `_setColorTheme` on every launch). + * Defaulting here gives the theme pipeline a single choke point. + */ +export const resolveContextTheme = (awc: WorkContext): WorkContextThemeCfg => { + const theme = awc.theme ?? WORK_CONTEXT_DEFAULT_THEME; + // For tags: theme.primary is the explicit override. If it's still at + // the auto-default (or unset) and tag.color is set, fall back to + // tag.color so newly created tags drive Material theming with their + // randomized color while still letting users override explicitly. + if (awc.type === WorkContextType.TAG) { + const tagColor = (awc as unknown as Tag).color; + const primary = theme.primary; + if (tagColor && (!primary || primary === DEFAULT_TAG_COLOR)) { + return { ...theme, primary: tagColor }; + } + } + return theme; +}; + @Injectable({ providedIn: 'root', }) @@ -230,20 +255,7 @@ export class WorkContextService { ); currentTheme$: Observable = this.activeWorkContext$.pipe( - map((awc) => { - // For tags: theme.primary is the explicit override. If it's still at - // the auto-default (or unset) and tag.color is set, fall back to - // tag.color so newly created tags drive Material theming with their - // randomized color while still letting users override explicitly. - if (awc.type === WorkContextType.TAG) { - const tagColor = (awc as unknown as Tag).color; - const primary = awc.theme?.primary; - if (tagColor && (!primary || primary === DEFAULT_TAG_COLOR)) { - return { ...awc.theme, primary: tagColor }; - } - } - return awc.theme; - }), + map(resolveContextTheme), distinctUntilChanged(isShallowEqual), ); diff --git a/src/app/op-log/validation/auto-fix-typia-errors.spec.ts b/src/app/op-log/validation/auto-fix-typia-errors.spec.ts index 6518c0d318..bbf3db1caf 100644 --- a/src/app/op-log/validation/auto-fix-typia-errors.spec.ts +++ b/src/app/op-log/validation/auto-fix-typia-errors.spec.ts @@ -4,6 +4,8 @@ import type { IValidation } from 'typia'; import { initialTaskState } from '../../features/tasks/store/task.reducer'; import { DEFAULT_TASK } from '../../features/tasks/task.model'; import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; +import { DEFAULT_TAG } from '../../features/tag/tag.const'; +import { DEFAULT_PROJECT } from '../../features/project/project.const'; const createTypiaError = ( path: string, @@ -480,4 +482,120 @@ describe('autoFixTypiaErrors', () => { ); }); }); + + describe('issue #9139 — tag/project entities missing `theme` entirely', () => { + // The `expected` string typia ACTUALLY emits for this error, captured by + // running the real validator. It is a generated anonymous type name whose + // ordinal suffix moves whenever the type graph changes — which is exactly + // why the fix must not key on it. See the no-longer-brittle test below. + const REAL_EXPECTED = 'Readonly<__type>.o26'; + + it('should backfill a missing tag.theme with the default tag theme', () => { + const mockData = createAppDataCompleteMock(); + (mockData as any).tag = { + ids: ['TODAY'], + entities: { TODAY: { id: 'TODAY', title: 'Today', taskIds: [] } }, + }; + const errors = [ + createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, undefined), + ]; + + const result = autoFixTypiaErrors(mockData, errors); + + expect((result as any).tag.entities.TODAY.theme).toEqual(DEFAULT_TAG.theme); + expect(errSpy).toHaveBeenCalledWith( + '[auto-fix-typia-errors] Applied validation auto-fix', + undefined, + jasmine.objectContaining({ + fix: 'work-context-theme-undefined-to-default', + pathRoot: 'tag', + }), + ); + }); + + it('should backfill a missing project.theme with the default project theme', () => { + const mockData = createAppDataCompleteMock(); + (mockData as any).project = { + ids: ['p1'], + entities: { p1: { id: 'p1', title: 'Proj', taskIds: [] } }, + }; + const errors = [ + createTypiaError('$input.project.entities.p1.theme', REAL_EXPECTED, undefined), + ]; + + const result = autoFixTypiaErrors(mockData, errors); + + expect((result as any).project.entities.p1.theme).toEqual(DEFAULT_PROJECT.theme); + }); + + it('should still fire when typia renames the generated `expected` type', () => { + // Guards the #9045 failure mode: a branch keyed on `error.expected` + // would silently stop firing the moment the ordinal shifts. This fix + // matches on path + `value === undefined` only, so a renamed type must + // not change the outcome. + const mockData = createAppDataCompleteMock(); + (mockData as any).tag = { + ids: ['TODAY'], + entities: { TODAY: { id: 'TODAY', title: 'Today', taskIds: [] } }, + }; + const errors = [ + createTypiaError( + '$input.tag.entities.TODAY.theme', + 'Readonly<__type>.o99999', + undefined, + ), + ]; + + const result = autoFixTypiaErrors(mockData, errors); + + expect((result as any).tag.entities.TODAY.theme).toEqual(DEFAULT_TAG.theme); + }); + + it('should backfill a COPY, never the shared default constant', () => { + // Aliasing DEFAULT_TAG.theme across entities would let any later + // mutation write through into the app-wide constant. + const mockData = createAppDataCompleteMock(); + (mockData as any).tag = { + ids: ['TODAY', 't2'], + entities: { + TODAY: { id: 'TODAY', title: 'Today', taskIds: [] }, + t2: { id: 't2', title: 'Other', taskIds: [] }, + }, + }; + const errors = [ + createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, undefined), + createTypiaError('$input.tag.entities.t2.theme', REAL_EXPECTED, undefined), + ]; + + const result = autoFixTypiaErrors(mockData, errors); + + const a = (result as any).tag.entities.TODAY.theme; + const b = (result as any).tag.entities.t2.theme; + expect(a).not.toBe(DEFAULT_TAG.theme); + expect(b).not.toBe(DEFAULT_TAG.theme); + expect(a).not.toBe(b); + expect(a).toEqual(DEFAULT_TAG.theme); + }); + + it('should NOT overwrite a theme that is present but merely invalid', () => { + // `value === undefined` scopes this to a genuinely absent field; a + // present-but-wrong theme is a different problem and must not be + // silently replaced (that would discard user styling). + const mockData = createAppDataCompleteMock(); + const existingTheme = { primary: '#user-picked' }; + (mockData as any).tag = { + ids: ['TODAY'], + entities: { + TODAY: { id: 'TODAY', title: 'Today', taskIds: [], theme: existingTheme }, + }, + }; + const errors = [ + createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, existingTheme), + ]; + + const result = autoFixTypiaErrors(mockData, errors); + + expect((result as any).tag.entities.TODAY.theme).toEqual(existingTheme); + }); + }); }); diff --git a/src/app/op-log/validation/auto-fix-typia-errors.ts b/src/app/op-log/validation/auto-fix-typia-errors.ts index eef5f8f988..6f17386b54 100644 --- a/src/app/op-log/validation/auto-fix-typia-errors.ts +++ b/src/app/op-log/validation/auto-fix-typia-errors.ts @@ -2,7 +2,8 @@ import { AppDataComplete } from '../model/model-config'; import { IValidation } from 'typia'; import type { SyncLogMeta } from '@sp/sync-core'; import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const'; -import { INBOX_PROJECT } from '../../features/project/project.const'; +import { DEFAULT_PROJECT, INBOX_PROJECT } from '../../features/project/project.const'; +import { DEFAULT_TAG } from '../../features/tag/tag.const'; import { RECREATE_FALLBACK } from '../core/recreate-fallback.const'; import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; import { devError } from '../../util/dev-error'; @@ -271,6 +272,36 @@ export const autoFixTypiaErrors = ( const created = Date.now(); setValueByPath(data, keys, created); logAutoFixApplied(path, keys, 'tag-created-undefined-to-now', value, created); + } else if ( + (keys[0] === 'tag' || keys[0] === 'project') && + keys[1] === 'entities' && + keys.length === 4 && + keys[3] === 'theme' && + value === undefined + ) { + // A tag/project entity can be persisted with no `theme` at all (#9139). + // Left unrepaired it either dead-ends legacy migration ("Migration + // failed") or, on the hydration paths where validation is non-fatal, + // loads and crashes the theme pipeline on every launch. + // + // Deliberately NOT matched on `error.expected`: typia reports this as + // the generated name `Readonly<__type>.oNN`, whose ordinal shifts + // whenever the type graph changes. Keying on it would make this branch + // silently stop firing. Path + `value === undefined` is stable. + // Copy, never share the constant by reference: two theme-less entities + // would otherwise alias one object, and any later mutation would write + // straight through into DEFAULT_TAG/DEFAULT_PROJECT for the whole app. + const theme = { + ...(keys[0] === 'tag' ? DEFAULT_TAG.theme : DEFAULT_PROJECT.theme), + }; + setValueByPath(data, keys, theme); + logAutoFixApplied( + path, + keys, + 'work-context-theme-undefined-to-default', + value, + theme, + ); } else if ( keys[0] === 'metric' && keys[1] === 'entities' &&