From 3ae23603455a575c26515742e44247f552c8d23e Mon Sep 17 00:00:00 2001 From: Erick0412-dev <58981335+Erick0412-dev@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:45:49 +0800 Subject: [PATCH] feat: suppress idle dialog during active work sessions (#8965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * @ feat: suppress idle dialog during focus mode sessions Add a new setting (default: ON) to suppress the idle time dialog when a focus mode session is actively running. The idle popup no longer interrupts Pomodoro, Flowtime, or Countdown sessions. Changes: - Add isSuppressIdleDuringFocusMode field to IdleConfig - Add checkbox to Settings → Time Tracking → Idle Handling - Skip idle trigger in idle.effects.ts when focus session is active - Add translations for all 28 languages Closes: #1834 References: #1676 @ * feat: add opt-in setting to suppress idle dialog during work sessions Address review feedback: narrow scope, default OFF, fix existing-user config merge. Changes: - default: isSuppressIdleDuringFocusMode changed from true to false (opt-in) - reducer: deep-merge idle section on loadAllData so the new field defaults properly for users with persisted idle configs - i18n: revert all non-English translation files, keep only en.json - effects: fix Prettier/ESLint indentation - tests: add reducer test for idle section merging + idle-effects spec - docs: add setting to Settings-and-Preferences.md Co-Authored-By: Claude * fix: repair idle-effects test harness for CI - Use ReplaySubject(1) so onReady$ replay survives late subscription - Provide LOCAL_ACTIONS token + provideMockActions - Fix TS2740 type mismatch on chromeInterfaceMock.onReady$ Co-Authored-By: Claude --------- Co-authored-by: Claude --- docs/wiki/3.02-Settings-and-Preferences.md | 11 ++ .../config/default-global-config.const.ts | 1 + .../config/form-cfgs/idle-form.const.ts | 9 + .../features/config/global-config.model.ts | 1 + .../store/global-config.reducer.spec.ts | 32 +++ .../config/store/global-config.reducer.ts | 4 + .../features/idle/store/idle.effects.spec.ts | 184 ++++++++++++++++++ src/app/features/idle/store/idle.effects.ts | 14 +- src/app/t.const.ts | 1 + src/assets/i18n/en.json | 1 + 10 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/app/features/idle/store/idle.effects.spec.ts diff --git a/docs/wiki/3.02-Settings-and-Preferences.md b/docs/wiki/3.02-Settings-and-Preferences.md index 2bfd0fa7f7..2dfeee1e76 100755 --- a/docs/wiki/3.02-Settings-and-Preferences.md +++ b/docs/wiki/3.02-Settings-and-Preferences.md @@ -40,6 +40,17 @@ Note: Differences between the Web app and the Desktop app are specified in [[3.0 #### global-settings.Idle-Handling +- **Enable idle time tracking** — When enabled, Super Productivity will detect + when you are away from the computer and show an idle dialog asking what you did. +- **Minimum idle time** — The amount of time you need to be away before the idle + dialog is shown. +- **Only show idle dialog when a task is active** — Only show the idle dialog when + a task is currently being tracked. +- **Suppress idle dialog during work sessions** — When enabled, the idle dialog + will not appear while a focus mode work session is running. Useful when you + are working at the computer without active keyboard/mouse input (e.g. reading, + studying, or writing exams). Default: off (opt-in). + #### global-settings.Keyboard-Shortcuts See [[3.03-Keyboard-Shortcuts]] for full list. diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 8459a507b0..b44dddb334 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -84,6 +84,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { }, idle: { isOnlyOpenIdleWhenCurrentTask: false, + isSuppressIdleDuringFocusMode: false, isEnableIdleTimeTracking: true, minIdleTime: 5 * minute, }, diff --git a/src/app/features/config/form-cfgs/idle-form.const.ts b/src/app/features/config/form-cfgs/idle-form.const.ts index dd4d81baf4..0c30648741 100644 --- a/src/app/features/config/form-cfgs/idle-form.const.ts +++ b/src/app/features/config/form-cfgs/idle-form.const.ts @@ -45,5 +45,14 @@ export const IDLE_FORM_CFG: ConfigFormSection = { label: T.GCF.IDLE.IS_ONLY_OPEN_IDLE_WHEN_CURRENT_TASK, }, }, + { + key: 'isSuppressIdleDuringFocusMode', + className: HelperClasses.isHideForNoAdvancedFeatures, + type: 'checkbox', + hideExpression: '!model.isEnableIdleTimeTracking', + templateOptions: { + label: T.GCF.IDLE.IS_SUPPRESS_IDLE_DURING_FOCUS_MODE, + }, + }, ], }; diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 6b506a58d0..f0a3b7b78e 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -105,6 +105,7 @@ export type IdleConfig = Readonly<{ isEnableIdleTimeTracking: boolean; minIdleTime: number; isOnlyOpenIdleWhenCurrentTask: boolean; + isSuppressIdleDuringFocusMode: boolean; }>; export type TakeABreakConfig = Readonly<{ diff --git a/src/app/features/config/store/global-config.reducer.spec.ts b/src/app/features/config/store/global-config.reducer.spec.ts index acc3464f69..ebb571ed31 100644 --- a/src/app/features/config/store/global-config.reducer.spec.ts +++ b/src/app/features/config/store/global-config.reducer.spec.ts @@ -94,6 +94,38 @@ describe('GlobalConfigReducer', () => { expect(result.tasks.notesTemplate).toBe(DEFAULT_GLOBAL_CONFIG.tasks.notesTemplate); }); + it('should fill missing idle config fields with defaults', () => { + // Simulate loading config with a partial idle section that lacks the + // newly added isSuppressIdleDuringFocusMode field (e.g., existing user + // whose persisted idle config predates this setting). + const partialIdleConfig = { + isEnableIdleTimeTracking: true, + isOnlyOpenIdleWhenCurrentTask: false, + minIdleTime: 5 * 60 * 1000, + // isSuppressIdleDuringFocusMode is intentionally absent + }; + + const incomingConfig = { + ...initialGlobalConfigState, + idle: partialIdleConfig as any, + }; + + const result = globalConfigReducer( + initialGlobalConfigState, + loadAllData({ + appDataComplete: { globalConfig: incomingConfig } as AppDataComplete, + }), + ); + + // Existing values preserved + expect(result.idle.isEnableIdleTimeTracking).toBe(true); + expect(result.idle.minIdleTime).toBe(5 * 60 * 1000); + // Missing value filled from default (must be false = opt-in) + expect(result.idle.isSuppressIdleDuringFocusMode).toBe( + DEFAULT_GLOBAL_CONFIG.idle.isSuppressIdleDuringFocusMode, + ); + }); + it('should coerce a legacy null defaultProjectId to the Inbox default (#7891)', () => { // Older configs stored `null` (the removed "None" default). With the "None" // option gone, that value no longer matches a dropdown option, so it must be diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index fde426a31f..1b29767432 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -208,6 +208,10 @@ export const globalConfigReducer = createReducer( ...DEFAULT_GLOBAL_CONFIG.focusMode, ...migrateFocusModeConfig(appDataComplete.globalConfig.focusMode), }, + idle: { + ...DEFAULT_GLOBAL_CONFIG.idle, + ...appDataComplete.globalConfig.idle, + }, keyboard: migrateKeyboardConfig(appDataComplete.globalConfig.keyboard), sync: syncConfig, }; diff --git a/src/app/features/idle/store/idle.effects.spec.ts b/src/app/features/idle/store/idle.effects.spec.ts new file mode 100644 index 0000000000..85d4c7e30e --- /dev/null +++ b/src/app/features/idle/store/idle.effects.spec.ts @@ -0,0 +1,184 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs'; +import { Action } from '@ngrx/store'; +import { IdleEffects } from './idle.effects'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { DataInitStateService } from '../../../core/data-init/data-init-state.service'; +import { ChromeExtensionInterfaceService } from '../../../core/chrome-extension-interface/chrome-extension-interface.service'; +import { WorkContextService } from '../../work-context/work-context.service'; +import { TaskService } from '../../tasks/task.service'; +import { MatDialog } from '@angular/material/dialog'; +import { UiHelperService } from '../../ui-helper/ui-helper.service'; +import { SimpleCounterService } from '../../simple-counter/simple-counter.service'; +import { DateService } from '../../../core/date/date.service'; +import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; +import { IPC } from '../../../../../electron/shared-with-frontend/ipc-events.const'; +import { selectIdleConfig } from '../../config/store/global-config.reducer'; +import { selectIsSessionRunning } from '../../focus-mode/store/focus-mode.selectors'; +import { selectIsIdle } from './idle.selectors'; +import { triggerIdle } from './idle.actions'; + +describe('IdleEffects', () => { + let effects: IdleEffects; + let actions$: Subject; + let store: MockStore; + let chromeInterfaceMock: { + onReady$: Subject; + addEventListener: jasmine.Spy; + }; + let idleCallback: ((ev: Event, data?: unknown) => void) | null; + + const setup = (overrides?: { + isSuppressIdleDuringFocusMode?: boolean; + isFocusSessionRunning?: boolean; + }): void => { + idleCallback = null; + const isSuppress = overrides?.isSuppressIdleDuringFocusMode ?? false; + const isSessionRunning = overrides?.isFocusSessionRunning ?? false; + + actions$ = new Subject(); + const onReady$ = new ReplaySubject(1); + chromeInterfaceMock = { + onReady$, + addEventListener: jasmine + .createSpy('addEventListener') + .and.callFake((event: string, cb: (ev: Event, data?: unknown) => void) => { + if (event === IPC.IDLE_TIME) { + idleCallback = cb; + } + }), + }; + + const dataInitStateMock = { + isAllDataLoadedInitially$: new BehaviorSubject(true), + }; + + const taskServiceMock = { + currentTaskId: jasmine.createSpy('currentTaskId').and.returnValue('task-1'), + }; + + TestBed.configureTestingModule({ + providers: [ + IdleEffects, + provideMockActions(() => actions$), + { provide: LOCAL_ACTIONS, useValue: actions$ }, + provideMockStore({ + selectors: [ + { + selector: selectIdleConfig, + value: { + isEnableIdleTimeTracking: true, + isSuppressIdleDuringFocusMode: isSuppress, + isOnlyOpenIdleWhenCurrentTask: false, + minIdleTime: 60000, + }, + }, + { selector: selectIsSessionRunning, value: isSessionRunning }, + { selector: selectIsIdle, value: false }, + ], + }), + { provide: DataInitStateService, useValue: dataInitStateMock }, + { provide: ChromeExtensionInterfaceService, useValue: chromeInterfaceMock }, + { provide: WorkContextService, useValue: {} as any }, + { provide: TaskService, useValue: taskServiceMock }, + { provide: MatDialog, useValue: {} as any }, + { provide: UiHelperService, useValue: {} as any }, + { + provide: SimpleCounterService, + useValue: { enabledSimpleStopWatchCounters$: new BehaviorSubject([]) }, + }, + { + provide: DateService, + useValue: { todayStr: () => '2026-07-13' }, + }, + ], + }); + + effects = TestBed.inject(IdleEffects); + store = TestBed.inject(MockStore); + + // Emit ready signal so _triggerIdleApis$ subscribes to the inner listener. + // ReplaySubject replays the value even though the effect hasn't subscribed yet. + onReady$.next(); + onReady$.complete(); + }; + + afterEach(() => { + store?.resetSelectors(); + }); + + describe('triggerIdleWhenEnabled$', () => { + it('should suppress idle when isSuppressIdleDuringFocusMode is true and a work session is running', (done) => { + setup({ isSuppressIdleDuringFocusMode: true, isFocusSessionRunning: true }); + + const emitted: unknown[] = []; + const sub = effects.triggerIdleWhenEnabled$.subscribe({ + next: (action) => emitted.push(action), + error: (err) => { + sub.unsubscribe(); + done.fail(err); + }, + }); + + // Fire idle time above minIdleTime (60000ms) + if (idleCallback) { + idleCallback(null as unknown as Event, 120000); + } + + setTimeout(() => { + sub.unsubscribe(); + expect(emitted.length).toBe(0); + done(); + }, 200); + }); + + it('should NOT suppress idle when isSuppressIdleDuringFocusMode is true but no session is running', (done) => { + setup({ isSuppressIdleDuringFocusMode: true, isFocusSessionRunning: false }); + + const emitted: unknown[] = []; + const sub = effects.triggerIdleWhenEnabled$.subscribe({ + next: (action) => emitted.push(action), + error: (err) => { + sub.unsubscribe(); + done.fail(err); + }, + }); + + if (idleCallback) { + idleCallback(null as unknown as Event, 120000); + } + + setTimeout(() => { + sub.unsubscribe(); + expect(emitted.length).toBe(1); + expect(emitted[0]).toEqual(triggerIdle({ idleTime: 120000 })); + done(); + }, 200); + }); + + it('should NOT suppress idle when isSuppressIdleDuringFocusMode is false even if session is running', (done) => { + setup({ isSuppressIdleDuringFocusMode: false, isFocusSessionRunning: true }); + + const emitted: unknown[] = []; + const sub = effects.triggerIdleWhenEnabled$.subscribe({ + next: (action) => emitted.push(action), + error: (err) => { + sub.unsubscribe(); + done.fail(err); + }, + }); + + if (idleCallback) { + idleCallback(null as unknown as Event, 120000); + } + + setTimeout(() => { + sub.unsubscribe(); + expect(emitted.length).toBe(1); + expect(emitted[0]).toEqual(triggerIdle({ idleTime: 120000 })); + done(); + }, 200); + }); + }); +}); diff --git a/src/app/features/idle/store/idle.effects.ts b/src/app/features/idle/store/idle.effects.ts index 0d4f3b45b1..7f6c338878 100644 --- a/src/app/features/idle/store/idle.effects.ts +++ b/src/app/features/idle/store/idle.effects.ts @@ -117,21 +117,31 @@ export class IdleEffects { ({ isEnableIdleTimeTracking, isOnlyOpenIdleWhenCurrentTask, + isSuppressIdleDuringFocusMode, minIdleTime = DEFAULT_MIN_IDLE_TIME, }) => !isEnableIdleTimeTracking ? of(resetIdle()) : this._triggerIdleApis$.pipe( - withLatestFrom(this._store.select(selectIsIdle)), - switchMap(([idleTimeInMs, isAlreadyIdle]) => { + withLatestFrom( + this._store.select(selectIsIdle), + this._isFocusSessionRunning$, + ), + switchMap(([idleTimeInMs, isAlreadyIdle, isFocusSessionRunning]) => { // Skip if already in idle state - let internal poll timer handle updates if (isAlreadyIdle) { return EMPTY; } + + if (isSuppressIdleDuringFocusMode && isFocusSessionRunning) { + return EMPTY; + } + Log.verbose('triggerIdleWhenEnabled$', { idleTimeInMs, isEnableIdleTimeTracking, isOnlyOpenIdleWhenCurrentTask, + isSuppressIdleDuringFocusMode, minIdleTime, }); if ( diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 833ac64e03..b1183756f2 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2525,6 +2525,7 @@ const T = { HELP: 'GCF.IDLE.HELP', IS_ENABLE_IDLE_TIME_TRACKING: 'GCF.IDLE.IS_ENABLE_IDLE_TIME_TRACKING', IS_ONLY_OPEN_IDLE_WHEN_CURRENT_TASK: 'GCF.IDLE.IS_ONLY_OPEN_IDLE_WHEN_CURRENT_TASK', + IS_SUPPRESS_IDLE_DURING_FOCUS_MODE: 'GCF.IDLE.IS_SUPPRESS_IDLE_DURING_FOCUS_MODE', MIN_IDLE_TIME: 'GCF.IDLE.MIN_IDLE_TIME', TITLE: 'GCF.IDLE.TITLE', }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a500b91994..8037f7fcc3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2463,6 +2463,7 @@ "HELP": "

When idle time handling is enabled, a dialog will open after a specified amount of time to check if - and on which - task you want to track your time when you have been idle.

", "IS_ENABLE_IDLE_TIME_TRACKING": "Enable idle time handling", "IS_ONLY_OPEN_IDLE_WHEN_CURRENT_TASK": "Only trigger idle time dialog when the current task is selected", + "IS_SUPPRESS_IDLE_DURING_FOCUS_MODE": "Suppress idle dialog during focus mode sessions", "MIN_IDLE_TIME": "Trigger idle after X", "TITLE": "Idle Handling" },