feat: suppress idle dialog during active work sessions (#8965)

* @
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 <noreply@anthropic.com>

* fix: repair idle-effects test harness for CI

- Use ReplaySubject<void>(1) so onReady$ replay survives late subscription
- Provide LOCAL_ACTIONS token + provideMockActions
- Fix TS2740 type mismatch on chromeInterfaceMock.onReady$
Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Erick0412-dev 2026-07-14 22:45:49 +08:00 committed by GitHub
parent b3d8c74257
commit 3ae2360345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 256 additions and 2 deletions

View file

@ -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.

View file

@ -84,6 +84,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
},
idle: {
isOnlyOpenIdleWhenCurrentTask: false,
isSuppressIdleDuringFocusMode: false,
isEnableIdleTimeTracking: true,
minIdleTime: 5 * minute,
},

View file

@ -45,5 +45,14 @@ export const IDLE_FORM_CFG: ConfigFormSection<IdleConfig> = {
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,
},
},
],
};

View file

@ -105,6 +105,7 @@ export type IdleConfig = Readonly<{
isEnableIdleTimeTracking: boolean;
minIdleTime: number;
isOnlyOpenIdleWhenCurrentTask: boolean;
isSuppressIdleDuringFocusMode: boolean;
}>;
export type TakeABreakConfig = Readonly<{

View file

@ -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

View file

@ -208,6 +208,10 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
...DEFAULT_GLOBAL_CONFIG.focusMode,
...migrateFocusModeConfig(appDataComplete.globalConfig.focusMode),
},
idle: {
...DEFAULT_GLOBAL_CONFIG.idle,
...appDataComplete.globalConfig.idle,
},
keyboard: migrateKeyboardConfig(appDataComplete.globalConfig.keyboard),
sync: syncConfig,
};

View file

@ -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<Action>;
let store: MockStore;
let chromeInterfaceMock: {
onReady$: Subject<void>;
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<Action>();
const onReady$ = new ReplaySubject<void>(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<boolean>(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);
});
});
});

View file

@ -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 (

View file

@ -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',
},

View file

@ -2463,6 +2463,7 @@
"HELP": "<div><p>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.</p></div>",
"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"
},