diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412b8f..0000000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 23fa10dae8..6aba5beeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,7 +98,6 @@ electron-builder.win-store.yaml /src/environments/environment.js /src/environments/environment.js.map /.claude/* -!/.claude/skills .aider* diff --git a/package.json b/package.json index 501c7d51f7..62db9fe4f6 100644 --- a/package.json +++ b/package.json @@ -361,7 +361,9 @@ "owner": "johannesjo" } ], + "packageManager": "npm@11.7.0", "volta": { - "node": "22.18.0" + "node": "22.18.0", + "npm": "11.7.0" } } diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 11e6835a32..3bc2910532 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -28,6 +28,7 @@ import { updateGlobalConfigSection } from './global-config.actions'; import { selectConfigFeatureState, selectLocalizationConfig, + selectMiscConfig, } from './global-config.reducer'; import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util'; import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; @@ -37,6 +38,8 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions' import { selectAllTasks } from '../../tasks/store/task.selectors'; import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config'; import { Log } from '../../../core/log'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { FULL_STATE_OP_TYPES } from '../../../op-log/core/operation.types'; const LAYOUT_DETECTION_TIMEOUT_MS = 1000; @@ -220,6 +223,34 @@ export class GlobalConfigEffects { ), ); + // Bulk replay intentionally hides its inner actions from effects. Reconcile this + // local, non-persistent runtime state after reducers finish, but keep the persistent + // dueDay migration in the direct local-change effect above. + setStartOfNextDayDiffOnBulkApply = createEffect(() => + this._actions$.pipe( + ofType(bulkApplyOperations), + filter(({ operations }) => + operations.some( + (op) => + (op.entityType === 'GLOBAL_CONFIG' && op.entityId === 'misc') || + FULL_STATE_OP_TYPES.has(op.opType), + ), + ), + withLatestFrom(this._store.select(selectMiscConfig)), + map(([, misc]) => { + const normalizedMisc = normalizeStartOfNextDayConfig(misc); + this._dateService.setStartOfNextDayDiff( + normalizedMisc.startOfNextDayTime, + normalizedMisc.startOfNextDay, + ); + return AppStateActions.setTodayString({ + todayStr: this._dateService.todayStr(), + startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(), + }); + }), + ), + ); + notifyElectronAboutCfgChange = createEffect( () => this._actions$.pipe( diff --git a/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts new file mode 100644 index 0000000000..e54e92c01e --- /dev/null +++ b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, Store, StoreModule } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; +import { Subscription, take } from 'rxjs'; +import { DateService } from '../../../core/date/date.service'; +import { GlobalConfigEffects } from './global-config.effects'; +import { + CONFIG_FEATURE_NAME, + globalConfigReducer, + selectMiscConfig, +} from './global-config.reducer'; +import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; +import { GlobalConfigState, MiscConfig } from '../global-config.model'; +import { loadAllData } from '../../../root-store/meta/load-all-data.action'; +import { AppDataComplete } from '../../../op-log/model/model-config'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { bulkOperationsMetaReducer } from '../../../op-log/apply/bulk-hydration.meta-reducer'; +import { ActionType, Operation, OpType } from '../../../op-log/core/operation.types'; +import { initialTaskState, TASK_FEATURE_NAME } from '../../tasks/store/task.reducer'; +import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; +import { + appStateFeatureKey, + appStateReducer, +} from '../../../root-store/app-state/app-state.reducer'; +import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; +import { selectStartOfNextDayDiffMs } from '../../../root-store/app-state/app-state.selectors'; +import { LanguageService } from '../../../core/language/language.service'; +import { SnackService } from '../../../core/snack/snack.service'; +import { UserProfileService } from '../../user-profile/user-profile.service'; +import { KeyboardLayoutService } from '../../../core/keyboard-layout/keyboard-layout.service'; +import { IS_ELECTRON_TOKEN } from '../../../app.constants'; +import { IS_MAC_TOKEN } from '../../../util/is-mac'; + +describe('start-of-next-day offset across operation replay', () => { + const SIX_AM_MS = 6 * 60 * 60 * 1000; + + const misc = (startOfNextDay: number, startOfNextDayTime: string): MiscConfig => ({ + ...DEFAULT_GLOBAL_CONFIG.misc, + startOfNextDay, + startOfNextDayTime, + }); + + const appDataWithMisc = (miscCfg: MiscConfig): AppDataComplete => + ({ + globalConfig: { + ...DEFAULT_GLOBAL_CONFIG, + misc: miscCfg, + } as GlobalConfigState, + }) as unknown as AppDataComplete; + + const configOp = (miscCfg: MiscConfig, clientId = 'client1'): Operation => ({ + id: `op-cfg-misc-${clientId}`, + opType: OpType.Update, + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION, + payload: { + actionPayload: { sectionKey: 'misc', sectionCfg: miscCfg }, + entityChanges: [], + }, + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const fullStateOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + id: `op-full-state-${clientId}`, + opType: OpType.SyncImport, + entityType: 'ALL', + actionType: ActionType.LOAD_ALL_DATA, + payload: appDataWithMisc(miscCfg), + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const repairOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + ...fullStateOp(miscCfg, clientId), + id: `op-repair-${clientId}`, + opType: OpType.Repair, + actionType: ActionType.REPAIR_AUTO, + payload: { + appDataComplete: appDataWithMisc(miscCfg), + repairSummary: { + entityStateFixed: 1, + orphanedEntitiesRestored: 0, + invalidReferencesRemoved: 0, + relationshipsFixed: 0, + structureRepaired: 0, + typeErrorsFixed: 0, + }, + }, + }); + + let store: Store; + let dateService: DateService; + let observedActions: Action[]; + let actionsSubscription: Subscription; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot( + { + [CONFIG_FEATURE_NAME]: globalConfigReducer, + [TASK_FEATURE_NAME]: () => initialTaskState, + [appStateFeatureKey]: appStateReducer, + }, + { metaReducers: [bulkOperationsMetaReducer] }, + ), + EffectsModule.forRoot([GlobalConfigEffects]), + ], + providers: [ + { + provide: LanguageService, + useValue: { setLng: (): void => undefined, tryAutoswitch: (): boolean => true }, + }, + { provide: SnackService, useValue: { open: (): void => undefined } }, + { + provide: UserProfileService, + useValue: { migrateOnFirstEnable: (): Promise => Promise.resolve() }, + }, + { provide: KeyboardLayoutService, useValue: new KeyboardLayoutService() }, + { provide: IS_ELECTRON_TOKEN, useValue: false }, + { provide: IS_MAC_TOKEN, useValue: false }, + ], + }); + + store = TestBed.inject(Store); + dateService = TestBed.inject(DateService); + observedActions = []; + actionsSubscription = TestBed.inject(Actions).subscribe((action) => + observedActions.push(action), + ); + }); + + afterEach(() => actionsSubscription.unsubscribe()); + + const readStoreHour = (): number => { + let hour = -1; + store + .select(selectMiscConfig) + .pipe(take(1)) + .subscribe((cfg) => (hour = cfg.startOfNextDay)); + return hour; + }; + + const readAppStateOffset = (): number => { + let offset = -1; + store + .select(selectStartOfNextDayDiffMs) + .pipe(take(1)) + .subscribe((value) => (offset = value)); + return offset; + }; + + const bootFromSnapshotAt = (miscCfg: MiscConfig): void => { + store.dispatch(loadAllData({ appDataComplete: appDataWithMisc(miscCfg) })); + observedActions = []; + }; + + const replay = (operations: Operation[], localClientId = 'client1'): void => { + store.dispatch(bulkApplyOperations({ operations, localClientId })); + }; + + it('installs the offset when the config op is replayed at startup', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client1')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when the config change arrives from another device', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client2')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a full-state operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([fullStateOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a repair operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([repairOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('does not re-mint task updates while replaying the config operation', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'))]); + + expect( + observedActions.some( + (action) => action.type === TaskSharedActions.updateTasks.type, + ), + ).toBeFalse(); + }); + + it('ignores bulk operations that cannot change the day-start config', () => { + bootFromSnapshotAt(misc(0, '00:00')); + const unrelatedOp: Operation = { + ...configOp(misc(6, '06:00')), + id: 'op-cfg-sound-client1', + entityId: 'sound', + payload: { + actionPayload: { sectionKey: 'sound', sectionCfg: {} }, + entityChanges: [], + }, + }; + + replay([unrelatedOp]); + + expect(dateService.getStartOfNextDayDiffMs()).toBe(0); + expect( + observedActions.some( + (action) => action.type === AppStateActions.setTodayString.type, + ), + ).toBeFalse(); + }); +}); diff --git a/src/app/op-log/apply/operation-converter.util.spec.ts b/src/app/op-log/apply/operation-converter.util.spec.ts index bb2c65f669..a55ee633ec 100644 --- a/src/app/op-log/apply/operation-converter.util.spec.ts +++ b/src/app/op-log/apply/operation-converter.util.spec.ts @@ -202,6 +202,7 @@ describe('operation-converter utility', () => { }); const action = convertOpToAction(op); + expect(action.type).toBe(ActionType.LOAD_ALL_DATA); expect((action as any).appDataComplete).toEqual(fullState); expect((action as any).task).toBeUndefined(); }); @@ -218,6 +219,7 @@ describe('operation-converter utility', () => { }); const action = convertOpToAction(op); + expect(action.type).toBe(ActionType.LOAD_ALL_DATA); expect((action as any).appDataComplete).toEqual(fullState); }); diff --git a/src/app/op-log/apply/operation-converter.util.ts b/src/app/op-log/apply/operation-converter.util.ts index 96becd1751..f65d20d970 100644 --- a/src/app/op-log/apply/operation-converter.util.ts +++ b/src/app/op-log/apply/operation-converter.util.ts @@ -163,6 +163,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => { // Handle full-state operations (SYNC_IMPORT, BACKUP_IMPORT, Repair) specially // These need their payload wrapped in appDataComplete for the loadAllData action const isFullStateOp = FULL_STATE_OP_TYPES.has(op.opType as OpType); + const replayActionType = isFullStateOp ? ActionType.LOAD_ALL_DATA : actionType; let actionPayload: Record = isFullStateOp ? extractFullStatePayload(op.payload) : (extractActionPayload(op.payload) as Record); @@ -211,7 +212,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => { // named 'type' (like SimpleCounter.type = 'ClickCounter') from overwriting the action type. return { ...actionPayload, - type: actionType, + type: replayActionType, meta: { isPersistent: true, entityType: op.entityType,