mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
b3d8c74257
commit
3ae2360345
10 changed files with 256 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
},
|
||||
idle: {
|
||||
isOnlyOpenIdleWhenCurrentTask: false,
|
||||
isSuppressIdleDuringFocusMode: false,
|
||||
isEnableIdleTimeTracking: true,
|
||||
minIdleTime: 5 * minute,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export type IdleConfig = Readonly<{
|
|||
isEnableIdleTimeTracking: boolean;
|
||||
minIdleTime: number;
|
||||
isOnlyOpenIdleWhenCurrentTask: boolean;
|
||||
isSuppressIdleDuringFocusMode: boolean;
|
||||
}>;
|
||||
|
||||
export type TakeABreakConfig = Readonly<{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
184
src/app/features/idle/store/idle.effects.spec.ts
Normal file
184
src/app/features/idle/store/idle.effects.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue