fix(sync): improve robustness of sync and counter operations

Code review improvements addressing critical and high priority issues:

Archive Handler:
- Rollback BOTH archiveYoung and archiveOld on flush failure
- Prevents data loss when partial write occurs

Cache Invalidation:
- Add _unsyncedCache invalidation in deleteOpsWhere
- Prevents stale data when deleted ops include unsynced operations

Simple Counter:
- Extract _getCounterValue helper to reduce code duplication
- Use selectSimpleCounterById (O(1)) instead of selectAllSimpleCounters+find (O(n))
- Update tests to properly mock both selectors

Operation Log Sync:
- Add infinite loop prevention when force download returns no clocks
- Add GREATER_THAN corruption detection (treats as CONCURRENT to be safe)

ESLint Hydration Guard Rule:
- Fix combineLatest detection at root level vs nested in operator callbacks
- Add comprehensive test suite (17 test cases)

E2E Tests:
- Fix flaky reminders-schedule-page tests (tasks disappear after scheduling)
This commit is contained in:
Johannes Millan 2025-12-19 11:42:56 +01:00
parent 8135953836
commit 3d4bfb5637
8 changed files with 521 additions and 135 deletions

View file

@ -2,7 +2,6 @@ import type { Locator, Page } from '@playwright/test';
import { test, expect } from '../../fixtures/test.fixture';
const TASK = 'task';
const TASK_SCHEDULE_BTN = '.ico-btn.schedule-btn';
const SCHEDULE_DIALOG = 'dialog-schedule-task';
const SCHEDULE_DIALOG_TIME_INPUT = 'dialog-schedule-task input[type="time"]';
const SCHEDULE_DIALOG_CONFIRM = 'mat-dialog-actions button:last-child';
@ -94,10 +93,9 @@ test.describe('Reminders Schedule Page', () => {
// Open detail panel to access schedule action
await scheduleTaskViaDetailPanel(page, targetTask, scheduleTime);
// Wait for schedule indicator to appear on the task
await targetTask
.locator(TASK_SCHEDULE_BTN)
.waitFor({ state: 'visible', timeout: 10000 });
// Note: After scheduling with time, task may disappear from Today view
// because scheduleTaskWithTime sets dueDay: undefined.
// We skip checking for schedule button visibility and go directly to scheduled list.
// Navigate to scheduled page
try {
@ -139,10 +137,9 @@ test.describe('Reminders Schedule Page', () => {
await scheduleTaskViaDetailPanel(page, task, scheduleTime);
await task
.locator(TASK_SCHEDULE_BTN)
.first()
.waitFor({ state: 'visible', timeout: 10000 });
// Note: After scheduling with time, task may disappear from Today view
// because scheduleTaskWithTime sets dueDay: undefined.
// We skip checking for schedule button visibility.
};
// Add and schedule first task
@ -177,13 +174,9 @@ test.describe('Reminders Schedule Page', () => {
await scheduleTask(title2, scheduleTime2);
// Verify both tasks have schedule indicators
// Use first() to avoid multiple element issues if there are duplicates
const task1 = page.locator(TASK).filter({ hasText: title1 }).first();
const task2 = page.locator(TASK).filter({ hasText: title2 }).first();
await expect(task1.locator(TASK_SCHEDULE_BTN).first()).toBeVisible();
await expect(task2.locator(TASK_SCHEDULE_BTN).first()).toBeVisible();
// Note: After scheduling with time, tasks disappear from Today view
// (scheduleTaskWithTime sets dueDay: undefined).
// We verify tasks in the scheduled list instead.
// Navigate to scheduled page
try {

View file

@ -151,33 +151,54 @@ module.exports = {
};
/**
* Check if a selector is used as a secondary source (e.g., in withLatestFrom, combineLatest).
* Check if a selector is used as a secondary source (e.g., in withLatestFrom, combineLatestWith).
* These selectors don't drive the observable - they're just providing supplemental data.
*
* IMPORTANT: This distinguishes between:
* - `combineLatest([sel1, sel2]).pipe(...)` at root level selectors ARE primary (not secondary)
* - `actions$.pipe(combineLatestWith(sel))` selector IS secondary
* - `actions$.pipe(withLatestFrom(sel))` selector IS secondary
*/
const isSecondarySource = (selectorNode) => {
let current = selectorNode.parent;
let passedFunctionBoundary = false;
while (current) {
// Track if we've passed a function boundary (indicating we're inside an operator callback)
if (
current.type === 'ArrowFunctionExpression' ||
current.type === 'FunctionExpression'
) {
passedFunctionBoundary = true;
}
if (current.type === 'CallExpression') {
const callee = current.callee;
if (callee.type === 'Identifier') {
const opName = callee.name;
// Operators where selectors are secondary sources
if (
[
'withLatestFrom',
'combineLatest',
'combineLatestWith',
'forkJoin',
'zip',
'zipWith',
].includes(opName)
) {
// Check if the selector is in the arguments of this operator
// withLatestFrom is ALWAYS secondary (only used inside pipe chains)
if (opName === 'withLatestFrom') {
if (current.arguments.some((arg) => containsNode(arg, selectorNode))) {
return true;
}
}
// combineLatestWith, zipWith are ALWAYS secondary (only used inside pipe chains)
if (['combineLatestWith', 'zipWith'].includes(opName)) {
if (current.arguments.some((arg) => containsNode(arg, selectorNode))) {
return true;
}
}
// combineLatest, forkJoin, zip at ROOT level (not inside a function) → NOT secondary
// But if inside a function (like switchMap callback) → IS secondary
if (['combineLatest', 'forkJoin', 'zip'].includes(opName)) {
if (current.arguments.some((arg) => containsNode(arg, selectorNode))) {
// Only secondary if we're inside an operator callback
return passedFunctionBoundary;
}
}
}
}
current = current.parent;
@ -297,6 +318,13 @@ module.exports = {
findPrimarySelectorCalls(node.argument, effectFnNode, results);
}
// Handle ArrayExpression (e.g., combineLatest([sel1, sel2]))
if (node.type === 'ArrayExpression') {
for (const element of node.elements || []) {
findPrimarySelectorCalls(element, effectFnNode, results);
}
}
return results;
};

View file

@ -0,0 +1,243 @@
/**
* Tests for require-hydration-guard ESLint rule
*/
const { RuleTester } = require('eslint');
const rule = require('./require-hydration-guard');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
});
ruleTester.run('require-hydration-guard', rule, {
valid: [
// Action-based effect - should NOT flag (actions$.pipe is primary source)
{
code: `
createEffect(() =>
this._actions$.pipe(
ofType(someAction),
withLatestFrom(this._store$.select(mySelector)),
map(([action, data]) => anotherAction())
)
)
`,
},
// Selector with skipDuringSync guard - should NOT flag
{
code: `
createEffect(() =>
this._store$.select(mySelector).pipe(
skipDuringSync(),
tap(() => this.doSomething())
)
)
`,
},
// Selector with isApplyingRemoteOps guard - should NOT flag
{
code: `
createEffect(() =>
this._store$.select(mySelector).pipe(
filter(() => !this.hydrationState.isApplyingRemoteOps()),
tap(() => this.doSomething())
)
)
`,
},
// Nested selector inside switchMap - should NOT flag (protected by outer stream)
{
code: `
createEffect(() =>
this._actions$.pipe(
ofType(someAction),
switchMap(() => this._store$.select(mySelector)),
map(data => anotherAction())
)
)
`,
},
// Selector in withLatestFrom (secondary source) - should NOT flag
{
code: `
createEffect(() =>
this._actions$.pipe(
ofType(someAction),
withLatestFrom(this._store$.select(selectorA), this._store$.select(selectorB)),
map(([action, a, b]) => anotherAction())
)
)
`,
},
// Selector in combineLatestWith (secondary source, inside operator chain) - should NOT flag
{
code: `
createEffect(() =>
this._actions$.pipe(
ofType(someAction),
combineLatestWith(this._store$.select(mySelector)),
map(([action, data]) => anotherAction())
)
)
`,
},
// Using store instead of _store$
{
code: `
createEffect(() =>
this.store.select(mySelector).pipe(
skipDuringSync(),
tap(() => this.doSomething())
)
)
`,
},
// Using store$ property
{
code: `
createEffect(() =>
this.store$.select(mySelector).pipe(
filter(() => !this.hydrationState.isApplyingRemoteOps()),
tap(() => this.doSomething())
)
)
`,
},
// Guard in second pipe call - should NOT flag
{
code: `
createEffect(() =>
this._store$.select(mySelector)
.pipe(map(x => x))
.pipe(skipDuringSync())
.pipe(tap(() => this.doSomething()))
)
`,
},
// Not a createEffect call - should NOT flag
{
code: `
someOtherFunction(() =>
this._store$.select(mySelector).pipe(
tap(() => this.doSomething())
)
)
`,
},
// combineLatest INSIDE switchMap - should NOT flag (nested in operator callback)
{
code: `
createEffect(() =>
this._actions$.pipe(
ofType(someAction),
switchMap(() =>
combineLatest([
this._store$.select(selectorA),
this._store$.select(selectorB)
])
),
map(([a, b]) => anotherAction())
)
)
`,
},
],
invalid: [
// Selector without guard - SHOULD flag
{
code: `
createEffect(() =>
this._store$.select(mySelector).pipe(
tap(() => this.doSomething())
)
)
`,
errors: [{ messageId: 'missingHydrationGuard' }],
},
// _store$ variant without guard - SHOULD flag
{
code: `
createEffect(() =>
this._store$.select(selector).pipe(
map(x => x)
)
)
`,
errors: [{ messageId: 'missingHydrationGuard' }],
},
// store (not _store$) without guard - SHOULD flag
{
code: `
createEffect(() =>
this.store.select(mySelector).pipe(
filter(x => x !== null),
tap(() => this.doSomething())
)
)
`,
errors: [{ messageId: 'missingHydrationGuard' }],
},
// Multiple pipe calls without guard - SHOULD flag
{
code: `
createEffect(() =>
this._store$.select(mySelector)
.pipe(map(x => x))
.pipe(tap(() => this.doSomething()))
)
`,
errors: [{ messageId: 'missingHydrationGuard' }],
},
// Complex pipe chain without guard - SHOULD flag
{
code: `
createEffect(() =>
this._store$.select(selectFeatureState).pipe(
distinctUntilChanged(),
filter(state => state.isReady),
map(state => someAction({ data: state.data }))
)
)
`,
errors: [{ messageId: 'missingHydrationGuard' }],
},
// combineLatest at ROOT level without guard - SHOULD flag (both selectors are primary)
// Note: This is different from combineLatestWith inside a pipe chain
{
code: `
createEffect(() =>
combineLatest([
this._store$.select(selectorA),
this._store$.select(selectorB)
]).pipe(
map(([a, b]) => someAction({ a, b }))
)
)
`,
errors: [
{ messageId: 'missingHydrationGuard' },
{ messageId: 'missingHydrationGuard' },
],
},
],
});
console.log('All tests passed!');

View file

@ -233,37 +233,78 @@ export class ArchiveOperationHandler {
const timestamp = (action as ReturnType<typeof flushYoungToOld>).timestamp;
const pfapi = this._getPfapiService();
const archiveYoung = await pfapi.m.archiveYoung.load();
const archiveOld = await pfapi.m.archiveOld.load();
// Load original state for potential rollback
const originalArchiveYoung = await pfapi.m.archiveYoung.load();
const originalArchiveOld = await pfapi.m.archiveOld.load();
const newSorted = sortTimeTrackingAndTasksFromArchiveYoungToOld({
archiveYoung,
archiveOld,
archiveYoung: originalArchiveYoung,
archiveOld: originalArchiveOld,
threshold: ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD,
now: timestamp,
});
await pfapi.m.archiveYoung.save(
{
...newSorted.archiveYoung,
lastTimeTrackingFlush: timestamp,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true, // Remote ops: DB is locked during sync processing
},
);
try {
await pfapi.m.archiveYoung.save(
{
...newSorted.archiveYoung,
lastTimeTrackingFlush: timestamp,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true, // Remote ops: DB is locked during sync processing
},
);
await pfapi.m.archiveOld.save(
{
...newSorted.archiveOld,
lastTimeTrackingFlush: timestamp,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true, // Remote ops: DB is locked during sync processing
},
);
await pfapi.m.archiveOld.save(
{
...newSorted.archiveOld,
lastTimeTrackingFlush: timestamp,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true, // Remote ops: DB is locked during sync processing
},
);
} catch (e) {
// Attempt rollback: restore BOTH archiveYoung and archiveOld to original state
Log.err('Archive flush failed, attempting rollback...', e);
const rollbackErrors: Error[] = [];
// Rollback archiveYoung
try {
if (originalArchiveYoung) {
await pfapi.m.archiveYoung.save(originalArchiveYoung, {
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true,
});
}
} catch (rollbackErr) {
rollbackErrors.push(rollbackErr as Error);
}
// Rollback archiveOld
try {
if (originalArchiveOld) {
await pfapi.m.archiveOld.save(originalArchiveOld, {
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true,
});
}
} catch (rollbackErr) {
rollbackErrors.push(rollbackErr as Error);
}
if (rollbackErrors.length > 0) {
Log.err(
'Archive flush rollback FAILED - archive may be inconsistent',
rollbackErrors,
);
} else {
Log.log('Archive flush rollback successful');
}
throw e; // Re-throw original error
}
Log.log(
'______________________\nFLUSHED ALL FROM ARCHIVE YOUNG TO OLD (via remote op handler)\n_______________________',

View file

@ -418,13 +418,22 @@ export class OperationLogStoreService {
const tx = this.db.transaction('ops', 'readwrite');
const store = tx.objectStore('ops');
let cursor = await store.openCursor();
let deletedCount = 0;
while (cursor) {
if (predicate(cursor.value)) {
await cursor.delete();
deletedCount++;
}
cursor = await cursor.continue();
}
await tx.done;
// Invalidate caches if any ops were deleted to prevent stale data
if (deletedCount > 0) {
this._appliedOpIdsCache = null;
this._cacheLastSeq = 0;
this._invalidateUnsyncedCache();
}
}
async getLastSeq(): Promise<number> {

View file

@ -381,8 +381,20 @@ export class OperationLogSyncService {
forceDownloadResult.allOpClocks,
);
} else {
// No extra clocks from force download, resolve with what we have
mergedOpsCreated += await this._resolveStaleLocalOps(stillPendingOps);
// Force download returned no clocks but we have concurrent ops.
// This is an unrecoverable edge case - cannot safely resolve without server clocks.
// Mark ops as rejected to prevent infinite retry loop.
OpLog.err(
`OperationLogSyncService: Force download returned no clocks. ` +
`Cannot safely resolve ${stillPendingOps.length} concurrent ops. Marking as rejected.`,
);
for (const { opId } of stillPendingOps) {
await this.opLogStore.markRejected([opId]);
}
this.snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.CONFLICT_RESOLUTION_FAILED,
});
}
}
} else {
@ -1358,18 +1370,21 @@ export class OperationLogSyncService {
/**
* Adjusts comparison result for potential per-entity clock corruption.
* Converts LESS_THAN to CONCURRENT if corruption is suspected.
* Converts LESS_THAN or GREATER_THAN to CONCURRENT if corruption is suspected.
*
* ## Known Limitation
* This only handles LESS_THAN corruption (where local ops would be incorrectly
* skipped). GREATER_THAN corruption (where remote ops would be skipped) is NOT
* handled because:
* 1. It's extremely rare (requires specific corruption pattern)
* 2. Local pending ops will eventually sync on next sync cycle
* 3. Adding complexity for this edge case isn't worth the risk of introducing bugs
* ## Corruption Detection
* Potential corruption is detected when:
* - Entity has pending local ops (we made changes)
* - But has no snapshot clock AND empty local frontier
* - This suggests the clock data was lost/corrupted
*
* If you encounter data loss from GREATER_THAN corruption, a full re-sync
* (clear local data and download from server) will restore consistency.
* ## Safety Behavior
* When corruption is suspected:
* - LESS_THAN CONCURRENT: Prevents incorrectly skipping local ops
* - GREATER_THAN CONCURRENT: Prevents incorrectly skipping remote ops
*
* Converting to CONCURRENT forces conflict resolution, which is safer than
* silently skipping either local or remote operations.
*/
private _adjustForClockCorruption(
comparison: VectorClockComparison,
@ -1390,6 +1405,15 @@ export class OperationLogSyncService {
);
return VectorClockComparison.CONCURRENT;
}
if (potentialCorruption && comparison === VectorClockComparison.GREATER_THAN) {
OpLog.warn(
`OperationLogSyncService: Converting GREATER_THAN to CONCURRENT for entity ${entityKey} due to potential clock corruption. ` +
`Remote op will be processed via conflict resolution instead of being skipped.`,
);
return VectorClockComparison.CONCURRENT;
}
return comparison;
}

View file

@ -36,6 +36,25 @@ describe('SimpleCounterService', () => {
...partial,
});
/**
* Helper to mock counters for both selectAllSimpleCounters and selectSimpleCounterById.
* Since the service now uses selectSimpleCounterById (O(1) lookup), we need to mock both.
*/
const mockCounters = (counters: SimpleCounter[]): void => {
store.overrideSelector(selectAllSimpleCounters, counters);
// For selectSimpleCounterById, we override the base state since it uses props
const entities: Record<string, SimpleCounter> = {};
for (const counter of counters) {
entities[counter.id] = counter;
}
store.setState({
simpleCounter: {
ids: counters.map((c) => c.id),
entities,
},
});
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
@ -60,10 +79,11 @@ describe('SimpleCounterService', () => {
service = TestBed.inject(SimpleCounterService);
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
// Mock the selector to return counters
store.overrideSelector(selectAllSimpleCounters, [
// Mock the counters with CURRENT state (BEFORE increment)
// New behavior: we read state first, calculate new value, then dispatch both actions
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-15': 6 },
countOnDay: { '2024-01-15': 5 },
}),
]);
});
@ -92,6 +112,7 @@ describe('SimpleCounterService', () => {
// Should dispatch both the increment action AND the sync action
expect(dispatchSpy.calls.count()).toBe(2);
// New behavior: reads state (5) BEFORE dispatch, calculates newVal = 5 + 1 = 6
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
@ -104,10 +125,11 @@ describe('SimpleCounterService', () => {
describe('decreaseCounterToday', () => {
beforeEach(() => {
// Mock the selector to return the counter after decrement
store.overrideSelector(selectAllSimpleCounters, [
// Mock the counters BEFORE decrement
// New behavior: reads state first, calculates new value, then dispatches
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-15': 4 },
countOnDay: { '2024-01-15': 5 },
}),
]);
});
@ -131,6 +153,7 @@ describe('SimpleCounterService', () => {
// Should dispatch both the decrement action AND the sync action
expect(dispatchSpy.calls.count()).toBe(2);
// New behavior: reads state (5) BEFORE dispatch, calculates newVal = 5 - 1 = 4
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
@ -215,7 +238,7 @@ describe('SimpleCounterService', () => {
describe('deleteSimpleCounters', () => {
beforeEach(() => {
store.overrideSelector(selectAllSimpleCounters, [
mockCounters([
createCounter('counter1', { countOnDay: { '2024-01-15': 6 } }),
createCounter('counter2', { countOnDay: { '2024-01-15': 3 } }),
]);
@ -262,7 +285,7 @@ describe('SimpleCounterService', () => {
describe('stopwatch sync with absolute values', () => {
beforeEach(() => {
store.overrideSelector(selectAllSimpleCounters, [
mockCounters([
createCounter('stopwatch1', {
type: SimpleCounterType.StopWatch,
countOnDay: { '2024-01-15': 20000 }, // 20 seconds
@ -282,7 +305,7 @@ describe('SimpleCounterService', () => {
it('should use setSimpleCounterCounterToday for stopwatch sync (not relative duration)', fakeAsync(() => {
// Verify the sync uses absolute values by checking the action type
// When stopwatch syncs, it should dispatch setSimpleCounterCounterToday with absolute value
store.overrideSelector(selectAllSimpleCounters, [
mockCounters([
createCounter('stopwatch1', {
type: SimpleCounterType.StopWatch,
countOnDay: { '2024-01-15': 20000 },
@ -298,15 +321,17 @@ describe('SimpleCounterService', () => {
describe('click counter immediate sync - edge cases', () => {
it('should sync with correct absolute value when counter starts from 0', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, [
// BEFORE increment: counter is at 0
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-15': 1 }, // After increment from 0
countOnDay: { '2024-01-15': 0 },
}),
]);
service.increaseCounterToday('counter1', 1);
tick();
// New behavior: reads state (0) BEFORE dispatch, calculates newVal = 0 + 1 = 1
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
@ -317,15 +342,17 @@ describe('SimpleCounterService', () => {
}));
it('should sync with 0 when decrementing to 0', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, [
// BEFORE decrement: counter is at 1
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-15': 0 }, // After decrement to 0
countOnDay: { '2024-01-15': 1 },
}),
]);
service.decreaseCounterToday('counter1', 1);
tick();
// New behavior: reads state (1) BEFORE dispatch, calculates newVal = max(0, 1 - 1) = 0
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
@ -336,7 +363,8 @@ describe('SimpleCounterService', () => {
}));
it('should handle multiple counters independently', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, [
// BEFORE increment: counters at 10 and 20
mockCounters([
createCounter('counter1', { countOnDay: { '2024-01-15': 10 } }),
createCounter('counter2', { countOnDay: { '2024-01-15': 20 } }),
]);
@ -344,11 +372,11 @@ describe('SimpleCounterService', () => {
service.increaseCounterToday('counter1', 1);
tick();
// Should sync counter1 with its absolute value
// Should sync counter1: reads 10, calculates 10 + 1 = 11
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
newVal: 10,
newVal: 11,
today: '2024-01-15',
}),
);
@ -357,20 +385,21 @@ describe('SimpleCounterService', () => {
service.increaseCounterToday('counter2', 1);
tick();
// Should sync counter2 with its absolute value
// Should sync counter2: reads 20, calculates 20 + 1 = 21
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter2',
newVal: 20,
newVal: 21,
today: '2024-01-15',
}),
);
}));
it('should handle increment by values greater than 1', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, [
// BEFORE increment: counter is at 10
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-15': 15 }, // After increment by 5
countOnDay: { '2024-01-15': 10 },
}),
]);
@ -385,6 +414,7 @@ describe('SimpleCounterService', () => {
}),
);
// New behavior: reads state (10) BEFORE dispatch, calculates newVal = 10 + 5 = 15
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
@ -394,32 +424,20 @@ describe('SimpleCounterService', () => {
);
}));
it('should not dispatch sync if counter not found', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, []);
it('should not dispatch anything if counter not found', fakeAsync(() => {
mockCounters([]);
service.increaseCounterToday('nonexistent', 1);
tick();
// Should dispatch the increment action
expect(dispatchSpy).toHaveBeenCalledWith(
increaseSimpleCounterCounterToday({
id: 'nonexistent',
increaseBy: 1,
today: '2024-01-15',
}),
);
// Should NOT dispatch sync since counter not found
const syncCalls = dispatchSpy.calls
.allArgs()
.filter(
(args) => args[0].type === '[SimpleCounter] Set SimpleCounter Counter Today',
);
expect(syncCalls.length).toBe(0);
// New behavior: reads state first, counter not found, returns early
// Should NOT dispatch ANY actions since counter not found
expect(dispatchSpy.calls.count()).toBe(0);
}));
it('should handle missing countOnDay for today', fakeAsync(() => {
store.overrideSelector(selectAllSimpleCounters, [
// BEFORE increment: counter has no entry for today (defaults to 0)
mockCounters([
createCounter('counter1', {
countOnDay: { '2024-01-14': 5 }, // Yesterday, not today
}),
@ -428,11 +446,11 @@ describe('SimpleCounterService', () => {
service.increaseCounterToday('counter1', 1);
tick();
// Should sync with 0 since today's count is missing
// New behavior: reads state, today's count is 0 (undefined), calculates 0 + 1 = 1
expect(dispatchSpy).toHaveBeenCalledWith(
setSimpleCounterCounterToday({
id: 'counter1',
newVal: 0,
newVal: 1,
today: '2024-01-15',
}),
);

View file

@ -5,6 +5,7 @@ import {
selectEnabledAndToggledSimpleCounters,
selectEnabledSimpleCounters,
selectEnabledSimpleStopWatchCounters,
selectSimpleCounterById,
} from './store/simple-counter.reducer';
import {
addSimpleCounter,
@ -180,18 +181,19 @@ export class SimpleCounterService implements OnDestroy {
* This ensures remote clients get the correct value regardless of their current state.
*/
private _syncStopwatchAbsoluteValue(id: string, date: string): void {
// Read current state synchronously via firstValueFrom
firstValueFrom(this._store$.pipe(select(selectAllSimpleCounters))).then(
(counters) => {
const counter = counters.find((c) => c.id === id);
// Read current state via selectSimpleCounterById (O(1) lookup)
firstValueFrom(this._store$.pipe(select(selectSimpleCounterById, { id })))
.then((counter) => {
if (counter) {
const newVal = counter.countOnDay[date] || 0;
this._store$.dispatch(
setSimpleCounterCounterToday({ id, newVal, today: date }),
);
}
},
);
})
.catch((error) => {
console.error('[SimpleCounterService] Error syncing stopwatch value:', error);
});
}
updateAll(items: SimpleCounter[]): void {
@ -207,36 +209,64 @@ export class SimpleCounterService implements OnDestroy {
this._store$.dispatch(setSimpleCounterCounterForDate({ id, newVal, date }));
}
async increaseCounterToday(id: string, increaseBy: number): Promise<void> {
const today = this._dateService.todayStr();
// Local UI update (non-persistent)
this._store$.dispatch(increaseSimpleCounterCounterToday({ id, increaseBy, today }));
// Immediately sync with absolute value (persistent)
const counters = await firstValueFrom(
this._store$.pipe(select(selectAllSimpleCounters)),
/**
* Get current counter value for today using O(1) selector lookup.
* Returns null if counter doesn't exist.
*/
private async _getCounterValue(id: string, today: string): Promise<number | null> {
const counter = await firstValueFrom(
this._store$.pipe(select(selectSimpleCounterById, { id })),
);
const counter = counters.find((c) => c.id === id);
if (counter) {
const newVal = counter.countOnDay[today] || 0;
this._store$.dispatch(setSimpleCounterCounterToday({ id, newVal, today }));
}
if (!counter) return null;
return counter.countOnDay[today] || 0;
}
/**
* Increase counter and sync with absolute value.
*
* Uses two-phase approach to ensure correct sync:
* 1. Read current value FIRST to calculate correct new value
* 2. Dispatch local update for immediate UI feedback (non-persistent)
* 3. Dispatch sync action with calculated absolute value (persistent)
*
* This prevents race conditions where NgRx hasn't processed the local update
* before we read the state for syncing.
*/
async increaseCounterToday(id: string, increaseBy: number): Promise<void> {
const today = this._dateService.todayStr();
const currentVal = await this._getCounterValue(id, today);
if (currentVal === null) return;
const newVal = currentVal + increaseBy;
// Local UI update (non-persistent)
this._store$.dispatch(increaseSimpleCounterCounterToday({ id, increaseBy, today }));
// Sync with calculated absolute value (persistent)
this._store$.dispatch(setSimpleCounterCounterToday({ id, newVal, today }));
}
/**
* Decrease counter and sync with absolute value.
*
* Uses two-phase approach to ensure correct sync:
* 1. Read current value FIRST to calculate correct new value
* 2. Dispatch local update for immediate UI feedback (non-persistent)
* 3. Dispatch sync action with calculated absolute value (persistent)
*
* This prevents race conditions where NgRx hasn't processed the local update
* before we read the state for syncing.
*/
async decreaseCounterToday(id: string, decreaseBy: number): Promise<void> {
const today = this._dateService.todayStr();
const currentVal = await this._getCounterValue(id, today);
if (currentVal === null) return;
const newVal = Math.max(0, currentVal - decreaseBy);
// Local UI update (non-persistent)
this._store$.dispatch(decreaseSimpleCounterCounterToday({ id, decreaseBy, today }));
// Immediately sync with absolute value (persistent)
const counters = await firstValueFrom(
this._store$.pipe(select(selectAllSimpleCounters)),
);
const counter = counters.find((c) => c.id === id);
if (counter) {
const newVal = counter.countOnDay[today] || 0;
this._store$.dispatch(setSimpleCounterCounterToday({ id, newVal, today }));
}
// Sync with calculated absolute value (persistent)
this._store$.dispatch(setSimpleCounterCounterToday({ id, newVal, today }));
}
toggleCounter(id: string): void {