fix(focus-mode): prevent CPU spike on task completion

Refactored updateBanner$ effect from selector-based to action-based pattern
to prevent excessive re-evaluation that caused 95-107% CPU spikes when
marking tasks as done with Focus Mode enabled.

Changes:
- Converted updateBanner$ from combineLatest([10 selectors]) to action-based
  pattern that only fires on relevant Focus Mode actions
- Added throttling (500ms) to limit banner updates to max 2/second
- Added skipWhileApplyingRemoteOps guards to setTaskBarProgress$ and
  playTickSound$ effects to prevent duplicate operations during sync
- Added distinctUntilChanged to flatDoneTodayNr$ selector to reduce
  unnecessary task list filtering

Fixes #6001
This commit is contained in:
Johannes Millan 2026-01-16 19:21:16 +01:00
parent 5b1a843196
commit e8054b1b3d
2 changed files with 37 additions and 14 deletions

View file

@ -12,6 +12,7 @@ import {
switchMap,
take,
tap,
throttleTime,
withLatestFrom,
} from 'rxjs/operators';
import * as actions from './focus-mode.actions';
@ -632,6 +633,7 @@ export class FocusModeEffects {
createEffect(
() =>
this.store.select(selectors.selectProgress).pipe(
skipWhileApplyingRemoteOps(),
withLatestFrom(this.store.select(selectors.selectIsRunning)),
tap(([progress, isRunning]) => {
window.ea.setProgressBar({
@ -656,24 +658,43 @@ export class FocusModeEffects {
{ dispatch: false },
);
// Update banner when session or break state changes
// Only shows banner when focus mode feature is enabled
// Update banner when focus mode actions occur
// Action-based pattern preferred over selector-based (CLAUDE.md Section 8)
// Throttled to prevent excessive banner updates (timer ticks every 1s)
updateBanner$ = createEffect(
() =>
combineLatest([
this.store.select(selectors.selectIsSessionRunning),
this.store.select(selectors.selectIsBreakActive),
this.store.select(selectors.selectIsSessionCompleted),
this.store.select(selectors.selectIsSessionPaused),
this.store.select(selectors.selectMode),
this.store.select(selectors.selectCurrentCycle),
this.store.select(selectors.selectIsOverlayShown),
this.store.select(selectors.selectTimer),
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
this.actions$.pipe(
ofType(
actions.tick,
actions.startFocusSession,
actions.pauseFocusSession,
actions.unPauseFocusSession,
actions.startBreak,
actions.skipBreak,
actions.completeBreak,
actions.completeFocusSession,
actions.cancelFocusSession,
actions.hideFocusOverlay,
actions.showFocusOverlay,
),
// Throttle to prevent excessive banner updates (timer ticks every 1s)
// Use leading + trailing to ensure first and last updates both trigger
throttleTime(500, undefined, { leading: true, trailing: true }),
withLatestFrom(
this.store.select(selectors.selectIsSessionRunning),
this.store.select(selectors.selectIsBreakActive),
this.store.select(selectors.selectIsSessionCompleted),
this.store.select(selectors.selectIsSessionPaused),
this.store.select(selectors.selectMode),
this.store.select(selectors.selectCurrentCycle),
this.store.select(selectors.selectIsOverlayShown),
this.store.select(selectors.selectTimer),
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
),
tap(
([
_action,
isSessionRunning,
isOnBreak,
isSessionCompleted,
@ -986,6 +1007,7 @@ export class FocusModeEffects {
playTickSound$ = createEffect(
() =>
this.store.select(selectors.selectTimer).pipe(
skipWhileApplyingRemoteOps(),
filter(
(timer) => timer.isRunning && timer.purpose === 'work' && timer.elapsed > 0,
),

View file

@ -381,6 +381,7 @@ export class WorkContextService {
const done = tasks.filter((task) => task.isDone);
return done.length;
}),
distinctUntilChanged(), // Only emit when count actually changes
);
undoneTasks$: Observable<TaskWithSubTasks[]> = combineLatest([