diff --git a/packages/shared-schema/src/migrate.ts b/packages/shared-schema/src/migrate.ts index f62eac318..8cb7a9b91 100644 --- a/packages/shared-schema/src/migrate.ts +++ b/packages/shared-schema/src/migrate.ts @@ -98,16 +98,17 @@ export function migrateState( /** * Migrate a single operation from its version to targetVersion. * Returns null if the operation should be dropped (e.g., removed feature). + * Returns an array if the operation should be split into multiple operations. * Pure function - no side effects. * * @param op - The operation to migrate * @param targetVersion - Target version (defaults to CURRENT_SCHEMA_VERSION) - * @returns Migration result with transformed operation, null, or error + * @returns Migration result with transformed operation(s), null, or error */ export function migrateOperation( op: OperationLike, targetVersion: number = CURRENT_SCHEMA_VERSION, -): MigrationResult { +): MigrationResult { const sourceVersion = op.schemaVersion ?? 1; // Validate source version @@ -123,11 +124,12 @@ export function migrateOperation( return { success: true, data: op }; } - let currentOp: OperationLike | null = { ...op }; + // Start with an array containing the single operation + let currentOps: OperationLike[] = [{ ...op }]; let version = sourceVersion; // Apply migrations sequentially - while (version < targetVersion && currentOp !== null) { + while (version < targetVersion && currentOps.length > 0) { const migration = findMigration(version); if (!migration) { return { @@ -139,18 +141,30 @@ export function migrateOperation( } try { - if (migration.migrateOperation) { - currentOp = migration.migrateOperation(currentOp); - if (currentOp !== null) { - // Update version on the migrated operation - currentOp = { ...currentOp, schemaVersion: migration.toVersion }; + const nextOps: OperationLike[] = []; + + for (const currentOp of currentOps) { + if (migration.migrateOperation) { + const result = migration.migrateOperation(currentOp); + if (result === null) { + // Operation dropped, don't add to nextOps + continue; + } else if (Array.isArray(result)) { + // Operation split into multiple + for (const r of result) { + nextOps.push({ ...r, schemaVersion: migration.toVersion }); + } + } else { + // Single operation returned + nextOps.push({ ...result, schemaVersion: migration.toVersion }); + } + } else { + // No operation migration defined - just update version + nextOps.push({ ...currentOp, schemaVersion: migration.toVersion }); } - } else { - // No operation migration defined - just update version - currentOp = { ...currentOp, schemaVersion: migration.toVersion }; } - // Track version even if operation was dropped (null) - // This ensures migratedToVersion reflects where we actually stopped + + currentOps = nextOps; version = migration.toVersion; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -163,17 +177,35 @@ export function migrateOperation( } } - return { - success: true, - data: currentOp, - migratedFromVersion: sourceVersion, - migratedToVersion: version, - }; + // Return based on the number of resulting operations + if (currentOps.length === 0) { + return { + success: true, + data: null, + migratedFromVersion: sourceVersion, + migratedToVersion: version, + }; + } else if (currentOps.length === 1) { + return { + success: true, + data: currentOps[0], + migratedFromVersion: sourceVersion, + migratedToVersion: version, + }; + } else { + return { + success: true, + data: currentOps, + migratedFromVersion: sourceVersion, + migratedToVersion: version, + }; + } } /** * Migrate an array of operations. * Drops operations that return null from migration. + * Handles operations that are split into multiple operations. * * @param ops - Array of operations to migrate * @param targetVersion - Target version (defaults to CURRENT_SCHEMA_VERSION) @@ -195,10 +227,14 @@ export function migrateOperations( }; } - if (result.data !== null && result.data !== undefined) { - migrated.push(result.data); - } else { + if (result.data === null || result.data === undefined) { droppedCount++; + } else if (Array.isArray(result.data)) { + // Operation was split into multiple operations + migrated.push(...result.data); + } else { + // Single operation + migrated.push(result.data); } } diff --git a/packages/shared-schema/src/migration.types.ts b/packages/shared-schema/src/migration.types.ts index 3beebc26c..0c1bf7138 100644 --- a/packages/shared-schema/src/migration.types.ts +++ b/packages/shared-schema/src/migration.types.ts @@ -50,9 +50,11 @@ export interface SchemaMigration { /** * Transform an individual operation payload. * Return null to drop the operation entirely (e.g., for removed features). + * Return an array to split one operation into multiple (e.g., when moving + * settings from one config section to another). * Only required for non-additive changes (renames, removals, type changes). */ - migrateOperation?: (op: OperationLike) => OperationLike | null; + migrateOperation?: (op: OperationLike) => OperationLike | OperationLike[] | null; /** * Explicit declaration that forces migration authors to think about diff --git a/packages/shared-schema/src/migrations/index.ts b/packages/shared-schema/src/migrations/index.ts index be7a23aa9..27fe4e6c5 100644 --- a/packages/shared-schema/src/migrations/index.ts +++ b/packages/shared-schema/src/migrations/index.ts @@ -1,4 +1,5 @@ import type { SchemaMigration } from '../migration.types'; +import { MiscToTasksSettingsMigration_v1v2 } from './misc-to-tasks-settings-migration-v1-to-v2'; /** * Registry of all schema migrations. @@ -25,6 +26,4 @@ import type { SchemaMigration } from '../migration.types'; * } * ``` */ -export const MIGRATIONS: SchemaMigration[] = [ - // No migrations yet - schema version 1 is the initial version -]; +export const MIGRATIONS: SchemaMigration[] = [MiscToTasksSettingsMigration_v1v2]; diff --git a/packages/shared-schema/src/migrations/misc-to-tasks-settings-migration-v1-to-v2.ts b/packages/shared-schema/src/migrations/misc-to-tasks-settings-migration-v1-to-v2.ts new file mode 100644 index 000000000..285039e9b --- /dev/null +++ b/packages/shared-schema/src/migrations/misc-to-tasks-settings-migration-v1-to-v2.ts @@ -0,0 +1,163 @@ +import { OperationLike, SchemaMigration } from '../migration.types'; + +/** + * Mapping from old misc field names to new tasks field names. + * Key: old field name in misc + * Value: new field name in tasks (or transform function) + */ +const FIELD_MAPPINGS: Record [string, unknown])> = { + isConfirmBeforeTaskDelete: 'isConfirmBeforeDelete', + isAutoAddWorkedOnToToday: 'isAutoAddWorkedOnToToday', + isAutMarkParentAsDone: 'isAutoMarkParentAsDone', // Fixed typo + isTrayShowCurrentTask: 'isTrayShowCurrent', + isTurnOffMarkdown: (value) => ['isMarkdownFormattingInNotesEnabled', !value], // Inverted + defaultProjectId: 'defaultProjectId', + taskNotesTpl: 'notesTemplate', +}; + +const MIGRATED_FIELDS = Object.keys(FIELD_MAPPINGS); + +export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = { + fromVersion: 1, + toVersion: 2, + description: 'Move settings from MiscConfig to TasksConfig.', + + migrateState: (state: any) => { + const misc = state.globalConfig?.misc; + if (!misc || !hasMigratedFields(misc)) { + return state; + } + + const tasks = state.globalConfig?.tasks ?? {}; + + // Skip if already migrated (tasks has new fields) + if (tasks.isConfirmBeforeDelete !== undefined) { + return state; + } + + return { + ...state, + globalConfig: { + ...state.globalConfig, + misc: removeMigratedFields(misc), + tasks: { ...tasks, ...transformMiscToTasks(misc) }, + }, + }; + }, + + requiresOperationMigration: true, + migrateOperation: (op: OperationLike): OperationLike | OperationLike[] | null => { + if (op.entityType !== 'GLOBAL_CONFIG' || op.entityId !== 'misc') { + return op; + } + + const payload = op.payload as Record | undefined; + if (!payload || typeof payload !== 'object') { + return op; + } + + // Extract sectionCfg from various payload formats + let sectionCfg: Record | null = null; + let actionPayload: Record | null = null; + + if (isMultiEntityPayload(payload)) { + actionPayload = payload.actionPayload; + sectionCfg = extractSectionCfg(actionPayload); + } else if ('sectionCfg' in payload) { + actionPayload = payload; + sectionCfg = extractSectionCfg(payload); + } else { + sectionCfg = payload; + } + + if (!sectionCfg || !hasMigratedFields(sectionCfg)) { + return op; + } + + // Transform settings + const tasksCfg = transformMiscToTasks(sectionCfg); + const miscCfg = removeMigratedFields(sectionCfg); + + // Build payload for the new operation + const buildPayload = (cfg: Record, sectionKey: string): unknown => { + if (isMultiEntityPayload(payload)) { + return { + ...payload, + actionPayload: { ...actionPayload, sectionKey, sectionCfg: cfg }, + }; + } else if (actionPayload && 'sectionCfg' in actionPayload) { + return { ...actionPayload, sectionKey, sectionCfg: cfg }; + } + return cfg; + }; + + const result: OperationLike[] = []; + + if (Object.keys(miscCfg).length > 0) { + result.push({ ...op, payload: buildPayload(miscCfg, 'misc') }); + } + + if (Object.keys(tasksCfg).length > 0) { + result.push({ + ...op, + id: `${op.id}_tasks_migrated`, + entityId: 'tasks', + payload: buildPayload(tasksCfg, 'tasks'), + }); + } + + return result.length === 0 ? null : result.length === 1 ? result[0] : result; + }, +}; + +function transformMiscToTasks(miscCfg: Record): Record { + const tasksCfg: Record = {}; + + for (const [oldKey, mapping] of Object.entries(FIELD_MAPPINGS)) { + if (oldKey in miscCfg) { + if (typeof mapping === 'function') { + const [newKey, newValue] = mapping(miscCfg[oldKey]); + tasksCfg[newKey] = newValue; + } else { + tasksCfg[mapping] = miscCfg[oldKey]; + } + } + } + + return tasksCfg; +} + +function removeMigratedFields(miscCfg: Record): Record { + const result = { ...miscCfg }; + for (const key of MIGRATED_FIELDS) { + delete result[key]; + } + return result; +} + +function hasMigratedFields(cfg: Record): boolean { + return MIGRATED_FIELDS.some((key) => key in cfg); +} + +interface MultiEntityPayload { + actionPayload: Record; + entityChanges?: unknown[]; +} + +function isMultiEntityPayload(payload: unknown): payload is MultiEntityPayload { + return ( + payload !== null && + typeof payload === 'object' && + 'actionPayload' in payload && + typeof (payload as MultiEntityPayload).actionPayload === 'object' + ); +} + +function extractSectionCfg( + actionPayload: Record, +): Record | null { + if ('sectionCfg' in actionPayload && typeof actionPayload['sectionCfg'] === 'object') { + return actionPayload['sectionCfg'] as Record; + } + return null; +} diff --git a/packages/shared-schema/src/schema-version.ts b/packages/shared-schema/src/schema-version.ts index f82922b81..92997bb47 100644 --- a/packages/shared-schema/src/schema-version.ts +++ b/packages/shared-schema/src/schema-version.ts @@ -2,7 +2,7 @@ * Current schema version for all operations and state snapshots. * Increment this BEFORE adding a new migration. */ -export const CURRENT_SCHEMA_VERSION = 1; +export const CURRENT_SCHEMA_VERSION = 2; /** * Minimum schema version that this codebase can still handle. diff --git a/packages/shared-schema/tests/migrate.spec.ts b/packages/shared-schema/tests/migrate.spec.ts index a6d11cfe8..e8f97cafd 100644 --- a/packages/shared-schema/tests/migrate.spec.ts +++ b/packages/shared-schema/tests/migrate.spec.ts @@ -182,13 +182,14 @@ describe('shared-schema migration functions', () => { }); describe('validateMigrationRegistry', () => { - it('returns empty array when no migrations and version is 1', () => { - // Only valid if CURRENT_SCHEMA_VERSION is 1 - if (CURRENT_SCHEMA_VERSION === 1) { - const errors = validateMigrationRegistry(); - expect(errors).toEqual([]); - } - }); + // @todo: How can we change this test to check this behavior and increase CURRENT_SCHEMA_VERSION? + // it('returns empty array when no migrations and version is 1', () => { + // // Only valid if CURRENT_SCHEMA_VERSION is 1 + // if (CURRENT_SCHEMA_VERSION === 1) { + // const errors = validateMigrationRegistry(); + // expect(errors).toEqual([]); + // } + // }); it('returns errors when CURRENT_SCHEMA_VERSION > 1 but no migrations', () => { // This is a consistency check for when we add migrations diff --git a/packages/shared-schema/tests/migrations/misc-to-tasks-settings-migration-v1-to-v2.spec.ts b/packages/shared-schema/tests/migrations/misc-to-tasks-settings-migration-v1-to-v2.spec.ts new file mode 100644 index 000000000..dc39e8598 --- /dev/null +++ b/packages/shared-schema/tests/migrations/misc-to-tasks-settings-migration-v1-to-v2.spec.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; +import { MIGRATIONS } from '../../src/migrations'; +import { OperationLike } from '../../src/migration.types'; + +describe('Migrate MiscConfig to TasksConfig', () => { + const migration = MIGRATIONS.find((m) => m.fromVersion === 1 && m.toVersion === 2); + + if (!migration) { + throw new Error('Migration for version 1 to 2 not found'); + } + + describe('migrateState', () => { + it('should migrate settings from misc to tasks', () => { + const initialState = { + globalConfig: { + misc: { + isConfirmBeforeTaskDelete: true, + isAutoAddWorkedOnToToday: true, + isAutMarkParentAsDone: false, + isTrayShowCurrentTask: true, + isTurnOffMarkdown: false, + defaultProjectId: 'project_1', + taskNotesTpl: 'Template', + }, + tasks: {}, + }, + }; + + const migratedState = migration.migrateState(initialState) as { + globalConfig: { + misc: Record; + tasks: Record; + }; + }; + + expect(migratedState.globalConfig.tasks).toEqual({ + isConfirmBeforeDelete: true, + isAutoAddWorkedOnToToday: true, + isAutoMarkParentAsDone: false, + isTrayShowCurrent: true, + isMarkdownFormattingInNotesEnabled: true, + defaultProjectId: 'project_1', + notesTemplate: 'Template', + }); + + expect(migratedState.globalConfig.misc).toEqual({}); + }); + + it('should not modify state if misc is empty', () => { + const initialState = { + globalConfig: { + misc: {}, + tasks: {}, + }, + }; + + const migratedState = migration.migrateState(initialState) as { + globalConfig: { + misc: Record; + tasks: Record; + }; + }; + + expect(migratedState).toEqual(initialState); + }); + + it('should not modify state if tasks already migrated', () => { + const initialState = { + globalConfig: { + misc: { + isConfirmBeforeTaskDelete: true, + }, + tasks: { + isConfirmBeforeDelete: true, + }, + }, + }; + + const migratedState = migration.migrateState(initialState) as { + globalConfig: { + misc: Record; + tasks: Record; + }; + }; + + expect(migratedState).toEqual(initialState); + }); + }); + + describe('migrateOperation', () => { + it('should split misc operation into misc and tasks operations', () => { + const op: OperationLike = { + id: 'op_1', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + isConfirmBeforeTaskDelete: true, + isTurnOffMarkdown: true, + isMinimizeToTray: false, + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(Array.isArray(result)).toBe(true); + const resultArray = result as OperationLike[]; + expect(resultArray.length).toBe(2); + + // First operation should be misc with non-migrated settings + const miscOp = resultArray.find((r) => r.entityId === 'misc'); + expect(miscOp).toBeDefined(); + expect(miscOp!.payload).toEqual({ + isMinimizeToTray: false, + }); + + // Second operation should be tasks with migrated settings + const tasksOp = resultArray.find((r) => r.entityId === 'tasks'); + expect(tasksOp).toBeDefined(); + expect(tasksOp!.id).toBe('op_1_tasks_migrated'); + expect(tasksOp!.payload).toEqual({ + isConfirmBeforeDelete: true, + isMarkdownFormattingInNotesEnabled: false, // Inverted from isTurnOffMarkdown: true + }); + }); + + it('should return only tasks operation when misc payload contains only migrated settings', () => { + const op: OperationLike = { + id: 'op_2', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + isAutoAddWorkedOnToToday: true, + defaultProjectId: 'project_123', + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + // Should return single tasks operation + expect(Array.isArray(result)).toBe(false); + const singleOp = result as OperationLike; + expect(singleOp.entityId).toBe('tasks'); + expect(singleOp.payload).toEqual({ + isAutoAddWorkedOnToToday: true, + defaultProjectId: 'project_123', + }); + }); + + it('should not modify non-GLOBAL_CONFIG operations', () => { + const op: OperationLike = { + id: 'op_3', + opType: 'UPD', + entityType: 'TASK', + entityId: 'task_1', + payload: { title: 'Test task' }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(result).toEqual(op); + }); + + it('should not modify GLOBAL_CONFIG operations with non-misc entityId', () => { + const op: OperationLike = { + id: 'op_4', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'keyboard', + payload: { someKey: 'value' }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(result).toEqual(op); + }); + + it('should not modify misc operations without migrated settings', () => { + const op: OperationLike = { + id: 'op_5', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + isMinimizeToTray: true, + isConfirmBeforeExit: false, + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(result).toEqual(op); + }); + + it('should correctly invert isTurnOffMarkdown to isMarkdownFormattingInNotesEnabled', () => { + const opWithMarkdownOff: OperationLike = { + id: 'op_6', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { isTurnOffMarkdown: true }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(opWithMarkdownOff) as OperationLike; + expect(result.entityId).toBe('tasks'); + expect( + (result.payload as Record)['isMarkdownFormattingInNotesEnabled'], + ).toBe(false); + + const opWithMarkdownOn: OperationLike = { + id: 'op_7', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { isTurnOffMarkdown: false }, + schemaVersion: 1, + }; + + const result2 = migration.migrateOperation!(opWithMarkdownOn) as OperationLike; + expect(result2.entityId).toBe('tasks'); + expect( + (result2.payload as Record)[ + 'isMarkdownFormattingInNotesEnabled' + ], + ).toBe(true); + }); + + it('should rename isAutMarkParentAsDone to isAutoMarkParentAsDone', () => { + const op: OperationLike = { + id: 'op_8', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { isAutMarkParentAsDone: true }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op) as OperationLike; + expect(result.entityId).toBe('tasks'); + expect((result.payload as Record)['isAutoMarkParentAsDone']).toBe( + true, + ); + expect( + (result.payload as Record)['isAutMarkParentAsDone'], + ).toBeUndefined(); + }); + + describe('MultiEntityPayload format', () => { + it('should handle MultiEntityPayload with sectionCfg containing migrated settings', () => { + const op: OperationLike = { + id: 'op_multi_1', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + actionPayload: { + sectionKey: 'misc', + sectionCfg: { + isConfirmBeforeTaskDelete: true, + isTurnOffMarkdown: true, + isMinimizeToTray: false, + }, + }, + entityChanges: [], + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(Array.isArray(result)).toBe(true); + const resultArray = result as OperationLike[]; + expect(resultArray.length).toBe(2); + + // Misc operation + const miscOp = resultArray.find((r) => r.entityId === 'misc'); + expect(miscOp).toBeDefined(); + const miscPayload = miscOp!.payload as { + actionPayload: { sectionKey: string; sectionCfg: Record }; + }; + expect(miscPayload.actionPayload.sectionKey).toBe('misc'); + expect(miscPayload.actionPayload.sectionCfg).toEqual({ + isMinimizeToTray: false, + }); + + // Tasks operation + const tasksOp = resultArray.find((r) => r.entityId === 'tasks'); + expect(tasksOp).toBeDefined(); + const tasksPayload = tasksOp!.payload as { + actionPayload: { sectionKey: string; sectionCfg: Record }; + }; + expect(tasksPayload.actionPayload.sectionKey).toBe('tasks'); + expect(tasksPayload.actionPayload.sectionCfg).toEqual({ + isConfirmBeforeDelete: true, + isMarkdownFormattingInNotesEnabled: false, + }); + }); + + it('should handle MultiEntityPayload with only migrated settings', () => { + const op: OperationLike = { + id: 'op_multi_2', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + actionPayload: { + sectionKey: 'misc', + sectionCfg: { + defaultProjectId: 'project_abc', + taskNotesTpl: 'My template', + }, + }, + entityChanges: [], + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + // Should return single tasks operation + expect(Array.isArray(result)).toBe(false); + const singleOp = result as OperationLike; + expect(singleOp.entityId).toBe('tasks'); + const payload = singleOp.payload as { + actionPayload: { sectionKey: string; sectionCfg: Record }; + }; + expect(payload.actionPayload.sectionKey).toBe('tasks'); + expect(payload.actionPayload.sectionCfg).toEqual({ + defaultProjectId: 'project_abc', + notesTemplate: 'My template', + }); + }); + + it('should not modify MultiEntityPayload without migrated settings', () => { + const op: OperationLike = { + id: 'op_multi_3', + opType: 'UPD', + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + payload: { + actionPayload: { + sectionKey: 'misc', + sectionCfg: { + isMinimizeToTray: true, + isConfirmBeforeExit: false, + }, + }, + entityChanges: [], + }, + schemaVersion: 1, + }; + + const result = migration.migrateOperation!(op); + + expect(result).toEqual(op); + }); + }); + }); +}); diff --git a/packages/super-sync-server/src/sync/services/snapshot.service.ts b/packages/super-sync-server/src/sync/services/snapshot.service.ts index b4d1ecf1b..5eae8d1ef 100644 --- a/packages/super-sync-server/src/sync/services/snapshot.service.ts +++ b/packages/super-sync-server/src/sync/services/snapshot.service.ts @@ -537,6 +537,15 @@ export class SnapshotService { let payload = row.payload; const opSchemaVersion = row.schemaVersion ?? 1; + + // Prepare list of operations to process (may be expanded by migration) + let opsToProcess: Array<{ + opType: string; + entityType: string; + entityId: string | null; + payload: unknown; + }> = [{ opType, entityType, entityId, payload }]; + if (opSchemaVersion < CURRENT_SCHEMA_VERSION) { const opLike: OperationLike = { id: row.id, @@ -554,71 +563,109 @@ export class SnapshotService { const migratedOp = migrationResult.data; if (!migratedOp) continue; - opType = migratedOp.opType as Operation['opType']; - entityType = migratedOp.entityType; - entityId = migratedOp.entityId ?? null; - payload = migratedOp.payload as any; - } - - // Handle full-state operations BEFORE entity type check - // These operations replace the entire state and don't use a specific entity type - if (opType === 'SYNC_IMPORT' || opType === 'BACKUP_IMPORT' || opType === 'REPAIR') { - if (payload && typeof payload === 'object' && 'appDataComplete' in payload) { - Object.assign(state, (payload as { appDataComplete: unknown }).appDataComplete); + // Handle array result (operation was split into multiple) + if (Array.isArray(migratedOp)) { + opsToProcess = migratedOp.map((op) => ({ + opType: op.opType, + entityType: op.entityType, + entityId: op.entityId ?? null, + payload: op.payload, + })); } else { - Object.assign(state, payload); + opsToProcess = [ + { + opType: migratedOp.opType, + entityType: migratedOp.entityType, + entityId: migratedOp.entityId ?? null, + payload: migratedOp.payload, + }, + ]; } - continue; } - if (!ALLOWED_ENTITY_TYPES.has(entityType)) continue; + // Process all operations (original or migrated) + for (const opToProcess of opsToProcess) { + const { + opType: processOpType, + entityType: processEntityType, + entityId: processEntityId, + payload: processPayload, + } = opToProcess; - if (!state[entityType]) { - state[entityType] = {}; - } + // Handle full-state operations BEFORE entity type check + // These operations replace the entire state and don't use a specific entity type + if ( + processOpType === 'SYNC_IMPORT' || + processOpType === 'BACKUP_IMPORT' || + processOpType === 'REPAIR' + ) { + if ( + processPayload && + typeof processPayload === 'object' && + 'appDataComplete' in processPayload + ) { + Object.assign( + state, + (processPayload as { appDataComplete: unknown }).appDataComplete, + ); + } else { + Object.assign(state, processPayload); + } + continue; + } - switch (opType) { - case 'CRT': - case 'UPD': - if (entityId) { - state[entityType][entityId] = { - ...(state[entityType][entityId] as Record), - ...(payload as Record), - }; - } - break; - case 'DEL': - if (entityId) { - delete state[entityType][entityId]; - } - break; - case 'MOV': - if (entityId && payload) { - state[entityType][entityId] = { - ...(state[entityType][entityId] as Record), - ...(payload as Record), - }; - } - break; - case 'BATCH': - if (payload && typeof payload === 'object') { - const batchPayload = payload as Record; - if (batchPayload.entities && typeof batchPayload.entities === 'object') { - const entities = batchPayload.entities as Record; - for (const [id, entity] of Object.entries(entities)) { - state[entityType][id] = { - ...(state[entityType][id] as Record), - ...(entity as Record), - }; - } - } else if (entityId) { - state[entityType][entityId] = { - ...(state[entityType][entityId] as Record), - ...batchPayload, + if (!ALLOWED_ENTITY_TYPES.has(processEntityType)) continue; + + if (!state[processEntityType]) { + state[processEntityType] = {}; + } + + switch (processOpType) { + case 'CRT': + case 'UPD': + if (processEntityId) { + state[processEntityType][processEntityId] = { + ...(state[processEntityType][processEntityId] as Record), + ...(processPayload as Record), }; } - } - break; + break; + case 'DEL': + if (processEntityId) { + delete state[processEntityType][processEntityId]; + } + break; + case 'MOV': + if (processEntityId && processPayload) { + state[processEntityType][processEntityId] = { + ...(state[processEntityType][processEntityId] as Record), + ...(processPayload as Record), + }; + } + break; + case 'BATCH': + if (processPayload && typeof processPayload === 'object') { + const batchPayload = processPayload as Record; + if (batchPayload.entities && typeof batchPayload.entities === 'object') { + const entities = batchPayload.entities as Record; + for (const [id, entity] of Object.entries(entities)) { + state[processEntityType][id] = { + ...(state[processEntityType][id] as Record), + ...(entity as Record), + }; + } + } else if (processEntityId) { + state[processEntityType][processEntityId] = { + ...(state[processEntityType][processEntityId] as Record< + string, + unknown + >), + ...batchPayload, + }; + } + } + break; + } } } return state; diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 9ff682f3e..7b99ab667 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -7,6 +7,13 @@ import { GlobalConfigState } from './global-config.model'; const minute = 60 * 1000; const defaultVoice = getDefaultVoice(); +const defaultTaskNotesTemplate = `**How can I best achieve it now?** + +**What do I want?** + +**Why do I want it?** +`; + export const DEFAULT_DAY_START = '9:00'; export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { appFeatures: { @@ -27,27 +34,24 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { dateTimeLocale: undefined, firstDayOfWeek: undefined, }, + tasks: { + isConfirmBeforeDelete: true, + isAutoAddWorkedOnToToday: true, + isAutoMarkParentAsDone: false, + isTrayShowCurrent: true, + defaultProjectId: null, + isMarkdownFormattingInNotesEnabled: true, + notesTemplate: defaultTaskNotesTemplate, + }, misc: { isConfirmBeforeExit: false, isConfirmBeforeExitWithoutFinishDay: true, - isConfirmBeforeTaskDelete: true, - isAutMarkParentAsDone: false, - isTurnOffMarkdown: false, - isAutoAddWorkedOnToToday: true, isMinimizeToTray: false, - isTrayShowCurrentTask: true, isTrayShowCurrentCountdown: true, - defaultProjectId: null, startOfNextDay: 0, isDisableAnimations: false, isDisableCelebration: false, isShowProductivityTipLonger: false, - taskNotesTpl: `**How can I best achieve it now?** - -**What do I want?** - -**Why do I want it?** -`, isOverlayIndicatorEnabled: false, customTheme: 'default', defaultStartPage: 0, 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 6e681f11b..d24bf8adb 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 @@ -5,20 +5,12 @@ import { } from '../global-config.model'; import { T } from '../../../t.const'; import { IS_ELECTRON } from '../../../app.constants'; -import { AVAILABLE_CUSTOM_THEMES } from '../../../core/theme/custom-theme.service'; export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { title: T.GCF.MISC.TITLE, key: 'misc', help: T.GCF.MISC.HELP, items: [ - // { - // key: 'isDarkMode', - // type: 'checkbox', - // templateOptions: { - // label: T.GCF.MISC.IS_DARK_MODE, - // }, - // }, ...((IS_ELECTRON ? [ { @@ -38,35 +30,6 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { }, }, ]) as LimitedFormlyFieldConfig[]), - { - key: 'isConfirmBeforeTaskDelete', - type: 'checkbox', - templateOptions: { - label: T.GCF.MISC.IS_CONFIRM_BEFORE_TASK_DELETE, - }, - }, - { - key: 'isAutMarkParentAsDone', - type: 'checkbox', - templateOptions: { - label: T.GCF.MISC.IS_AUTO_MARK_PARENT_AS_DONE, - }, - }, - - { - key: 'isTurnOffMarkdown', - type: 'checkbox', - templateOptions: { - label: T.GCF.MISC.IS_TURN_OFF_MARKDOWN, - }, - }, - { - key: 'isAutoAddWorkedOnToToday', - type: 'checkbox', - templateOptions: { - label: T.GCF.MISC.IS_AUTO_ADD_WORKED_ON_TO_TODAY, - }, - }, { key: 'isMinimizeToTray', type: 'checkbox', @@ -74,20 +37,6 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { label: T.GCF.MISC.IS_MINIMIZE_TO_TRAY, }, }, - { - key: 'isTrayShowCurrentTask', - type: 'checkbox', - templateOptions: { - label: T.GCF.MISC.IS_TRAY_SHOW_CURRENT_TASK, - }, - }, - { - key: 'defaultProjectId', - type: 'project-select', - templateOptions: { - label: T.GCF.MISC.DEFAULT_PROJECT, - }, - }, { key: 'startOfNextDay', type: 'input', @@ -101,14 +50,6 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { max: 23, }, }, - { - key: 'taskNotesTpl', - type: 'textarea', - templateOptions: { - rows: 5, - label: T.GCF.MISC.TASK_NOTES_TPL, - }, - }, { key: 'isDisableAnimations', type: 'checkbox', @@ -156,17 +97,6 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { }, ] : []) as LimitedFormlyFieldConfig[]), - { - key: 'customTheme', - type: 'select', - templateOptions: { - label: T.GCF.MISC.THEME, - options: AVAILABLE_CUSTOM_THEMES.map((theme) => ({ - label: theme.name, - value: theme.id, - })), - }, - }, { key: 'defaultStartPage', type: 'select', diff --git a/src/app/features/config/form-cfgs/tasks-settings-form.const.ts b/src/app/features/config/form-cfgs/tasks-settings-form.const.ts new file mode 100644 index 000000000..0820f22f9 --- /dev/null +++ b/src/app/features/config/form-cfgs/tasks-settings-form.const.ts @@ -0,0 +1,59 @@ +import { ConfigFormSection, TasksConfig } from '../global-config.model'; +import { T } from '../../../t.const'; + +export const TASKS_SETTINGS_FORM_CFG: ConfigFormSection = { + title: T.GCF.TASKS.TITLE, + key: 'tasks', + items: [ + { + key: 'isConfirmBeforeDelete', + type: 'checkbox', + templateOptions: { + label: T.GCF.TASKS.IS_CONFIRM_BEFORE_DELETE, + }, + }, + { + key: 'isAutoAddWorkedOnToToday', + type: 'checkbox', + templateOptions: { + label: T.GCF.TASKS.IS_AUTO_ADD_WORKED_ON_TO_TODAY, + }, + }, + { + key: 'isAutoMarkParentAsDone', + type: 'checkbox', + templateOptions: { + label: T.GCF.TASKS.IS_AUTO_MARK_PARENT_AS_DONE, + }, + }, + { + key: 'isTrayShowCurrent', + type: 'checkbox', + templateOptions: { + label: T.GCF.TASKS.IS_TRAY_SHOW_CURRENT, + }, + }, + { + key: 'isMarkdownFormattingInNotesEnabled', + type: 'checkbox', + templateOptions: { + label: T.GCF.TASKS.IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED, + }, + }, + { + key: 'defaultProjectId', + type: 'project-select', + templateOptions: { + label: T.GCF.TASKS.DEFAULT_PROJECT, + }, + }, + { + key: 'notesTemplate', + type: 'textarea', + templateOptions: { + rows: 5, + label: T.GCF.TASKS.NOTES_TEMPLATE, + }, + }, + ], +}; diff --git a/src/app/features/config/global-config-form-config.const.ts b/src/app/features/config/global-config-form-config.const.ts index d4f9eb192..af4348b6d 100644 --- a/src/app/features/config/global-config-form-config.const.ts +++ b/src/app/features/config/global-config-form-config.const.ts @@ -16,6 +16,7 @@ import { DOMINA_MODE_FORM } from './form-cfgs/domina-mode-form.const'; import { FOCUS_MODE_FORM_CFG } from './form-cfgs/focus-mode-form.const'; import { REMINDER_FORM_CFG } from './form-cfgs/reminder-form.const'; import { SHORT_SYNTAX_FORM_CFG } from './form-cfgs/short-syntax-form.const'; +import { TASKS_SETTINGS_FORM_CFG } from './form-cfgs/tasks-settings-form.const'; const filterGlobalConfigForm = (cfg: ConfigFormSection): boolean => { return ( @@ -29,7 +30,6 @@ export const GLOBAL_GENERAL_FORM_CONFIG: ConfigFormConfig = [ LANGUAGE_SELECTION_FORM_FORM, APP_FEATURES_FORM_CFG, MISC_SETTINGS_FORM_CFG, - SHORT_SYNTAX_FORM_CFG, KEYBOARD_SETTINGS_FORM_CFG, ].filter(filterGlobalConfigForm); @@ -58,3 +58,8 @@ export const GLOBAL_PRODUCTIVITY_FORM_CONFIG: ConfigFormConfig = [ SIMPLE_COUNTER_FORM, ...(!window.ea?.isSnap() && !!window.speechSynthesis ? [DOMINA_MODE_FORM] : []), ].filter(filterGlobalConfigForm); + +export const GLOBAL_TASKS_FORM_CONFIG: ConfigFormConfig = [ + TASKS_SETTINGS_FORM_CFG, + SHORT_SYNTAX_FORM_CFG, +].filter(filterGlobalConfigForm); diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 9ff596247..985313eb8 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -21,18 +21,10 @@ export type AppFeaturesConfig = Readonly<{ }>; export type MiscConfig = Readonly<{ - isAutMarkParentAsDone: boolean; isConfirmBeforeExit: boolean; isConfirmBeforeExitWithoutFinishDay: boolean; - isConfirmBeforeTaskDelete?: boolean; - isTurnOffMarkdown: boolean; - isAutoAddWorkedOnToToday: boolean; isMinimizeToTray: boolean; - isTrayShowCurrentTask: boolean; - // allow also false because of #569 - defaultProjectId?: string | null | false; startOfNextDay: number; - taskNotesTpl: string; isDisableAnimations: boolean; // optional because it was added later isDisableCelebration?: boolean; @@ -43,6 +35,25 @@ export type MiscConfig = Readonly<{ customTheme?: string; defaultStartPage?: number; unsplashApiKey?: string | null; + + // @todo: remove deprecated items in future major releases, after giving users time to migrate + isConfirmBeforeTaskDelete?: boolean; // Deprecated + isAutoAddWorkedOnToToday?: boolean; // Deprecated + isAutMarkParentAsDone?: boolean; // Deprecated + isTrayShowCurrentTask?: boolean; // Deprecated + isTurnOffMarkdown?: boolean; // Deprecated + defaultProjectId?: string | null | false; // Deprecated + taskNotesTpl?: string; // Deprecated +}>; + +export type TasksConfig = Readonly<{ + isAutoMarkParentAsDone: boolean; + isAutoAddWorkedOnToToday: boolean; + isConfirmBeforeDelete?: boolean; + isTrayShowCurrent: boolean; + isMarkdownFormattingInNotesEnabled: boolean; + defaultProjectId?: string | null | false; // allow 'false' because of #569 + notesTemplate: string; }>; export type ShortSyntaxConfig = Readonly<{ @@ -217,6 +228,7 @@ export type GlobalConfigState = Readonly<{ appFeatures: AppFeaturesConfig; localization: LocalizationConfig; misc: MiscConfig; + tasks: TasksConfig; shortSyntax: ShortSyntaxConfig; evaluation: EvaluationConfig; idle: IdleConfig; @@ -239,6 +251,7 @@ export type GlobalConfigSectionKey = keyof GlobalConfigState | 'EMPTY'; export type GlobalSectionConfig = | MiscConfig + | TasksConfig | PomodoroConfig | KeyboardConfig | ScheduleConfig diff --git a/src/app/features/config/global-config.service.ts b/src/app/features/config/global-config.service.ts index b061de427..3d7a0be95 100644 --- a/src/app/features/config/global-config.service.ts +++ b/src/app/features/config/global-config.service.ts @@ -18,6 +18,7 @@ import { SoundConfig, SyncConfig, TakeABreakConfig, + TasksConfig, } from './global-config.model'; import { selectConfigFeatureState, @@ -30,6 +31,7 @@ import { selectSoundConfig, selectSyncConfig, selectTakeABreakConfig, + selectTasksConfig, selectTimelineConfig, } from './store/global-config.reducer'; import { distinctUntilChanged, shareReplay } from 'rxjs/operators'; @@ -48,6 +50,11 @@ export class GlobalConfigService { shareReplay(1), ); + tasks$: Observable = this._store.pipe( + select(selectTasksConfig), + shareReplay(1), + ); + localization$: Observable = this._store.pipe( select(selectLocalizationConfig), shareReplay(1), @@ -103,6 +110,9 @@ export class GlobalConfigService { this.localization$, { initialValue: undefined }, ); + readonly tasks: Signal = toSignal(this.tasks$, { + initialValue: undefined, + }); readonly misc: Signal = toSignal(this.misc$, { initialValue: undefined, }); diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index a874a861b..0ff208591 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -15,6 +15,7 @@ import { SoundConfig, SyncConfig, TakeABreakConfig, + TasksConfig, } from '../global-config.model'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; @@ -27,6 +28,10 @@ export const selectLocalizationConfig = createSelector( selectConfigFeatureState, (cfg): LocalizationConfig => cfg?.localization ?? DEFAULT_GLOBAL_CONFIG.localization, ); +export const selectTasksConfig = createSelector( + selectConfigFeatureState, + (cfg): TasksConfig => cfg.tasks ?? DEFAULT_GLOBAL_CONFIG.tasks, +); export const selectMiscConfig = createSelector( selectConfigFeatureState, (cfg): MiscConfig => cfg?.misc ?? DEFAULT_GLOBAL_CONFIG.misc, diff --git a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts index 6af7505e2..f135f71db 100644 --- a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts +++ b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts @@ -68,8 +68,8 @@ describe('FocusModeMainComponent', () => { storeSpy.select.and.returnValue(of([])); const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], { - misc: jasmine.createSpy().and.returnValue({ - taskNotesTpl: 'Default task notes template', + tasks: jasmine.createSpy().and.returnValue({ + notesTemplate: 'Default task notes template', }), }); @@ -586,8 +586,8 @@ describe('FocusModeMainComponent - notes panel (issue #5752)', () => { storeSpy.select.and.returnValue(of([])); const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], { - misc: jasmine.createSpy().and.returnValue({ - taskNotesTpl: 'Default task notes template', + tasks: jasmine.createSpy().and.returnValue({ + notesTemplate: 'Default task notes template', }), }); @@ -772,8 +772,8 @@ describe('FocusModeMainComponent - sync with tracking (issue #6009)', () => { storeSpy.select.and.returnValue(of([])); const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], { - misc: jasmine.createSpy().and.returnValue({ - taskNotesTpl: 'Default task notes template', + tasks: jasmine.createSpy().and.returnValue({ + notesTemplate: 'Default task notes template', }), }); diff --git a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.ts b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.ts index e1a7abab8..155f58c9d 100644 --- a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.ts +++ b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.ts @@ -228,9 +228,9 @@ export class FocusModeMainComponent { // Use effect to reactively update defaultTaskNotes effect(() => { - const misc = this._globalConfigService.misc(); - if (misc) { - this.defaultTaskNotes.set(misc.taskNotesTpl); + const tasks = this._globalConfigService.tasks(); + if (tasks) { + this.defaultTaskNotes.set(tasks.notesTemplate); } }); diff --git a/src/app/features/project/store/project.effects.spec.ts b/src/app/features/project/store/project.effects.spec.ts index 3dd712f21..0e030326f 100644 --- a/src/app/features/project/store/project.effects.spec.ts +++ b/src/app/features/project/store/project.effects.spec.ts @@ -26,8 +26,8 @@ describe('ProjectEffects', () => { const createConfigState = (): GlobalConfigState => ({ ...DEFAULT_GLOBAL_CONFIG, - misc: { - ...DEFAULT_GLOBAL_CONFIG.misc, + tasks: { + ...DEFAULT_GLOBAL_CONFIG.tasks, defaultProjectId: 'project-1', }, }); @@ -65,14 +65,14 @@ describe('ProjectEffects', () => { // Set up config where project-1 is the default globalConfigServiceMock.cfg.and.returnValue({ ...DEFAULT_GLOBAL_CONFIG, - misc: { - ...DEFAULT_GLOBAL_CONFIG.misc, + tasks: { + ...DEFAULT_GLOBAL_CONFIG.tasks, defaultProjectId: 'project-1', }, }); effects.deleteProjectRelatedData.subscribe(() => { - expect(globalConfigServiceMock.updateSection).toHaveBeenCalledWith('misc', { + expect(globalConfigServiceMock.updateSection).toHaveBeenCalledWith('tasks', { defaultProjectId: null, }); done(); @@ -91,8 +91,8 @@ describe('ProjectEffects', () => { // Set up config where project-1 is the default, but we delete project-2 globalConfigServiceMock.cfg.and.returnValue({ ...DEFAULT_GLOBAL_CONFIG, - misc: { - ...DEFAULT_GLOBAL_CONFIG.misc, + tasks: { + ...DEFAULT_GLOBAL_CONFIG.tasks, defaultProjectId: 'project-1', }, }); @@ -114,8 +114,8 @@ describe('ProjectEffects', () => { it('should NOT clear defaultProjectId when there is no default', (done) => { globalConfigServiceMock.cfg.and.returnValue({ ...DEFAULT_GLOBAL_CONFIG, - misc: { - ...DEFAULT_GLOBAL_CONFIG.misc, + tasks: { + ...DEFAULT_GLOBAL_CONFIG.tasks, defaultProjectId: null, }, }); diff --git a/src/app/features/project/store/project.effects.ts b/src/app/features/project/store/project.effects.ts index 5293c1ba2..548b899ab 100644 --- a/src/app/features/project/store/project.effects.ts +++ b/src/app/features/project/store/project.effects.ts @@ -35,8 +35,8 @@ export class ProjectEffects { tap(({ projectId }) => { // Clear defaultProjectId if the deleted project was the default const cfg = this._globalConfigService.cfg(); - if (cfg && projectId === cfg.misc.defaultProjectId) { - this._globalConfigService.updateSection('misc', { defaultProjectId: null }); + if (cfg && projectId === cfg.tasks.defaultProjectId) { + this._globalConfigService.updateSection('tasks', { defaultProjectId: null }); } }), ), diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-mentions.spec.ts b/src/app/features/tasks/add-task-bar/add-task-bar-mentions.spec.ts index b961835ca..3616fd532 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-mentions.spec.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-mentions.spec.ts @@ -96,6 +96,7 @@ describe('AddTaskBarComponent Mentions Integration', () => { }), localization: () => ({ timeLocale: DEFAULT_LOCALE }), misc$: miscSubject, + tasks$: new BehaviorSubject({ defaultProjectId: null }), }); const addTaskBarIssueSearchServiceSpy = jasmine.createSpyObj( 'AddTaskBarIssueSearchService', diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.component.spec.ts b/src/app/features/tasks/add-task-bar/add-task-bar.component.spec.ts index f8e41aec7..a73d34b16 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.component.spec.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.component.spec.ts @@ -180,6 +180,7 @@ describe('AddTaskBarComponent', () => { mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], { lang$: new BehaviorSubject(mockLocalizationConfig), misc$: new BehaviorSubject(mockMiscConfig), + tasks$: new BehaviorSubject({ defaultProjectId: null }), shortSyntax$: of({}), localization: () => ({ timeLocale: DEFAULT_LOCALE }), }); @@ -286,15 +287,10 @@ describe('AddTaskBarComponent', () => { mockWorkContextService.activeWorkContext$ as BehaviorSubject ).next(mockTagWorkContext); - // Set default project in config - const configWithDefault: MiscConfig = { - ...mockLocalizationConfig, - ...mockMiscConfig, + // Set default project in tasks config + (mockGlobalConfigService.tasks$ as BehaviorSubject).next({ defaultProjectId: 'default-project', - }; - (mockGlobalConfigService.misc$ as BehaviorSubject).next( - configWithDefault, - ); + }); const defaultProject = await component.defaultProject$.pipe(first()).toPromise(); @@ -397,15 +393,10 @@ describe('AddTaskBarComponent', () => { mockWorkContextService.activeWorkContext$ as BehaviorSubject ).next(mockTagWorkContext); - // Set default project - const configWithDefault: MiscConfig = { - ...mockLocalizationConfig, - ...mockMiscConfig, + // Set default project in tasks config + (mockGlobalConfigService.tasks$ as BehaviorSubject).next({ defaultProjectId: 'default-project', - }; - (mockGlobalConfigService.misc$ as BehaviorSubject).next( - configWithDefault, - ); + }); let defaultProject = await component.defaultProject$.pipe(first()).toPromise(); expect(defaultProject?.id).toBe('default-project'); @@ -426,27 +417,17 @@ describe('AddTaskBarComponent', () => { ).next(mockTagWorkContext); // Start with no default project - const configWithoutDefault: MiscConfig = { - ...mockLocalizationConfig, - ...mockMiscConfig, + (mockGlobalConfigService.tasks$ as BehaviorSubject).next({ defaultProjectId: null, - }; - (mockGlobalConfigService.misc$ as BehaviorSubject).next( - configWithoutDefault, - ); + }); let defaultProject = await component.defaultProject$.pipe(first()).toPromise(); expect(defaultProject?.id).toBe('INBOX_PROJECT'); // Change to configured default project - const configWithDefault: MiscConfig = { - ...mockLocalizationConfig, - ...mockMiscConfig, + (mockGlobalConfigService.tasks$ as BehaviorSubject).next({ defaultProjectId: 'default-project', - }; - (mockGlobalConfigService.misc$ as BehaviorSubject).next( - configWithDefault, - ); + }); defaultProject = await component.defaultProject$.pipe(first()).toPromise(); expect(defaultProject?.id).toBe('default-project'); @@ -458,15 +439,10 @@ describe('AddTaskBarComponent', () => { mockWorkContextService.activeWorkContext$ as BehaviorSubject ).next(null); - // Set default project - const configWithDefault: MiscConfig = { - ...mockLocalizationConfig, - ...mockMiscConfig, + // Set default project in tasks config + (mockGlobalConfigService.tasks$ as BehaviorSubject).next({ defaultProjectId: 'default-project', - }; - (mockGlobalConfigService.misc$ as BehaviorSubject).next( - configWithDefault, - ); + }); const defaultProject = await component.defaultProject$.pipe(first()).toPromise(); diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts index 722b9c6a9..4a38d1bdc 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts @@ -172,19 +172,19 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy { defaultProject$ = combineLatest([ this.projects$, this._workContextService.activeWorkContext$, - this._globalConfigService.misc$, + this._globalConfigService.tasks$, ]).pipe( - map(([projects, workContext, miscConfig]) => { + map(([projects, workContext, tasksConfig]) => { // Priority order: // 1. If current work context is a project → use that project - // 2. If misc.defaultProjectId is configured → use that project + // 2. If tasks.defaultProjectId is configured → use that project // 3. Otherwise → fall back to INBOX_PROJECT const defaultProject = (workContext?.type === WorkContextType.PROJECT ? projects.find((p) => p.id === workContext.id) : null) || - (miscConfig.defaultProjectId - ? projects.find((p) => p.id === miscConfig.defaultProjectId) + (tasksConfig.defaultProjectId + ? projects.find((p) => p.id === tasksConfig.defaultProjectId) : null) || projects.find((p) => p.id === 'INBOX_PROJECT'); return defaultProject; diff --git a/src/app/features/tasks/store/short-syntax.effects.spec.ts b/src/app/features/tasks/store/short-syntax.effects.spec.ts index 28ec57eac..638e90e9a 100644 --- a/src/app/features/tasks/store/short-syntax.effects.spec.ts +++ b/src/app/features/tasks/store/short-syntax.effects.spec.ts @@ -33,7 +33,7 @@ describe('ShortSyntaxEffects', () => { getByIdOnce$: jasmine.Spy; }; let globalConfigServiceMock: { - misc$: BehaviorSubject; + tasks$: BehaviorSubject; cfg: jasmine.Spy; }; let snackServiceSpy: jasmine.SpyObj; @@ -85,8 +85,8 @@ describe('ShortSyntaxEffects', () => { }; globalConfigServiceMock = { - misc$: new BehaviorSubject({ - ...DEFAULT_GLOBAL_CONFIG.misc, + tasks$: new BehaviorSubject({ + ...DEFAULT_GLOBAL_CONFIG.tasks, defaultProjectId: null, }), cfg: jasmine.createSpy('cfg').and.returnValue(DEFAULT_GLOBAL_CONFIG), diff --git a/src/app/features/tasks/store/short-syntax.effects.ts b/src/app/features/tasks/store/short-syntax.effects.ts index 4b10d8c42..4acae1b80 100644 --- a/src/app/features/tasks/store/short-syntax.effects.ts +++ b/src/app/features/tasks/store/short-syntax.effects.ts @@ -74,8 +74,8 @@ export class ShortSyntaxEffects { withLatestFrom( this._tagService.tagsNoMyDayAndNoList$, this._projectService.list$, - this._globalConfigService.misc$.pipe( - map((misc) => misc.defaultProjectId), + this._globalConfigService.tasks$.pipe( + map((tasks) => tasks.defaultProjectId), concatMap((defaultProjectId) => { if (this._workContextService.activeWorkContextId === INBOX_PROJECT.id) { diff --git a/src/app/features/tasks/store/task-internal.effects.spec.ts b/src/app/features/tasks/store/task-internal.effects.spec.ts index 25e10ea82..387be292b 100644 --- a/src/app/features/tasks/store/task-internal.effects.spec.ts +++ b/src/app/features/tasks/store/task-internal.effects.spec.ts @@ -8,13 +8,13 @@ import { setCurrentTask, toggleStart, unsetCurrentTask } from './task.actions'; import { selectTaskFeatureState } from './task.selectors'; import { selectConfigFeatureState, - selectMiscConfig, + selectTasksConfig, } from '../../config/store/global-config.reducer'; import { DEFAULT_TASK, Task, TaskState } from '../task.model'; import { WorkContextService } from '../../work-context/work-context.service'; import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'; -import { GlobalConfigState, MiscConfig } from '../../config/global-config.model'; +import { GlobalConfigState, TasksConfig } from '../../config/global-config.model'; import { WorkContextType } from '../../work-context/work-context.model'; describe('TaskInternalEffects', () => { @@ -60,8 +60,8 @@ describe('TaskInternalEffects', () => { ...partial, }); - const createMiscConfig = (partial: Partial = {}): MiscConfig => ({ - ...DEFAULT_GLOBAL_CONFIG.misc, + const createTasksConfig = (partial: Partial = {}): TasksConfig => ({ + ...DEFAULT_GLOBAL_CONFIG.tasks, ...partial, }); @@ -91,8 +91,8 @@ describe('TaskInternalEffects', () => { provideMockStore({ selectors: [ { - selector: selectMiscConfig, - value: createMiscConfig({ isAutMarkParentAsDone: true }), + selector: selectTasksConfig, + value: createTasksConfig({ isAutoMarkParentAsDone: true }), }, { selector: selectTaskFeatureState, value: createTaskState([]) }, { selector: selectConfigFeatureState, value: createConfigState() }, @@ -129,8 +129,8 @@ describe('TaskInternalEffects', () => { }); store.overrideSelector( - selectMiscConfig, - createMiscConfig({ isAutMarkParentAsDone: true }), + selectTasksConfig, + createTasksConfig({ isAutoMarkParentAsDone: true }), ); store.overrideSelector( selectTaskFeatureState, @@ -164,8 +164,8 @@ describe('TaskInternalEffects', () => { }); store.overrideSelector( - selectMiscConfig, - createMiscConfig({ isAutMarkParentAsDone: false }), + selectTasksConfig, + createTasksConfig({ isAutoMarkParentAsDone: false }), ); store.overrideSelector( selectTaskFeatureState, @@ -205,8 +205,8 @@ describe('TaskInternalEffects', () => { }); store.overrideSelector( - selectMiscConfig, - createMiscConfig({ isAutMarkParentAsDone: true }), + selectTasksConfig, + createTasksConfig({ isAutoMarkParentAsDone: true }), ); store.overrideSelector( selectTaskFeatureState, diff --git a/src/app/features/tasks/store/task-internal.effects.ts b/src/app/features/tasks/store/task-internal.effects.ts index 1635b680c..3c48c8097 100644 --- a/src/app/features/tasks/store/task-internal.effects.ts +++ b/src/app/features/tasks/store/task-internal.effects.ts @@ -13,7 +13,7 @@ import { filter, map, mergeMap, withLatestFrom } from 'rxjs/operators'; import { selectTaskFeatureState } from './task.selectors'; import { selectConfigFeatureState, - selectMiscConfig, + selectTasksConfig, } from '../../config/store/global-config.reducer'; import { Task, TaskState } from '../task.model'; import { EMPTY, of } from 'rxjs'; @@ -33,13 +33,13 @@ export class TaskInternalEffects { this._actions$.pipe( ofType(TaskSharedActions.updateTask), withLatestFrom( - this._store$.pipe(select(selectMiscConfig)), + this._store$.pipe(select(selectTasksConfig)), this._store$.pipe(select(selectTaskFeatureState)), ), filter( - ([{ task }, miscCfg, state]) => - !!miscCfg && - miscCfg.isAutMarkParentAsDone && + ([{ task }, tasksCfg, state]) => + !!tasksCfg && + tasksCfg.isAutoMarkParentAsDone && !!task.changes.isDone && // @ts-ignore !!state.entities[task.id].parentId, diff --git a/src/app/features/tasks/store/task-related-model.effects.spec.ts b/src/app/features/tasks/store/task-related-model.effects.spec.ts index 2197ef012..90f0c9a46 100644 --- a/src/app/features/tasks/store/task-related-model.effects.spec.ts +++ b/src/app/features/tasks/store/task-related-model.effects.spec.ts @@ -49,7 +49,7 @@ describe('TaskRelatedModelEffects', () => { { provide: GlobalConfigService, useValue: { - misc$: of({ isAutoAddWorkedOnToToday: true }), + tasks$: of({ isAutoAddWorkedOnToToday: true }), }, }, { provide: HydrationStateService, useValue: hydrationStateServiceSpy }, diff --git a/src/app/features/tasks/store/task-related-model.effects.ts b/src/app/features/tasks/store/task-related-model.effects.ts index 78327a559..2c92bad2c 100644 --- a/src/app/features/tasks/store/task-related-model.effects.ts +++ b/src/app/features/tasks/store/task-related-model.effects.ts @@ -27,8 +27,8 @@ export class TaskRelatedModelEffects { // --------------------- ifAutoAddTodayEnabled$ = (obs: Observable): Observable => - this._globalConfigService.misc$.pipe( - switchMap((misc) => (misc.isAutoAddWorkedOnToToday ? obs : EMPTY)), + this._globalConfigService.tasks$.pipe( + switchMap((tasks) => (tasks.isAutoAddWorkedOnToToday ? obs : EMPTY)), ); autoAddTodayTagOnTracking = createEffect(() => diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts index b91c5073a..eb94a121c 100644 --- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts +++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts @@ -289,10 +289,10 @@ export class TaskContextMenuInnerComponent implements AfterViewInit { return; } - const isConfirmBeforeTaskDelete = - this._globalConfigService.cfg()?.misc?.isConfirmBeforeTaskDelete ?? true; + const isConfirmBeforeDelete = + this._globalConfigService.cfg()?.tasks?.isConfirmBeforeDelete ?? true; - if (isConfirmBeforeTaskDelete) { + if (isConfirmBeforeDelete) { this._matDialog .open(DialogConfirmComponent, { data: { diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts index 54126d02b..004bbe7dd 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts @@ -244,8 +244,8 @@ export class TaskDetailPanelComponent implements OnInit, AfterViewInit, OnDestro // Default task notes computed signal defaultTaskNotes = computed(() => { - const misc = this._globalConfigService.misc(); - return misc?.taskNotesTpl || ''; + const tasks = this._globalConfigService.tasks(); + return tasks?.notesTemplate || ''; }); // Local attachments computed signal diff --git a/src/app/features/tasks/task.service.spec.ts b/src/app/features/tasks/task.service.spec.ts index 0cea248f9..bce6fb6fe 100644 --- a/src/app/features/tasks/task.service.spec.ts +++ b/src/app/features/tasks/task.service.spec.ts @@ -105,11 +105,12 @@ describe('TaskService', () => { const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [''], { cfg: signal({ - misc: { defaultProjectId: null }, + tasks: { defaultProjectId: null }, reminder: { defaultTaskRemindOption: 'AT_START' }, appFeatures: { isTimeTrackingEnabled: true }, }), misc: signal({ isShowProductivityTipLonger: false }), + tasks: signal({ defaultProjectId: null }), }); const taskFocusServiceSpy = jasmine.createSpyObj('TaskFocusService', [''], { diff --git a/src/app/features/tasks/task.service.ts b/src/app/features/tasks/task.service.ts index 04039f3fa..2f428e3e4 100644 --- a/src/app/features/tasks/task.service.ts +++ b/src/app/features/tasks/task.service.ts @@ -1234,7 +1234,7 @@ export class TaskService { ? { projectId: workContextId } : { projectId: - this._globalConfigService.cfg()?.misc.defaultProjectId || INBOX_PROJECT.id, + this._globalConfigService.cfg()?.tasks.defaultProjectId || INBOX_PROJECT.id, }), tagIds: @@ -1258,7 +1258,7 @@ export class TaskService { d1.projectId = workContextType === WorkContextType.PROJECT ? workContextId - : this._globalConfigService.cfg()?.misc.defaultProjectId || INBOX_PROJECT.id; + : this._globalConfigService.cfg()?.tasks.defaultProjectId || INBOX_PROJECT.id; } // Validate that we have a valid task before returning diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index e0c87149a..ab2d82ffd 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -371,7 +371,7 @@ export class TaskComponent implements OnDestroy, AfterViewInit { } const isConfirmBeforeTaskDelete = - this._configService.cfg()?.misc?.isConfirmBeforeTaskDelete ?? true; + this._configService.cfg()?.tasks?.isConfirmBeforeDelete ?? true; if (isConfirmBeforeTaskDelete) { this._matDialog diff --git a/src/app/op-log/persistence/schema-migration.service.spec.ts b/src/app/op-log/persistence/schema-migration.service.spec.ts index b6e98dae6..ef0617069 100644 --- a/src/app/op-log/persistence/schema-migration.service.spec.ts +++ b/src/app/op-log/persistence/schema-migration.service.spec.ts @@ -54,9 +54,9 @@ describe('SchemaMigrationService', () => { expect(service.getCurrentVersion()).toBe(CURRENT_SCHEMA_VERSION); }); - it('should return 1 as the initial version', () => { - // Current implementation starts at version 1 - expect(service.getCurrentVersion()).toBe(1); + it('should return 2 as the current version', () => { + // Current implementation is at version 2 after migration from MiscConfig to TasksConfig + expect(service.getCurrentVersion()).toBe(2); }); }); @@ -68,7 +68,7 @@ describe('SchemaMigrationService', () => { it('should return empty array for initial version', () => { // No migrations defined yet for version 1 - const migrations = service.getMigrations(); + const migrations = service.getMigrations(1, 1); expect(migrations.length).toBe(0); }); }); @@ -79,17 +79,15 @@ describe('SchemaMigrationService', () => { expect(service.needsMigration(cache)).toBeFalse(); }); - it('should return false for cache with undefined schemaVersion (defaults to 1)', () => { + it('should return true for cache with undefined schemaVersion (defaults to 1)', () => { const cache = createMockCache(undefined); // When schemaVersion is undefined, it defaults to 1 - // Since CURRENT_SCHEMA_VERSION is 1, no migration needed - expect(service.needsMigration(cache)).toBeFalse(); + // Since CURRENT_SCHEMA_VERSION is 2, migration is needed + expect(service.needsMigration(cache)).toBeTrue(); }); it('should return true for cache with older version', () => { - // This test will only make sense when we have migrations - // For now, since CURRENT_SCHEMA_VERSION is 1, there's no "older" version - const cache = createMockCache(0); // Version 0 is older + const cache = createMockCache(1); // Version 1 is older than current version 2 expect(service.needsMigration(cache)).toBeTrue(); }); }); @@ -100,14 +98,15 @@ describe('SchemaMigrationService', () => { expect(service.operationNeedsMigration(op)).toBeFalse(); }); - it('should return false for operation with undefined schemaVersion (defaults to 1)', () => { + it('should return true for operation with undefined schemaVersion (defaults to 1)', () => { const op = createMockOperation('op-1'); op.schemaVersion = undefined as any; - expect(service.operationNeedsMigration(op)).toBeFalse(); + // Since CURRENT_SCHEMA_VERSION is 2, migration is needed + expect(service.operationNeedsMigration(op)).toBeTrue(); }); it('should return true for operation with older version', () => { - const op = createMockOperation('op-1', 0); + const op = createMockOperation('op-1', 1); expect(service.operationNeedsMigration(op)).toBeTrue(); }); }); diff --git a/src/app/op-log/persistence/schema-migration.service.ts b/src/app/op-log/persistence/schema-migration.service.ts index ea41c2615..f3a1ebdcb 100644 --- a/src/app/op-log/persistence/schema-migration.service.ts +++ b/src/app/op-log/persistence/schema-migration.service.ts @@ -127,11 +127,12 @@ export class SchemaMigrationService { /** * Migrates a single operation to the current schema version if needed. * Returns null if the operation should be dropped (e.g., for removed features). + * Returns an array if the operation should be split into multiple operations. * * @param op - The operation to migrate - * @returns The migrated operation, or null if it should be dropped + * @returns The migrated operation(s), or null if it should be dropped */ - migrateOperation(op: Operation): Operation | null { + migrateOperation(op: Operation): Operation | Operation[] | null { const opVersion = op.schemaVersion ?? 1; if (opVersion >= CURRENT_SCHEMA_VERSION) { @@ -159,6 +160,19 @@ export class SchemaMigrationService { return null; } + // Handle array result (operation was split into multiple) + if (Array.isArray(result.data)) { + return result.data.map((migratedOpLike) => ({ + ...op, + opType: migratedOpLike.opType as Operation['opType'], + entityType: migratedOpLike.entityType as Operation['entityType'], + entityId: migratedOpLike.entityId, + entityIds: migratedOpLike.entityIds, + payload: migratedOpLike.payload, + schemaVersion: migratedOpLike.schemaVersion, + })); + } + // Merge migrated fields back into the original operation return { ...op, @@ -172,6 +186,7 @@ export class SchemaMigrationService { /** * Migrates an array of operations, filtering out any that should be dropped. + * Handles operations that are split into multiple operations. * * @param ops - The operations to migrate * @returns Array of migrated operations (dropped operations excluded) @@ -180,13 +195,19 @@ export class SchemaMigrationService { const migrated: Operation[] = []; for (const op of ops) { - const migratedOp = this.migrateOperation(op); - if (migratedOp !== null) { - migrated.push(migratedOp); - } else { + const migratedResult = this.migrateOperation(op); + if (migratedResult === null) { OpLog.normal( `SchemaMigrationService: Dropped operation ${op.id} (${op.actionType}) during migration`, ); + } else if (Array.isArray(migratedResult)) { + // Operation was split into multiple operations + migrated.push(...migratedResult); + OpLog.normal( + `SchemaMigrationService: Split operation ${op.id} into ${migratedResult.length} operations during migration`, + ); + } else { + migrated.push(migratedResult); } } @@ -224,9 +245,24 @@ export class SchemaMigrationService { } /** - * Returns all registered migrations (for debugging/testing). + * Returns registered migrations for a specific version range. + * If no range is provided, returns all migrations. + * + * @param fromVersion - The starting version (inclusive). + * @param toVersion - The ending version (inclusive). + * @returns Array of migrations within the specified range. */ - getMigrations(): readonly SchemaMigration[] { - return MIGRATIONS; + getMigrations(fromVersion?: number, toVersion?: number): readonly SchemaMigration[] { + if (fromVersion === undefined && toVersion === undefined) { + return MIGRATIONS; + } + + return MIGRATIONS.filter((migration) => { + const isAfterFromVersion = + fromVersion === undefined || migration.fromVersion >= fromVersion; + const isBeforeToVersion = + toVersion === undefined || migration.toVersion <= toVersion; + return isAfterFromVersion && isBeforeToVersion; + }); } } diff --git a/src/app/op-log/sync/remote-ops-processing.service.ts b/src/app/op-log/sync/remote-ops-processing.service.ts index 67d2c271c..b580054b9 100644 --- a/src/app/op-log/sync/remote-ops-processing.service.ts +++ b/src/app/op-log/sync/remote-ops-processing.service.ts @@ -130,9 +130,7 @@ export class RemoteOpsProcessingService { try { const migrated = this.schemaMigrationService.migrateOperation(op); - if (migrated) { - migratedOps.push(migrated); - } else { + if (migrated === null) { // Track dropped entity IDs for dependency warning if (op.entityId) { droppedEntityIds.add(op.entityId); @@ -143,6 +141,11 @@ export class RemoteOpsProcessingService { OpLog.verbose( `RemoteOpsProcessingService: Dropped op ${op.id} (migrated to null)`, ); + } else if (Array.isArray(migrated)) { + // Operation was split into multiple operations + migratedOps.push(...migrated); + } else { + migratedOps.push(migrated); } } catch (e) { OpLog.err(`RemoteOpsProcessingService: Migration failed for op ${op.id}`, e); diff --git a/src/app/op-log/testing/integration/migration-handling.integration.spec.ts b/src/app/op-log/testing/integration/migration-handling.integration.spec.ts index 7e2303cd3..202449b55 100644 --- a/src/app/op-log/testing/integration/migration-handling.integration.spec.ts +++ b/src/app/op-log/testing/integration/migration-handling.integration.spec.ts @@ -17,6 +17,7 @@ import { resetTestUuidCounter } from './helpers/test-client.helper'; import { LockService } from '../../sync/lock.service'; import { OperationLogCompactionService } from '../../persistence/operation-log-compaction.service'; import { SyncImportFilterService } from '../../sync/sync-import-filter.service'; +import { CURRENT_SCHEMA_VERSION } from '@sp/shared-schema'; /** * Integration tests for Schema Migration Handling in Sync. @@ -149,7 +150,7 @@ describe('Migration Handling Integration', () => { it('should reject operation with incompatible future version', async () => { // Logic: if opVersion > current + MAX_VERSION_SKIP, update required - const incompatibleVersion = 1 + MAX_VERSION_SKIP + 1; + const incompatibleVersion = CURRENT_SCHEMA_VERSION + MAX_VERSION_SKIP + 1; const op = createOp(incompatibleVersion); await service.processRemoteOps([op]); diff --git a/src/app/pages/config-page/config-page.component.html b/src/app/pages/config-page/config-page.component.html index 44ca70c47..368ebc777 100644 --- a/src/app/pages/config-page/config-page.component.html +++ b/src/app/pages/config-page/config-page.component.html @@ -32,6 +32,26 @@ > } + + + + + + + task + {{ 'PS.TABS.TASKS' | translate }} + +
+ @for (section of globalTasksFormCfg; track section.key) { +
+ +
+ } + diff --git a/src/app/pages/config-page/config-page.component.ts b/src/app/pages/config-page/config-page.component.ts index 243c36c23..31ee18072 100644 --- a/src/app/pages/config-page/config-page.component.ts +++ b/src/app/pages/config-page/config-page.component.ts @@ -15,6 +15,7 @@ import { GLOBAL_PLUGINS_FORM_CONFIG, GLOBAL_PRODUCTIVITY_FORM_CONFIG, GLOBAL_TIME_TRACKING_FORM_CONFIG, + GLOBAL_TASKS_FORM_CONFIG, } from '../../features/config/global-config-form-config.const'; import { ConfigFormConfig, @@ -101,6 +102,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy { // @todo - find better names for tabs configs forms // Tab-specific form configurations generalFormCfg: ConfigFormConfig; + globalTasksFormCfg: ConfigFormConfig; timeTrackingFormCfg: ConfigFormConfig; pluginsShortcutsFormCfg: ConfigFormConfig; globalImexFormCfg: ConfigFormConfig; @@ -138,6 +140,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy { this.pluginsShortcutsFormCfg = GLOBAL_PLUGINS_FORM_CONFIG.slice(); this.globalImexFormCfg = GLOBAL_IMEX_FORM_CONFIG.slice(); this.globalProductivityConfigFormCfg = GLOBAL_PRODUCTIVITY_FORM_CONFIG.slice(); + this.globalTasksFormCfg = GLOBAL_TASKS_FORM_CONFIG.slice(); // NOTE: needs special handling cause of the async stuff if (IS_ANDROID_WEB_VIEW) { diff --git a/src/app/plugins/plugin-bridge.service.spec.ts b/src/app/plugins/plugin-bridge.service.spec.ts index 2599731b5..2c999bb9c 100644 --- a/src/app/plugins/plugin-bridge.service.spec.ts +++ b/src/app/plugins/plugin-bridge.service.spec.ts @@ -596,6 +596,7 @@ import { TaskArchiveService } from '../features/archive/task-archive.service'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { SyncWrapperService } from '../imex/sync/sync-wrapper.service'; +import { getDbDateStr } from '../util/get-db-date-str'; describe('PluginBridgeService - Counter Methods', () => { let service: PluginBridgeService; @@ -651,7 +652,7 @@ describe('PluginBridgeService - Counter Methods', () => { // Arrange const counterId = 'new-counter'; const value = 10; - const today = new Date().toISOString().split('T')[0]; + const today = getDbDateStr(); // Act await service.setCounter(counterId, value); @@ -676,7 +677,7 @@ describe('PluginBridgeService - Counter Methods', () => { // Arrange const counterId = 'existing-counter'; const value = 15; - const today = new Date().toISOString().split('T')[0]; + const today = getDbDateStr(); // Act await service.setCounter(counterId, value); @@ -707,8 +708,8 @@ describe('PluginBridgeService - Counter Methods', () => { describe('incrementCounter', () => { it('should increment existing counter value', async () => { - // Arrange: existing counter has value 5 for 2025-12-30 - const today = new Date().toISOString().split('T')[0]; + // Arrange: existing counter has value 5 for today + const today = getDbDateStr(); store.overrideSelector(selectAllSimpleCounters, [ { ...mockExistingCounter, countOnDay: { [today]: 5 } }, ]); @@ -741,7 +742,7 @@ describe('PluginBridgeService - Counter Methods', () => { describe('decrementCounter', () => { it('should decrement existing counter value', async () => { // Arrange - const today = new Date().toISOString().split('T')[0]; + const today = getDbDateStr(); store.overrideSelector(selectAllSimpleCounters, [ { ...mockExistingCounter, countOnDay: { [today]: 10 } }, ]); @@ -755,7 +756,7 @@ describe('PluginBridgeService - Counter Methods', () => { it('should not go below zero', async () => { // Arrange - const today = new Date().toISOString().split('T')[0]; + const today = getDbDateStr(); store.overrideSelector(selectAllSimpleCounters, [ { ...mockExistingCounter, countOnDay: { [today]: 2 } }, ]); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 93ff2de6d..7c9b12918 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1825,16 +1825,12 @@ const T = { DARK_MODE_DARK: 'GCF.MISC.DARK_MODE_DARK', DARK_MODE_LIGHT: 'GCF.MISC.DARK_MODE_LIGHT', DARK_MODE_SYSTEM: 'GCF.MISC.DARK_MODE_SYSTEM', - DEFAULT_PROJECT: 'GCF.MISC.DEFAULT_PROJECT', DEFAULT_START_PAGE: 'GCF.MISC.DEFAULT_START_PAGE', FIRST_DAY_OF_WEEK: 'GCF.MISC.FIRST_DAY_OF_WEEK', HELP: 'GCF.MISC.HELP', - IS_AUTO_ADD_WORKED_ON_TO_TODAY: 'GCF.MISC.IS_AUTO_ADD_WORKED_ON_TO_TODAY', - IS_AUTO_MARK_PARENT_AS_DONE: 'GCF.MISC.IS_AUTO_MARK_PARENT_AS_DONE', IS_CONFIRM_BEFORE_EXIT: 'GCF.MISC.IS_CONFIRM_BEFORE_EXIT', IS_CONFIRM_BEFORE_EXIT_WITHOUT_FINISH_DAY: 'GCF.MISC.IS_CONFIRM_BEFORE_EXIT_WITHOUT_FINISH_DAY', - IS_CONFIRM_BEFORE_TASK_DELETE: 'GCF.MISC.IS_CONFIRM_BEFORE_TASK_DELETE', IS_DARK_MODE: 'GCF.MISC.IS_DARK_MODE', IS_DISABLE_ANIMATIONS: 'GCF.MISC.IS_DISABLE_ANIMATIONS', IS_DISABLE_CELEBRATION: 'GCF.MISC.IS_DISABLE_CELEBRATION', @@ -1842,18 +1838,26 @@ const T = { IS_OVERLAY_INDICATOR_ENABLED: 'GCF.MISC.IS_OVERLAY_INDICATOR_ENABLED', IS_SHOW_TIP_LONGER: 'GCF.MISC.IS_SHOW_TIP_LONGER', IS_TRAY_SHOW_CURRENT_COUNTDOWN: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN', - IS_TRAY_SHOW_CURRENT_TASK: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_TASK', - IS_TURN_OFF_MARKDOWN: 'GCF.MISC.IS_TURN_OFF_MARKDOWN', IS_USE_CUSTOM_WINDOW_TITLE_BAR: 'GCF.MISC.IS_USE_CUSTOM_WINDOW_TITLE_BAR', IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT: 'GCF.MISC.IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT', START_OF_NEXT_DAY: 'GCF.MISC.START_OF_NEXT_DAY', START_OF_NEXT_DAY_HINT: 'GCF.MISC.START_OF_NEXT_DAY_HINT', - TASK_NOTES_TPL: 'GCF.MISC.TASK_NOTES_TPL', THEME: 'GCF.MISC.THEME', THEME_EXPERIMENTAL: 'GCF.MISC.THEME_EXPERIMENTAL', THEME_SELECT_LABEL: 'GCF.MISC.THEME_SELECT_LABEL', TITLE: 'GCF.MISC.TITLE', }, + TASKS: { + DEFAULT_PROJECT: 'GCF.TASKS.DEFAULT_PROJECT', + IS_AUTO_ADD_WORKED_ON_TO_TODAY: 'GCF.TASKS.IS_AUTO_ADD_WORKED_ON_TO_TODAY', + IS_AUTO_MARK_PARENT_AS_DONE: 'GCF.TASKS.IS_AUTO_MARK_PARENT_AS_DONE', + IS_CONFIRM_BEFORE_DELETE: 'GCF.TASKS.IS_CONFIRM_BEFORE_DELETE', + IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED: + 'GCF.TASKS.IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED', + IS_TRAY_SHOW_CURRENT: 'GCF.TASKS.IS_TRAY_SHOW_CURRENT', + NOTES_TEMPLATE: 'GCF.TASKS.NOTES_TEMPLATE', + TITLE: 'GCF.TASKS.TITLE', + }, POMODORO: { BREAK_DURATION: 'GCF.POMODORO.BREAK_DURATION', CYCLES_BEFORE_LONGER_BREAK: 'GCF.POMODORO.CYCLES_BEFORE_LONGER_BREAK', @@ -2203,6 +2207,7 @@ const T = { SYNC_EXPORT: 'PS.SYNC_EXPORT', TABS: { GENERAL: 'PS.TABS.GENERAL', + TASKS: 'PS.TABS.TASKS', TIME_TRACKING: 'PS.TABS.TIME_TRACKING', PRODUCTIVITY: 'PS.TABS.PRODUCTIVITY', PLUGINS: 'PS.TABS.PLUGINS', diff --git a/src/app/ui/inline-markdown/inline-markdown.component.html b/src/app/ui/inline-markdown/inline-markdown.component.html index d0a304b09..fbd79f784 100644 --- a/src/app/ui/inline-markdown/inline-markdown.component.html +++ b/src/app/ui/inline-markdown/inline-markdown.component.html @@ -3,7 +3,7 @@ [class.isHideOverflow]="isHideOverflow()" class="markdown-wrapper" > - @if (isShowEdit() || isTurnOffMarkdownParsing()) { + @if (isShowEdit() || !isMarkdownFormattingEnabled()) {