fix(config): restore day-start offset after operation replay (#8899)

* chore(deps): pin npm version to remove lockfile drift

Contributors on npm 10 (Node 22's bundled default) vs npm 11 reconcile the
app-builder-lib.minimatch override differently, rewriting package-lock.json on
npm install. Pin npm 11.7.0 via the standard packageManager field (Corepack)
and complete the existing volta block so volta users pick it up automatically.

Ref: discussion #8869

* chore: stop tracking .claude/skills symlink (runtime-managed)

The Claude Code runtime scaffolds .claude/ each session and materializes
.claude/skills as a real directory, clobbering the committed symlink
(-> ../.agents/skills). Git then reported a perpetual 'D .claude/skills' in
every worktree/session. Skill sources remain tracked under .agents/skills/;
let the runtime own .claude/skills and ignore it via the existing /.claude/*
rule.

* fix(config): restore day-start offset after operation replay

Reconcile DateService and app-state from config after incremental or full-state bulk replay without re-minting persistent task operations.

Fixes #8890

* fix(sync): replay full-state operations through reducers

Normalize canonical full-state operation types to LOAD_ALL_DATA during replay while preserving their original operation metadata. This lets repair and legacy backup operations install state through the existing reducers.
This commit is contained in:
Johannes Millan 2026-07-10 13:59:48 +02:00 committed by GitHub
parent c68929d1fb
commit ec81c226c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 279 additions and 4 deletions

View file

@ -1 +0,0 @@
../.agents/skills

1
.gitignore vendored
View file

@ -98,7 +98,6 @@ electron-builder.win-store.yaml
/src/environments/environment.js /src/environments/environment.js
/src/environments/environment.js.map /src/environments/environment.js.map
/.claude/* /.claude/*
!/.claude/skills
.aider* .aider*

View file

@ -361,7 +361,9 @@
"owner": "johannesjo" "owner": "johannesjo"
} }
], ],
"packageManager": "npm@11.7.0",
"volta": { "volta": {
"node": "22.18.0" "node": "22.18.0",
"npm": "11.7.0"
} }
} }

View file

@ -28,6 +28,7 @@ import { updateGlobalConfigSection } from './global-config.actions';
import { import {
selectConfigFeatureState, selectConfigFeatureState,
selectLocalizationConfig, selectLocalizationConfig,
selectMiscConfig,
} from './global-config.reducer'; } from './global-config.reducer';
import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util'; import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util';
import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; 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 { selectAllTasks } from '../../tasks/store/task.selectors';
import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config'; import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config';
import { Log } from '../../../core/log'; 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; 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( notifyElectronAboutCfgChange = createEffect(
() => () =>
this._actions$.pipe( this._actions$.pipe(

View file

@ -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<void> => 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();
});
});

View file

@ -202,6 +202,7 @@ describe('operation-converter utility', () => {
}); });
const action = convertOpToAction(op); const action = convertOpToAction(op);
expect(action.type).toBe(ActionType.LOAD_ALL_DATA);
expect((action as any).appDataComplete).toEqual(fullState); expect((action as any).appDataComplete).toEqual(fullState);
expect((action as any).task).toBeUndefined(); expect((action as any).task).toBeUndefined();
}); });
@ -218,6 +219,7 @@ describe('operation-converter utility', () => {
}); });
const action = convertOpToAction(op); const action = convertOpToAction(op);
expect(action.type).toBe(ActionType.LOAD_ALL_DATA);
expect((action as any).appDataComplete).toEqual(fullState); expect((action as any).appDataComplete).toEqual(fullState);
}); });

View file

@ -163,6 +163,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => {
// Handle full-state operations (SYNC_IMPORT, BACKUP_IMPORT, Repair) specially // Handle full-state operations (SYNC_IMPORT, BACKUP_IMPORT, Repair) specially
// These need their payload wrapped in appDataComplete for the loadAllData action // These need their payload wrapped in appDataComplete for the loadAllData action
const isFullStateOp = FULL_STATE_OP_TYPES.has(op.opType as OpType); const isFullStateOp = FULL_STATE_OP_TYPES.has(op.opType as OpType);
const replayActionType = isFullStateOp ? ActionType.LOAD_ALL_DATA : actionType;
let actionPayload: Record<string, unknown> = isFullStateOp let actionPayload: Record<string, unknown> = isFullStateOp
? extractFullStatePayload(op.payload) ? extractFullStatePayload(op.payload)
: (extractActionPayload(op.payload) as Record<string, unknown>); : (extractActionPayload(op.payload) as Record<string, unknown>);
@ -211,7 +212,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => {
// named 'type' (like SimpleCounter.type = 'ClickCounter') from overwriting the action type. // named 'type' (like SimpleCounter.type = 'ClickCounter') from overwriting the action type.
return { return {
...actionPayload, ...actionPayload,
type: actionType, type: replayActionType,
meta: { meta: {
isPersistent: true, isPersistent: true,
entityType: op.entityType, entityType: op.entityType,