Merge branch 'master' into feat/operation-logs

* master:
  fix(build): remove deprecated win32metadata from electron-builder config
  fix(focus-mode): address critical focus mode and Android notification issues
  test(task-repeat): fix flaky tests and add Mon/Wed/Fri coverage (#5594)
  fix(electron): delay window focus after notification to prevent accidental input
  feat(task): add Go to Task button for all newly created tasks
  fix(sync): show context-aware permission error for Flatpak/Snap
  fix(android): skip reminder dialog on Android to fix snooze button
  fix(focus-mode): respect isFocusModeEnabled setting in App Features
  fix(android): sync notification timer when time spent is manually changed
  feat(sync): add WebDAV Test Connection button and improve UX
  fix(build): ensure consistent Windows EXE metadata for installer and portable
  Update es.json
  Update es.json
  Update es.json
  feat(i18n): update Turkish language
  16.7.3
  fix: es.json

# Conflicts:
#	src/app/features/android/store/android.effects.ts
#	src/app/features/config/form-cfgs/sync-form.const.ts
#	src/app/features/focus-mode/store/focus-mode.effects.ts
#	src/app/features/tasks/store/task-ui.effects.ts
#	src/app/imex/sync/sync-wrapper.service.ts
#	src/app/pages/config-page/config-page.component.ts
#	src/app/pfapi/api/sync/providers/webdav/webdav.ts
#	src/app/t.const.ts
This commit is contained in:
Johannes Millan 2025-12-22 20:44:21 +01:00
commit 501b8b5a32
39 changed files with 2476 additions and 946 deletions

View file

@ -1,3 +1,9 @@
## [16.7.3](https://github.com/johannesjo/super-productivity/compare/v16.7.2...v16.7.3) (2025-12-20)
### Bug Fixes
- es.json ([75e0e7e](https://github.com/johannesjo/super-productivity/commit/75e0e7e86c4bca3adb886e1d4bccc02c9f48568a))
## [16.7.2](https://github.com/johannesjo/super-productivity/compare/v16.6.1...v16.7.2) (2025-12-19)
### Bug Fixes

View file

@ -20,8 +20,8 @@ android {
minSdkVersion 24
targetSdkVersion 35
compileSdk 35
versionCode 16_07_02_0000
versionName "16.7.2"
versionCode 16_07_03_0000
versionName "16.7.3"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
hostName : "app.super-productivity.com",

View file

@ -86,10 +86,22 @@ class FocusModeForegroundService : Service() {
}
ACTION_UPDATE -> {
val wasPaused = isPaused
title = intent.getStringExtra(EXTRA_TITLE) ?: title
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs)
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused)
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, isBreak)
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle
lastUpdateTimestamp = System.currentTimeMillis()
// Restart update runnable if resuming from paused state
if (wasPaused && !isPaused) {
handler.removeCallbacks(updateRunnable)
handler.post(updateRunnable)
} else if (!wasPaused && isPaused) {
handler.removeCallbacks(updateRunnable)
}
updateNotification()
}

View file

@ -15,6 +15,7 @@ class TrackingForegroundService : Service() {
const val ACTION_START = "com.superproductivity.ACTION_START_TRACKING"
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_TRACKING"
const val ACTION_UPDATE = "com.superproductivity.ACTION_UPDATE_TRACKING"
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_TRACKING"
const val ACTION_DONE = "com.superproductivity.ACTION_MARK_DONE"
const val ACTION_GET_ELAPSED = "com.superproductivity.ACTION_GET_ELAPSED"
@ -79,6 +80,11 @@ class TrackingForegroundService : Service() {
startTracking(taskId, title, timeSpentMs)
}
ACTION_UPDATE -> {
val timeSpentMs = intent.getLongExtra(EXTRA_TIME_SPENT, accumulatedMs)
updateTimeSpent(timeSpentMs)
}
ACTION_STOP -> {
stopTracking()
}
@ -115,6 +121,21 @@ class TrackingForegroundService : Service() {
handler.post(updateRunnable)
}
private fun updateTimeSpent(timeSpentMs: Long) {
if (!isTracking) {
Log.d(TAG, "Ignoring updateTimeSpent: not tracking")
return
}
Log.d(TAG, "Updating time spent: timeSpentMs=$timeSpentMs (was accumulated=$accumulatedMs)")
// Reset the timer with the new accumulated value
accumulatedMs = timeSpentMs
startTimestamp = System.currentTimeMillis()
// Update notification immediately
updateNotification()
}
private fun stopTracking() {
Log.d(TAG, "Stopping tracking, elapsed=${getElapsedMs()}ms")

View file

@ -97,6 +97,16 @@ class JavaScriptInterface(
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun updateTrackingService(timeSpentMs: Long) {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_UPDATE
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun getTrackingElapsed(): String {
@ -143,11 +153,13 @@ class JavaScriptInterface(
@Suppress("unused")
@JavascriptInterface
fun updateFocusModeService(remainingMs: Long, isPaused: Boolean, taskTitle: String?) {
fun updateFocusModeService(title: String, remainingMs: Long, isPaused: Boolean, isBreak: Boolean, taskTitle: String?) {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_UPDATE
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
}
activity.startService(intent)

View file

@ -0,0 +1,4 @@
### Bug Fixes
* es.json

View file

@ -85,6 +85,8 @@ export interface ElectronAPI {
isSnap(): boolean;
isFlatpak(): boolean;
// SEND
// ----
reloadMainWin(): void;

View file

@ -61,6 +61,7 @@ const ea: ElectronAPI = {
isLinux: () => process.platform === 'linux',
isMacOS: () => process.platform === 'darwin',
isSnap: () => process && process.env && !!process.env.SNAP,
isFlatpak: () => process && process.env && !!process.env.FLATPAK_ID,
// SEND
// ----

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "superProductivity",
"version": "16.7.2",
"version": "16.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "superProductivity",
"version": "16.7.2",
"version": "16.7.3",
"license": "MIT",
"workspaces": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"name": "superProductivity",
"version": "16.7.2",
"version": "16.7.3",
"description": "ToDo list and Time Tracking",
"keywords": [
"ToDo",

View file

@ -160,10 +160,17 @@ export class AppComponent implements OnDestroy, AfterViewInit {
),
);
isShowFocusOverlay = toSignal(this._store.select(selectIsOverlayShown), {
private _isOverlayShownFromStore = toSignal(this._store.select(selectIsOverlayShown), {
initialValue: false,
});
// Only show focus overlay if both the store says to show it AND the feature is enabled
isShowFocusOverlay = computed(
() =>
this._isOverlayShownFromStore() &&
this._globalConfigService.cfg()?.appFeatures.isFocusModeEnabled,
);
private readonly _activeWorkContextId = toSignal(
this.workContextService.activeWorkContextId$,
{ initialValue: null },

View file

@ -41,6 +41,7 @@ export interface AndroidInterface {
// Foreground service methods for background time tracking
startTrackingService?(taskId: string, taskTitle: string, timeSpentMs: number): void;
stopTrackingService?(): void;
updateTrackingService?(timeSpentMs: number): void;
getTrackingElapsed?(): string;
// Foreground service methods for focus mode timer
@ -54,8 +55,10 @@ export interface AndroidInterface {
): void;
stopFocusModeService?(): void;
updateFocusModeService?(
title: string,
remainingMs: number,
isPaused: boolean,
isBreak: boolean,
taskTitle: string | null,
): void;

View file

@ -8,6 +8,7 @@ import {
selectIsBreakActive,
selectIsLongBreak,
selectMode,
selectPausedTaskId,
selectTimeRemaining,
selectTimer,
} from '../../focus-mode/store/focus-mode.selectors';
@ -87,12 +88,16 @@ export class AndroidFocusModeEffects {
} else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) {
// Only update if something significant changed
DroidLog.log('AndroidFocusModeEffects: Updating focus mode service', {
title,
remaining: remainingMs,
isPaused: !timer.isRunning,
isBreak: isBreakActive,
});
androidInterface.updateFocusModeService?.(
title,
remainingMs,
!timer.isRunning,
isBreakActive,
taskTitle,
);
}
@ -133,7 +138,8 @@ export class AndroidFocusModeEffects {
createEffect(() =>
androidInterface.onFocusSkip$.pipe(
tap(() => DroidLog.log('AndroidFocusModeEffects: Skip action received')),
map(() => focusModeActions.skipBreak()),
withLatestFrom(this._store.select(selectPausedTaskId)),
map(([_, pausedTaskId]) => focusModeActions.skipBreak({ pausedTaskId })),
),
);

View file

@ -0,0 +1,159 @@
import { TestBed, fakeAsync } from '@angular/core/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { BehaviorSubject } from 'rxjs';
import { TaskService } from '../../tasks/task.service';
import { DateService } from '../../../core/date/date.service';
import { Task } from '../../tasks/task.model';
// We need to test the effect logic by reimplementing it in tests since
// the actual effects are conditionally created based on IS_ANDROID_WEB_VIEW
describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () => {
let store: MockStore;
let currentTask$: BehaviorSubject<Task | null>;
let updateTrackingServiceSpy: jasmine.Spy;
beforeEach(() => {
currentTask$ = new BehaviorSubject<Task | null>(null);
updateTrackingServiceSpy = jasmine.createSpy('updateTrackingService');
TestBed.configureTestingModule({
providers: [
provideMockStore(),
{ provide: TaskService, useValue: { getByIdOnce$: () => currentTask$ } },
{ provide: DateService, useValue: { todayStr: () => '2024-01-01' } },
],
});
store = TestBed.inject(MockStore);
});
afterEach(() => {
store.resetSelectors();
});
/**
* Test the core logic: when timeSpent changes for the same task while tracking,
* the updateTrackingService should be called with the new value.
*/
describe('timeSpent change detection logic', () => {
it('should call updateTrackingService when timeSpent changes for the same task', fakeAsync(() => {
// Simulate the effect logic
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeTrue();
// In real code, this triggers: androidInterface.updateTrackingService?.(curr.timeSpent);
if (shouldUpdate) {
updateTrackingServiceSpy(currState.timeSpent);
}
expect(updateTrackingServiceSpy).toHaveBeenCalledWith(0);
}));
it('should NOT call updateTrackingService when switching to a different task', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-2', timeSpent: 30000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when focus mode is active', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: true };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when no task is being tracked', fakeAsync(() => {
const prevState = { taskId: null, timeSpent: 0, isFocusModeActive: false };
const currState = { taskId: null, timeSpent: 0, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when timeSpent did not change', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should call updateTrackingService when timeSpent is increased', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 120000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeTrue();
if (shouldUpdate) {
updateTrackingServiceSpy(currState.timeSpent);
}
expect(updateTrackingServiceSpy).toHaveBeenCalledWith(120000);
}));
});
describe('distinctUntilChanged behavior', () => {
it('should detect changes when only timeSpent differs', () => {
const stateA = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const stateB = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: false };
// The distinctUntilChanged comparator
const isEqual =
stateA.taskId === stateB.taskId &&
stateA.timeSpent === stateB.timeSpent &&
stateA.isFocusModeActive === stateB.isFocusModeActive;
expect(isEqual).toBeFalse(); // Should NOT be equal, so effect should fire
});
it('should NOT detect changes when state is identical', () => {
const stateA = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const stateB = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const isEqual =
stateA.taskId === stateB.taskId &&
stateA.timeSpent === stateB.timeSpent &&
stateA.isFocusModeActive === stateB.isFocusModeActive;
expect(isEqual).toBeTrue(); // Should be equal, so effect should NOT fire
});
});
});

View file

@ -110,6 +110,55 @@ export class AndroidForegroundTrackingEffects {
{ dispatch: false },
);
/**
* Update the native service when timeSpent changes for the current task.
* This handles the case where the user manually edits the time spent.
*/
syncTimeSpentChanges$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
combineLatest([
this._store.select(selectCurrentTask),
this._store.select(selectTimer),
]).pipe(
map(([currentTask, timer]) => ({
taskId: currentTask?.id || null,
timeSpent: currentTask?.timeSpent || 0,
isFocusModeActive: timer.purpose !== null,
})),
// Only react when timeSpent changes for the same task
distinctUntilChanged(
(a, b) =>
a.taskId === b.taskId &&
a.timeSpent === b.timeSpent &&
a.isFocusModeActive === b.isFocusModeActive,
),
pairwise(),
filter(([prev, curr]) => {
// Only update if:
// 1. Same task (not switching tasks - that's handled by syncTrackingToService$)
// 2. Task exists
// 3. Focus mode is not active (notification is hidden during focus mode)
// 4. timeSpent actually changed
return (
prev.taskId === curr.taskId &&
curr.taskId !== null &&
!curr.isFocusModeActive &&
prev.timeSpent !== curr.timeSpent
);
}),
tap(([, curr]) => {
DroidLog.log('Time spent changed for current task, updating service', {
taskId: curr.taskId,
timeSpent: curr.timeSpent,
});
androidInterface.updateTrackingService?.(curr.timeSpent);
}),
),
{ dispatch: false },
);
/**
* Handle pause action from the notification.
*/

View file

@ -27,6 +27,8 @@ export class AndroidEffects {
// Single-shot guard so we don't spam the user with duplicate warnings.
private _hasShownNotificationWarning = false;
private _hasCheckedExactAlarm = false;
// Track scheduled reminder IDs to cancel removed ones
private _scheduledReminderIds = new Set<string>();
askPermissionsIfNotGiven$ =
IS_ANDROID_WEB_VIEW &&
@ -65,8 +67,24 @@ export class AndroidEffects {
switchMap(() => this._store.select(selectAllTasksWithReminder)),
tap(async (tasksWithReminders) => {
try {
const currentReminderIds = new Set(
(tasksWithReminders || []).map((t) => t.id),
);
// Cancel reminders that are no longer in the list
for (const previousId of this._scheduledReminderIds) {
if (!currentReminderIds.has(previousId)) {
const notificationId = generateNotificationId(previousId);
DroidLog.log('AndroidEffects: cancelling removed reminder', {
relatedId: previousId,
notificationId,
});
androidInterface.cancelNativeReminder?.(notificationId);
}
}
if (!tasksWithReminders || tasksWithReminders.length === 0) {
// Nothing to schedule yet, so avoid triggering the runtime permission dialog prematurely.
this._scheduledReminderIds.clear();
return;
}
DroidLog.log('AndroidEffects: scheduling reminders natively', {
@ -102,6 +120,9 @@ export class AndroidEffects {
);
}
// Update tracked IDs
this._scheduledReminderIds = currentReminderIds;
DroidLog.log('AndroidEffects: scheduled native reminders', {
reminderCount: tasksWithReminders.length,
});

View file

@ -77,6 +77,10 @@ export const selectReminderConfig = createSelector(
selectConfigFeatureState,
(cfg): ReminderConfig => cfg.reminder,
);
export const selectIsFocusModeEnabled = createSelector(
selectConfigFeatureState,
(cfg): boolean => cfg.appFeatures.isFocusModeEnabled,
);
export const initialGlobalConfigState: GlobalConfigState = {
...DEFAULT_GLOBAL_CONFIG,

View file

@ -10,6 +10,7 @@ import {
Signal,
} from '@angular/core';
import { T } from '../../../t.const';
import { of } from 'rxjs';
describe('FocusModeBreakComponent', () => {
let component: FocusModeBreakComponent;
@ -20,9 +21,11 @@ describe('FocusModeBreakComponent', () => {
isBreakLong: Signal<boolean>;
};
let environmentInjector: EnvironmentInjector;
const mockPausedTaskId = 'test-task-id';
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['dispatch']);
mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select']);
mockStore.select.and.returnValue(of(mockPausedTaskId));
mockFocusModeService = {
timeRemaining: signal(300000),
@ -78,18 +81,22 @@ describe('FocusModeBreakComponent', () => {
});
describe('skipBreak', () => {
it('should dispatch skipBreak action', () => {
it('should dispatch skipBreak action with pausedTaskId', () => {
component.skipBreak();
expect(mockStore.dispatch).toHaveBeenCalledWith(skipBreak());
expect(mockStore.dispatch).toHaveBeenCalledWith(
skipBreak({ pausedTaskId: mockPausedTaskId }),
);
});
});
describe('completeBreak', () => {
it('should dispatch completeBreak action', () => {
it('should dispatch completeBreak action with pausedTaskId', () => {
component.completeBreak();
expect(mockStore.dispatch).toHaveBeenCalledWith(completeBreak());
expect(mockStore.dispatch).toHaveBeenCalledWith(
completeBreak({ pausedTaskId: mockPausedTaskId }),
);
});
});
});

View file

@ -5,10 +5,12 @@ import { FocusModeService } from '../focus-mode.service';
import { MsToClockStringPipe } from '../../../ui/duration/ms-to-clock-string.pipe';
import { Store } from '@ngrx/store';
import { completeBreak, skipBreak } from '../store/focus-mode.actions';
import { selectPausedTaskId } from '../store/focus-mode.selectors';
import { MatIcon } from '@angular/material/icon';
import { T } from '../../../t.const';
import { TranslatePipe } from '@ngx-translate/core';
import { TaskTrackingInfoComponent } from '../task-tracking-info/task-tracking-info.component';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'focus-mode-break',
@ -30,6 +32,9 @@ export class FocusModeBreakComponent {
private readonly _store = inject(Store);
T: typeof T = T;
// Get pausedTaskId before break ends (passed in action to avoid race condition)
private readonly _pausedTaskId = toSignal(this._store.select(selectPausedTaskId));
readonly remainingTime = computed(() => {
return this.focusModeService.timeRemaining() || 0;
});
@ -45,10 +50,10 @@ export class FocusModeBreakComponent {
);
skipBreak(): void {
this._store.dispatch(skipBreak());
this._store.dispatch(skipBreak({ pausedTaskId: this._pausedTaskId() }));
}
completeBreak(): void {
this._store.dispatch(completeBreak());
this._store.dispatch(completeBreak({ pausedTaskId: this._pausedTaskId() }));
}
}

View file

@ -42,8 +42,14 @@ export const startBreak = createAction(
'[FocusMode] Start Break',
props<{ duration?: number; isLongBreak?: boolean; pausedTaskId?: string | null }>(),
);
export const skipBreak = createAction('[FocusMode] Skip Break');
export const completeBreak = createAction('[FocusMode] Complete Break');
export const skipBreak = createAction(
'[FocusMode] Skip Break',
props<{ pausedTaskId?: string | null }>(),
);
export const completeBreak = createAction(
'[FocusMode] Complete Break',
props<{ pausedTaskId?: string | null }>(),
);
export const incrementCycle = createAction('[FocusMode] Next Cycle');
export const resetCycles = createAction('[FocusMode] Reset Cycles');

View file

@ -12,11 +12,12 @@ import { FocusModeStorageService } from '../focus-mode-storage.service';
import * as actions from './focus-mode.actions';
import * as selectors from './focus-mode.selectors';
import { FocusModeMode, FocusScreen, TimerState } from '../focus-mode.model';
import { unsetCurrentTask } from '../../tasks/store/task.actions';
import { unsetCurrentTask, setCurrentTask } from '../../tasks/store/task.actions';
import { openIdleDialog } from '../../idle/store/idle.actions';
import { selectTaskById } from '../../tasks/store/task.selectors';
import {
selectFocusModeConfig,
selectIsFocusModeEnabled,
selectPomodoroConfig,
} from '../../config/store/global-config.reducer';
import { updateGlobalConfigSection } from '../../config/store/global-config.actions';
@ -90,6 +91,7 @@ describe('FocusModeEffects', () => {
value: { isSyncSessionWithTracking: false },
},
{ selector: selectPomodoroConfig, value: { duration: 25 * 60 * 1000 } },
{ selector: selectIsFocusModeEnabled, value: true },
],
}),
{ provide: FocusModeStrategyFactory, useValue: strategyFactoryMock },
@ -422,7 +424,7 @@ describe('FocusModeEffects', () => {
describe('breakComplete$', () => {
it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => {
actions$ = of(actions.completeBreak());
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
@ -433,7 +435,7 @@ describe('FocusModeEffects', () => {
});
it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => {
actions$ = of(actions.completeBreak());
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
@ -453,11 +455,30 @@ describe('FocusModeEffects', () => {
},
});
});
it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => {
const pausedTaskId = 'test-paused-task-id';
actions$ = of(actions.completeBreak({ pausedTaskId }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
effects.breakComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(setCurrentTask({ id: pausedTaskId }));
done();
});
});
});
describe('skipBreak$', () => {
it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => {
actions$ = of(actions.skipBreak());
actions$ = of(actions.skipBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
@ -468,7 +489,7 @@ describe('FocusModeEffects', () => {
});
it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => {
actions$ = of(actions.skipBreak());
actions$ = of(actions.skipBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
@ -488,6 +509,25 @@ describe('FocusModeEffects', () => {
},
});
});
it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => {
const pausedTaskId = 'test-paused-task-id';
actions$ = of(actions.skipBreak({ pausedTaskId }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
effects.skipBreak$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(setCurrentTask({ id: pausedTaskId }));
done();
});
});
});
describe('cancelSession$', () => {
@ -613,6 +653,24 @@ describe('FocusModeEffects', () => {
done();
}, 50);
});
it('should NOT dispatch showFocusOverlay when isFocusModeEnabled is false', (done) => {
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.overrideSelector(selectIsFocusModeEnabled, false);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
currentTaskId$.next('task-123');
setTimeout(() => {
// If we get here without the effect emitting, test passes
done();
}, 50);
});
});
describe('syncTrackingStartToSession$', () => {
@ -741,6 +799,27 @@ describe('FocusModeEffects', () => {
done();
}, 50);
});
it('should NOT dispatch when isFocusModeEnabled is false', (done) => {
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.overrideSelector(selectors.selectTimer, createMockTimer());
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentScreen, FocusScreen.Main);
store.overrideSelector(selectIsFocusModeEnabled, false);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
currentTaskId$.next('task-123');
setTimeout(() => {
// Should not start session when focus mode feature is disabled
done();
}, 50);
});
});
describe('syncTrackingStopToSession$', () => {
@ -868,6 +947,32 @@ describe('FocusModeEffects', () => {
done();
}, 50);
});
it('should NOT dispatch when isFocusModeEnabled is false', (done) => {
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.overrideSelector(
selectors.selectTimer,
createMockTimer({ isRunning: true, purpose: 'work' }),
);
store.overrideSelector(selectIsFocusModeEnabled, false);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
currentTaskId$.next('task-123');
setTimeout(() => {
currentTaskId$.next(null);
}, 10);
setTimeout(() => {
// Should not pause session when focus mode feature is disabled
done();
}, 50);
});
});
describe('syncSessionPauseToTracking$', () => {

View file

@ -28,6 +28,7 @@ import { openIdleDialog } from '../../idle/store/idle.actions';
import { LS } from '../../../core/persistence/storage-keys.const';
import {
selectFocusModeConfig,
selectIsFocusModeEnabled,
selectPomodoroConfig,
} from '../../config/store/global-config.reducer';
import { updateGlobalConfigSection } from '../../config/store/global-config.actions';
@ -54,11 +55,15 @@ export class FocusModeEffects {
// Auto-show overlay when task is selected (if sync session with tracking is enabled)
// Skip showing overlay if isStartInBackground is enabled
// Only triggers when focus mode feature is enabled
autoShowOverlay$ = createEffect(() =>
this.store.select(selectFocusModeConfig).pipe(
combineLatest([
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
skipDuringSync(),
switchMap((cfg) =>
cfg?.isSyncSessionWithTracking && !cfg?.isStartInBackground
switchMap(([cfg, isFocusModeEnabled]) =>
isFocusModeEnabled && cfg?.isSyncSessionWithTracking && !cfg?.isStartInBackground
? this.taskService.currentTaskId$.pipe(
distinctUntilChanged(),
filter((id) => !!id),
@ -70,11 +75,14 @@ export class FocusModeEffects {
);
// Sync: When tracking starts → start/unpause focus session
// Only triggers when isSyncSessionWithTracking is enabled
// Only triggers when isSyncSessionWithTracking is enabled and focus mode feature is enabled
syncTrackingStartToSession$ = createEffect(() =>
this.store.select(selectFocusModeConfig).pipe(
switchMap((cfg) =>
cfg?.isSyncSessionWithTracking
combineLatest([
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
switchMap(([cfg, isFocusModeEnabled]) =>
isFocusModeEnabled && cfg?.isSyncSessionWithTracking
? this.taskService.currentTaskId$.pipe(
distinctUntilChanged(),
filter((taskId) => !!taskId),
@ -104,15 +112,18 @@ export class FocusModeEffects {
// Sync: When tracking stops → pause focus session
// Uses pairwise to capture the previous task ID before it's lost
// Only triggers when focus mode feature is enabled
syncTrackingStopToSession$ = createEffect(() =>
this.taskService.currentTaskId$.pipe(
pairwise(),
withLatestFrom(
this.store.select(selectFocusModeConfig),
this.store.select(selectors.selectTimer),
this.store.select(selectIsFocusModeEnabled),
),
filter(
([[prevTaskId, currTaskId], cfg, timer]) =>
([[prevTaskId, currTaskId], cfg, timer, isFocusModeEnabled]) =>
isFocusModeEnabled &&
!!cfg?.isSyncSessionWithTracking &&
timer.purpose === 'work' &&
timer.isRunning &&
@ -298,14 +309,13 @@ export class FocusModeEffects {
);
// Handle break completion
// Note: pausedTaskId is passed in action payload to avoid race condition
// (reducer clears pausedTaskId before effect reads state)
breakComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeBreak),
withLatestFrom(
this.store.select(selectors.selectMode),
this.store.select(selectors.selectPausedTaskId),
),
switchMap(([_, mode, pausedTaskId]) => {
withLatestFrom(this.store.select(selectors.selectMode)),
switchMap(([action, mode]) => {
const strategy = this.strategyFactory.getStrategy(mode);
const actionsToDispatch: any[] = [];
@ -313,8 +323,8 @@ export class FocusModeEffects {
this._notifyUser();
// Resume task tracking if we paused it during break
if (pausedTaskId) {
actionsToDispatch.push(setCurrentTask({ id: pausedTaskId }));
if (action.pausedTaskId) {
actionsToDispatch.push(setCurrentTask({ id: action.pausedTaskId }));
}
// Auto-start next session if configured
@ -329,20 +339,18 @@ export class FocusModeEffects {
);
// Handle skip break
// Note: pausedTaskId is passed in action payload to avoid race condition
skipBreak$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.skipBreak),
withLatestFrom(
this.store.select(selectors.selectMode),
this.store.select(selectors.selectPausedTaskId),
),
switchMap(([_, mode, pausedTaskId]) => {
withLatestFrom(this.store.select(selectors.selectMode)),
switchMap(([action, mode]) => {
const strategy = this.strategyFactory.getStrategy(mode);
const actionsToDispatch: any[] = [];
// Resume task tracking if we paused it during break
if (pausedTaskId) {
actionsToDispatch.push(setCurrentTask({ id: pausedTaskId }));
if (action.pausedTaskId) {
actionsToDispatch.push(setCurrentTask({ id: action.pausedTaskId }));
}
// Auto-start next session if configured
@ -527,6 +535,7 @@ export class FocusModeEffects {
);
// Update banner when session or break state changes
// Only shows banner when focus mode feature is enabled
updateBanner$ = createEffect(
() =>
combineLatest([
@ -539,6 +548,7 @@ export class FocusModeEffects {
this.store.select(selectors.selectIsOverlayShown),
this.store.select(selectors.selectTimer),
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
skipDuringSync(),
tap(
@ -552,9 +562,10 @@ export class FocusModeEffects {
isOverlayShown,
timer,
focusModeConfig,
isFocusModeEnabled,
]) => {
// Only show banner when overlay is hidden
if (isOverlayShown) {
// Only show banner when overlay is hidden and focus mode feature is enabled
if (isOverlayShown || !isFocusModeEnabled) {
this.bannerService.dismiss(BannerId.FocusMode);
return;
}
@ -694,18 +705,39 @@ export class FocusModeEffects {
label: T.F.FOCUS_MODE.B.START,
icon: 'play_arrow',
fn: () => {
// Start a new session using the current mode's strategy
this.store
.select(selectors.selectMode)
.pipe(take(1))
.subscribe((mode) => {
const strategy = this.strategyFactory.getStrategy(mode);
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
});
// When starting from break completion, first properly complete/skip the break
// to resume task tracking and clean up state
if (isBreakTimeUp) {
combineLatest([
this.store.select(selectors.selectMode),
this.store.select(selectors.selectPausedTaskId),
])
.pipe(take(1))
.subscribe(([mode, pausedTaskId]) => {
// Skip break (with pausedTaskId to resume tracking)
this.store.dispatch(actions.skipBreak({ pausedTaskId }));
// Then start new session
const strategy = this.strategyFactory.getStrategy(mode);
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
});
} else {
// Start a new session using the current mode's strategy
this.store
.select(selectors.selectMode)
.pipe(take(1))
.subscribe((mode) => {
const strategy = this.strategyFactory.getStrategy(mode);
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
});
}
},
}
: {

View file

@ -354,7 +354,7 @@ describe('FocusModeReducer', () => {
currentScreen: FocusScreen.Break,
};
const action = a.skipBreak();
const action = a.skipBreak({ pausedTaskId: null });
const result = focusModeReducer(breakState, action);
expect(result.currentScreen).toBe(FocusScreen.Main);
@ -376,7 +376,7 @@ describe('FocusModeReducer', () => {
currentScreen: FocusScreen.Break,
};
const action = a.completeBreak();
const action = a.completeBreak({ pausedTaskId: null });
const result = focusModeReducer(breakState, action);
expect(result.currentScreen).toBe(FocusScreen.Main);

View file

@ -157,7 +157,7 @@ export class IdleEffects {
// ALL IDLE SIDE EFFECTS
// ---------------------
if (IS_ELECTRON) {
this._uiHelperService.focusApp();
this._uiHelperService.focusAppAfterNotification();
}
// untrack current task time und unselect

View file

@ -86,6 +86,11 @@ export class ReminderModule {
this._showNotification(reminders);
// Skip dialog on Android - native notifications handle reminders
if (IS_ANDROID_WEB_VIEW) {
return;
}
const oldest = reminders[0];
const taskId = oldest.id;

View file

@ -285,7 +285,7 @@ export class TakeABreakService {
this._triggerFullscreenBlocker$.next(true);
}
if (IS_ELECTRON && cfg.takeABreak.isFocusWindow) {
this._uiHelperService.focusApp();
this._uiHelperService.focusAppAfterNotification();
}
this._bannerService.open({

View file

@ -229,13 +229,16 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
it('should update task dueDay when first occurrence differs from current (#5594)', () => {
// Scenario: Task is created today, but repeat config only matches future days
// Calculate next Monday from today
// Use a day that is 3 days from today (guaranteed to not be today)
const today = new Date();
const todayStr = getDbDateStr(today);
const daysUntilMonday = (8 - today.getDay()) % 7 || 7; // Next Monday (not today even if today is Monday)
const nextMonday = new Date(today);
nextMonday.setDate(today.getDate() + daysUntilMonday);
const mondayStr = getDbDateStr(nextMonday);
const todayDayOfWeek = today.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
// Pick a weekday that is 3 days from now (guaranteed to not be today)
const targetDayOfWeek = (todayDayOfWeek + 3) % 7;
const targetDate = new Date(today);
targetDate.setDate(today.getDate() + 3);
const targetDateStr = getDbDateStr(targetDate);
const taskCreatedToday: TaskWithSubTasks = {
...mockTask,
@ -244,18 +247,19 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
created: today.getTime(),
};
// Create weekday booleans with only the target day set to true
const weeklyRepeatCfg: TaskRepeatCfgCopy = {
...mockRepeatCfg,
repeatCycle: 'WEEKLY',
repeatEvery: 1,
startDate: todayStr,
monday: true,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
sunday: targetDayOfWeek === 0,
monday: targetDayOfWeek === 1,
tuesday: targetDayOfWeek === 2,
wednesday: targetDayOfWeek === 3,
thursday: targetDayOfWeek === 4,
friday: targetDayOfWeek === 5,
saturday: targetDayOfWeek === 6,
};
const action = addTaskRepeatCfgToTask({
@ -270,9 +274,9 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
// Verify that update was called with next Monday
// Verify that update was called with the target day (3 days from today)
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
dueDay: mondayStr,
dueDay: targetDateStr,
});
});
@ -405,13 +409,16 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
it('should use task created date as fallback when dueDay is missing', () => {
// Scenario: Task has no dueDay, should use created date for comparison
// Calculate next Monday from today
// Use a day that is 3 days from today (guaranteed to not be today)
const today = new Date();
const daysUntilMonday = (8 - today.getDay()) % 7 || 7; // Next Monday (not today even if today is Monday)
const nextMonday = new Date(today);
nextMonday.setDate(today.getDate() + daysUntilMonday);
const mondayStr = getDbDateStr(nextMonday);
const todayStr = getDbDateStr(today);
const todayDayOfWeek = today.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
// Pick a weekday that is 3 days from now (guaranteed to not be today)
const targetDayOfWeek = (todayDayOfWeek + 3) % 7;
const targetDate = new Date(today);
targetDate.setDate(today.getDate() + 3);
const targetDateStr = getDbDateStr(targetDate);
const taskWithoutDueDay: TaskWithSubTasks = {
...mockTask,
@ -420,18 +427,19 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
created: today.getTime(),
};
// Create weekday booleans with only the target day set to true
const weeklyRepeatCfg: TaskRepeatCfgCopy = {
...mockRepeatCfg,
repeatCycle: 'WEEKLY',
repeatEvery: 1,
startDate: todayStr,
monday: true,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
sunday: targetDayOfWeek === 0,
monday: targetDayOfWeek === 1,
tuesday: targetDayOfWeek === 2,
wednesday: targetDayOfWeek === 3,
thursday: targetDayOfWeek === 4,
friday: targetDayOfWeek === 5,
saturday: targetDayOfWeek === 6,
};
const action = addTaskRepeatCfgToTask({
@ -446,11 +454,80 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
// Verify that update was called with next Monday
// Verify that update was called with the target day (3 days from today)
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
dueDay: mondayStr,
dueDay: targetDateStr,
});
});
it('should update dueDay for Mon/Wed/Fri pattern when today is not a match (#5594 exact scenario)', () => {
// This test replicates the exact bug scenario from issue #5594:
// User creates Mon/Wed/Fri repeat, but dueDay incorrectly stays as today
const today = new Date();
const todayStr = getDbDateStr(today);
const todayDayOfWeek = today.getDay();
// Mon=1, Wed=3, Fri=5
const isMonWedFri =
todayDayOfWeek === 1 || todayDayOfWeek === 3 || todayDayOfWeek === 5;
// Calculate expected first occurrence
let expectedDate: Date;
if (isMonWedFri) {
expectedDate = new Date(today);
} else {
expectedDate = new Date(today);
const daysToAdd = [1, 3, 5]
.map((d) => (d - todayDayOfWeek + 7) % 7)
.filter((d) => d > 0)
.sort((a, b) => a - b)[0];
expectedDate.setDate(expectedDate.getDate() + daysToAdd);
}
const expectedDateStr = getDbDateStr(expectedDate);
const taskCreatedToday: TaskWithSubTasks = {
...mockTask,
subTasks: [],
dueDay: todayStr, // Task starts with today's date
created: today.getTime(),
};
const monWedFriRepeatCfg: TaskRepeatCfgCopy = {
...mockRepeatCfg,
repeatCycle: 'WEEKLY',
repeatEvery: 1,
startDate: todayStr,
monday: true,
tuesday: false,
wednesday: true,
thursday: false,
friday: true,
saturday: false,
sunday: false,
};
const action = addTaskRepeatCfgToTask({
taskRepeatCfg: monWedFriRepeatCfg,
taskId: 'parent-task-id',
});
actions$ = of(action);
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskCreatedToday));
spyOn(effects as any, '_updateRegularTaskInstance');
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
// If today is Mon/Wed/Fri, no update needed (dueDay already correct)
// If today is Tue/Thu/Sat/Sun, dueDay should be updated to next Mon/Wed/Fri
if (isMonWedFri) {
expect(taskService.update).not.toHaveBeenCalled();
} else {
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
dueDay: expectedDateStr,
});
}
});
});
describe('updateStartDateOnComplete$', () => {

View file

@ -0,0 +1,242 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of } from 'rxjs';
import { TaskUiEffects } from './task-ui.effects';
import { provideMockStore } from '@ngrx/store/testing';
import { TaskService } from '../task.service';
import { SnackService } from '../../../core/snack/snack.service';
import { SnackParams } from '../../../core/snack/snack.model';
import { WorkContextService } from '../../work-context/work-context.service';
import { NavigateToTaskService } from '../../../core-ui/navigate-to-task/navigate-to-task.service';
import { NotifyService } from '../../../core/notify/notify.service';
import { BannerService } from '../../../core/banner/banner.service';
import { GlobalConfigService } from '../../config/global-config.service';
import { Router } from '@angular/router';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { T } from '../../../t.const';
import { Task } from '../task.model';
import { WorkContextType } from '../../work-context/work-context.model';
import { selectProjectById } from '../../project/store/project.selectors';
describe('TaskUiEffects', () => {
let effects: TaskUiEffects;
let actions$: Observable<any>;
let snackServiceMock: jasmine.SpyObj<SnackService>;
let taskServiceMock: jasmine.SpyObj<TaskService>;
let navigateToTaskServiceMock: jasmine.SpyObj<NavigateToTaskService>;
const createMockTask = (overrides: Partial<Task> = {}): Task =>
({
id: 'task-123',
title: 'Test Task',
projectId: null,
tagIds: [],
subTaskIds: [],
parentId: null,
timeSpentOnDay: {},
timeSpent: 0,
timeEstimate: 0,
isDone: false,
notes: '',
doneOn: null,
plannedAt: null,
reminderId: null,
repeatCfgId: null,
issueId: null,
issueType: null,
issueProviderId: null,
issueWasUpdated: false,
issueLastUpdated: null,
issueTimeTracked: null,
attachments: [],
created: Date.now(),
...overrides,
}) as Task;
const createAddTaskAction = (
task: Task,
): ReturnType<typeof TaskSharedActions.addTask> =>
TaskSharedActions.addTask({
task,
workContextId: 'ctx-1',
workContextType: WorkContextType.PROJECT,
isAddToBacklog: false,
isAddToBottom: false,
});
describe('taskCreatedSnack$ with visible task', () => {
beforeEach(() => {
snackServiceMock = jasmine.createSpyObj('SnackService', ['open']);
taskServiceMock = jasmine.createSpyObj('TaskService', ['setSelectedId']);
navigateToTaskServiceMock = jasmine.createSpyObj('NavigateToTaskService', [
'navigate',
]);
const workContextServiceMock = {
mainListTaskIds$: of(['existing-task-1', 'existing-task-2']),
};
TestBed.configureTestingModule({
providers: [
TaskUiEffects,
provideMockActions(() => actions$),
provideMockStore({
initialState: {},
selectors: [{ selector: selectProjectById, value: null }],
}),
{ provide: SnackService, useValue: snackServiceMock },
{ provide: TaskService, useValue: taskServiceMock },
{ provide: NavigateToTaskService, useValue: navigateToTaskServiceMock },
{ provide: WorkContextService, useValue: workContextServiceMock },
{
provide: NotifyService,
useValue: jasmine.createSpyObj('NotifyService', ['notify']),
},
{
provide: BannerService,
useValue: jasmine.createSpyObj('BannerService', ['open', 'dismiss']),
},
{
provide: GlobalConfigService,
useValue: { sound$: of({ doneSound: null }) },
},
{ provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) },
],
});
effects = TestBed.inject(TaskUiEffects);
});
it('should show snack with action button when task is visible on current page', (done) => {
const task = createMockTask({ id: 'existing-task-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
expect(snackServiceMock.open).toHaveBeenCalled();
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
expect(snackParams.actionStr).toBe(T.F.TASK.S.GO_TO_TASK);
expect(snackParams.actionFn).toBeDefined();
done();
});
});
it('should call taskService.setSelectedId when action clicked for visible task', (done) => {
const task = createMockTask({ id: 'existing-task-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
snackParams.actionFn!();
expect(taskServiceMock.setSelectedId).toHaveBeenCalledWith('existing-task-1');
expect(navigateToTaskServiceMock.navigate).not.toHaveBeenCalled();
done();
});
});
it('should show TASK_CREATED message for task visible on current page', (done) => {
const task = createMockTask({ id: 'existing-task-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
expect(snackParams.msg).toBe(T.F.TASK.S.TASK_CREATED);
done();
});
});
});
describe('taskCreatedSnack$ with non-visible task', () => {
beforeEach(() => {
snackServiceMock = jasmine.createSpyObj('SnackService', ['open']);
taskServiceMock = jasmine.createSpyObj('TaskService', ['setSelectedId']);
navigateToTaskServiceMock = jasmine.createSpyObj('NavigateToTaskService', [
'navigate',
]);
const workContextServiceMock = {
mainListTaskIds$: of(['existing-task-1', 'existing-task-2']),
};
TestBed.configureTestingModule({
providers: [
TaskUiEffects,
provideMockActions(() => actions$),
provideMockStore({
initialState: {},
selectors: [
{
selector: selectProjectById,
value: { id: 'project-1', title: 'Test Project' },
},
],
}),
{ provide: SnackService, useValue: snackServiceMock },
{ provide: TaskService, useValue: taskServiceMock },
{ provide: NavigateToTaskService, useValue: navigateToTaskServiceMock },
{ provide: WorkContextService, useValue: workContextServiceMock },
{
provide: NotifyService,
useValue: jasmine.createSpyObj('NotifyService', ['notify']),
},
{
provide: BannerService,
useValue: jasmine.createSpyObj('BannerService', ['open', 'dismiss']),
},
{
provide: GlobalConfigService,
useValue: { sound$: of({ doneSound: null }) },
},
{ provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) },
],
});
effects = TestBed.inject(TaskUiEffects);
});
it('should show snack with action button when task is NOT visible on current page', (done) => {
const task = createMockTask({ id: 'new-task-456', projectId: 'project-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
expect(snackServiceMock.open).toHaveBeenCalled();
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
expect(snackParams.actionStr).toBe(T.F.TASK.S.GO_TO_TASK);
expect(snackParams.actionFn).toBeDefined();
done();
});
});
it('should call navigateToTaskService.navigate when action clicked for non-visible task', (done) => {
const task = createMockTask({ id: 'new-task-456', projectId: 'project-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
snackParams.actionFn!();
expect(navigateToTaskServiceMock.navigate).toHaveBeenCalledWith(
'new-task-456',
false,
);
expect(taskServiceMock.setSelectedId).not.toHaveBeenCalled();
done();
});
});
it('should show CREATED_FOR_PROJECT message for task in different project', (done) => {
const task = createMockTask({ id: 'new-task-456', projectId: 'project-1' });
actions$ = of(createAddTaskAction(task));
effects.taskCreatedSnack$.subscribe(() => {
const snackParams = snackServiceMock.open.calls.mostRecent()
.args[0] as SnackParams;
expect(snackParams.msg).toBe(T.F.TASK.S.CREATED_FOR_PROJECT);
done();
});
});
});
});

View file

@ -79,14 +79,14 @@ export class TaskUiEffects {
? T.F.TASK.S.CREATED_FOR_PROJECT
: T.F.TASK.S.TASK_CREATED,
ico: 'add',
...(task.projectId && !isTaskVisibleOnCurrentPage
? {
actionFn: () => {
this._navigateToTaskService.navigate(task.id, false);
},
actionStr: T.F.TASK.S.GO_TO_TASK,
}
: {}),
actionStr: T.F.TASK.S.GO_TO_TASK,
actionFn: () => {
if (isTaskVisibleOnCurrentPage) {
this._taskService.setSelectedId(task.id);
} else {
this._navigateToTaskService.navigate(task.id, false);
}
},
});
});
}),

View file

@ -216,6 +216,6 @@ export class TrackingReminderService {
}
private _focusWindow(): void {
this._uiHelperService.focusApp();
this._uiHelperService.focusAppAfterNotification();
}
}

View file

@ -54,6 +54,29 @@ export class UiHelperService {
}
}
/**
* Focus app after a delay to prevent accidental input.
* Used for "surprise" focus scenarios (tracking reminder, idle, take-a-break)
* where user might still be typing in another app.
*/
focusAppAfterNotification(): void {
if (!IS_ELECTRON) {
return;
}
const FOCUS_DELAY_MS = 1500;
setTimeout(() => {
window.ea.showOrFocus();
// Blur after focus to prevent any task input from receiving keystrokes
setTimeout(() => {
if (document.activeElement && document.activeElement !== document.body) {
(document.activeElement as HTMLElement).blur();
}
}, 100);
}, FOCUS_DELAY_MS);
}
private _zoomFactorMinMax(zoomFactor: number): number {
zoomFactor = Math.min(Math.max(zoomFactor, 0.1), 4);
zoomFactor = Math.round(zoomFactor * 1000) / 1000;

View file

@ -49,6 +49,7 @@ import { UserInputWaitStateService } from './user-input-wait-state.service';
import { LegacySyncProvider } from './legacy-sync-provider.model';
import { SYNC_WAIT_TIMEOUT_MS, SYNC_REINIT_DELAY_MS } from './sync.const';
import { SuperSyncStatusService } from '../../core/persistence/operation-log/sync/super-sync-status.service';
import { IS_ELECTRON } from '../../app.constants';
/**
* Converts LegacySyncProvider to SyncProviderId.
@ -302,7 +303,7 @@ export class SyncWrapperService {
return 'HANDLED_ERROR';
} else if (this._isPermissionError(error)) {
this._snackService.open({
msg: T.F.SYNC.S.ERROR_PERMISSION,
msg: this._getPermissionErrorMessage(),
type: 'ERROR',
config: { duration: 12000 },
});
@ -515,6 +516,16 @@ export class SyncWrapperService {
return /EROFS|EACCES|EPERM|read-only file system|permission denied/i.test(errStr);
}
private _getPermissionErrorMessage(): string {
if (IS_ELECTRON && window.ea?.isFlatpak?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_FLATPAK;
}
if (IS_ELECTRON && window.ea?.isSnap?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_SNAP;
}
return T.F.SYNC.S.ERROR_PERMISSION;
}
private lastConflictDialog?: MatDialogRef<any, any>;
private _openConflictDialog$(

View file

@ -34,6 +34,7 @@ import { SYNC_FORM } from '../../features/config/form-cfgs/sync-form.const';
import { PfapiService } from '../../pfapi/pfapi.service';
import { map, tap } from 'rxjs/operators';
import { SyncConfigService } from '../../imex/sync/sync-config.service';
import { WebdavApi } from '../../pfapi/api/sync/providers/webdav/webdav-api';
import { AsyncPipe } from '@angular/common';
import { PluginManagementComponent } from '../../plugins/ui/plugin-management/plugin-management.component';
import { CollapsibleComponent } from '../../ui/collapsible/collapsible.component';
@ -84,57 +85,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
globalConfigFormCfg: ConfigFormConfig;
globalImexFormCfg: ConfigFormConfig;
globalProductivityConfigFormCfg: ConfigFormConfig;
globalSyncConfigFormCfg = {
...SYNC_FORM,
items: [
...SYNC_FORM.items!,
{
hideExpression: (m, v, field) => !m.isEnabled || !field?.form?.valid,
key: '___',
type: 'btn',
className: 'mt3 block',
templateOptions: {
text: T.F.SYNC.BTN_SYNC_NOW,
required: false,
onClick: () => {
this._syncWrapperService.sync();
},
},
},
{
hideExpression: (m: any) =>
!m.isEnabled || m.syncProvider !== LegacySyncProvider.SuperSync,
key: '____',
type: 'btn',
className: 'mt2 block',
templateOptions: {
text: T.F.SYNC.BTN_RESTORE_FROM_HISTORY,
btnType: 'stroked',
required: false,
onClick: () => {
this._openRestoreDialog();
},
},
},
{
hideExpression: (m: any) =>
!m.isEnabled ||
m.syncProvider !== LegacySyncProvider.SuperSync ||
!m.superSync?.isEncryptionEnabled,
key: '_____',
type: 'btn',
className: 'mt2 block',
templateOptions: {
text: T.F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD,
btnType: 'stroked',
required: false,
onClick: () => {
this._openChangePasswordDialog();
},
},
},
],
};
globalSyncConfigFormCfg = this._buildSyncFormConfig();
globalCfg?: GlobalConfigState;
@ -259,6 +210,116 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
this._subs.unsubscribe();
}
private _buildSyncFormConfig(): typeof SYNC_FORM {
// Deep clone the SYNC_FORM items to avoid mutating the original
const items = SYNC_FORM.items!.map((item) => {
// Find the WebDAV fieldGroup and add the Test Connection button
if (item.key === 'webDav' && item.fieldGroup) {
return {
...item,
fieldGroup: [
...item.fieldGroup,
{
type: 'btn',
className: 'mt3 block',
templateOptions: {
text: T.F.SYNC.FORM.WEB_DAV.L_TEST_CONNECTION,
required: false,
onClick: async (_field: any, _form: any, model: any) => {
const webDavCfg = model;
if (
!webDavCfg?.baseUrl ||
!webDavCfg?.userName ||
!webDavCfg?.password ||
!webDavCfg?.syncFolderPath
) {
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.FORM.WEB_DAV.S_FILL_ALL_FIELDS,
});
return;
}
// Create a temporary WebdavApi instance for testing
const api = new WebdavApi(async () => webDavCfg);
const result = await api.testConnection(webDavCfg);
if (result.success) {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.SYNC.FORM.WEB_DAV.S_TEST_SUCCESS,
translateParams: { url: result.fullUrl },
});
} else {
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.FORM.WEB_DAV.S_TEST_FAIL,
translateParams: {
error: result.error || 'Unknown error',
url: result.fullUrl,
},
});
}
},
},
},
],
};
}
return item;
});
return {
...SYNC_FORM,
items: [
...items,
{
hideExpression: (m: any, _v: any, field: any) =>
!m.isEnabled || !field?.form?.valid,
type: 'btn',
className: 'mt3 block',
templateOptions: {
text: T.F.SYNC.BTN_SYNC_NOW,
required: false,
onClick: () => {
this._syncWrapperService.sync();
},
},
},
{
hideExpression: (m: any) =>
!m.isEnabled || m.syncProvider !== LegacySyncProvider.SuperSync,
type: 'btn',
className: 'mt2 block',
templateOptions: {
text: T.F.SYNC.BTN_RESTORE_FROM_HISTORY,
btnType: 'stroked',
required: false,
onClick: () => {
this._openRestoreDialog();
},
},
},
{
hideExpression: (m: any) =>
!m.isEnabled ||
m.syncProvider !== LegacySyncProvider.SuperSync ||
!m.superSync?.isEncryptionEnabled,
type: 'btn',
className: 'mt2 block',
templateOptions: {
text: T.F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD,
btnType: 'stroked',
required: false,
onClick: () => {
this._openChangePasswordDialog();
},
},
},
],
} as typeof SYNC_FORM;
}
async saveGlobalCfg($event: {
sectionKey: GlobalConfigSectionKey | ProjectCfgFormKey;
config: any;

View file

@ -280,19 +280,37 @@ export class WebdavApi {
uploadError.response.status === WebDavHttpStatus.CONFLICT)
) {
PFLog.debug(
`${WebdavApi.L}.upload() got 409, attempting to create parent directory`,
`${WebdavApi.L}.upload() got 409 Conflict for ${fullPath}. ` +
`This often indicates the sync folder path is misconfigured. ` +
`Attempting to create parent directory...`,
);
// Try to create parent directory
await this._ensureParentDirectory(fullPath);
// Retry the upload
response = await this._makeRequest({
url: fullPath,
method: WebDavHttpMethod.PUT,
body: data,
headers,
});
try {
response = await this._makeRequest({
url: fullPath,
method: WebDavHttpMethod.PUT,
body: data,
headers,
});
} catch (retryError) {
// If retry also fails with 409, log a helpful error message
if (
retryError instanceof HttpNotOkAPIError &&
retryError.response &&
retryError.response.status === WebDavHttpStatus.CONFLICT
) {
PFLog.err(
`${WebdavApi.L}.upload() 409 Conflict persists for ${fullPath} after creating parent directory. ` +
`Verify your syncFolderPath is relative to the WebDAV server root, ` +
`not your server's internal directory path.`,
);
}
throw retryError;
}
} else {
throw uploadError;
}
@ -384,6 +402,49 @@ export class WebdavApi {
}
}
async testConnection(
cfg: WebdavPrivateCfg,
): Promise<{ success: boolean; error?: string; fullUrl: string }> {
const fullPath = this._buildFullPath(cfg.baseUrl, cfg.syncFolderPath || '/');
PFLog.verbose(`${WebdavApi.L}.testConnection() testing ${fullPath}`);
try {
// Build authorization header
const auth = btoa(`${cfg.userName}:${cfg.password}`);
const headers = {
[WebDavHttpHeader.AUTHORIZATION]: `Basic ${auth}`,
[WebDavHttpHeader.CONTENT_TYPE]: 'application/xml; charset=utf-8',
[WebDavHttpHeader.DEPTH]: '0',
};
// Try PROPFIND on the sync folder path
const response = await this.httpAdapter.request({
url: fullPath,
method: WebDavHttpMethod.PROPFIND,
headers,
body: WebdavXmlParser.PROPFIND_XML,
});
if (
response.status === WebDavHttpStatus.MULTI_STATUS ||
response.status === WebDavHttpStatus.OK
) {
PFLog.verbose(`${WebdavApi.L}.testConnection() success for ${fullPath}`);
return { success: true, fullUrl: fullPath };
}
return {
success: false,
error: `Unexpected status ${response.status}`,
fullUrl: fullPath,
};
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred';
PFLog.warn(`${WebdavApi.L}.testConnection() failed for ${fullPath}`, e);
return { success: false, error: errorMessage, fullUrl: fullPath };
}
}
private async _makeRequest({
url,
method,

View file

@ -1201,11 +1201,16 @@ const T = {
TITLE: 'F.SYNC.FORM.TITLE',
WEB_DAV: {
CORS_INFO: 'F.SYNC.FORM.WEB_DAV.CORS_INFO',
D_SYNC_FOLDER_PATH: 'F.SYNC.FORM.WEB_DAV.D_SYNC_FOLDER_PATH',
INFO: 'F.SYNC.FORM.WEB_DAV.INFO',
L_BASE_URL: 'F.SYNC.FORM.WEB_DAV.L_BASE_URL',
L_PASSWORD: 'F.SYNC.FORM.WEB_DAV.L_PASSWORD',
L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.WEB_DAV.L_SYNC_FOLDER_PATH',
L_TEST_CONNECTION: 'F.SYNC.FORM.WEB_DAV.L_TEST_CONNECTION',
L_USER_NAME: 'F.SYNC.FORM.WEB_DAV.L_USER_NAME',
S_FILL_ALL_FIELDS: 'F.SYNC.FORM.WEB_DAV.S_FILL_ALL_FIELDS',
S_TEST_FAIL: 'F.SYNC.FORM.WEB_DAV.S_TEST_FAIL',
S_TEST_SUCCESS: 'F.SYNC.FORM.WEB_DAV.S_TEST_SUCCESS',
},
},
S: {
@ -1222,6 +1227,8 @@ const T = {
ERROR_CORS: 'F.SYNC.S.ERROR_CORS',
ERROR_DATA_IS_CURRENTLY_WRITTEN: 'F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN',
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
ERROR_PERMISSION_FLATPAK: 'F.SYNC.S.ERROR_PERMISSION_FLATPAK',
ERROR_PERMISSION_SNAP: 'F.SYNC.S.ERROR_PERMISSION_SNAP',
ERROR_FALLBACK_TO_BACKUP: 'F.SYNC.S.ERROR_FALLBACK_TO_BACKUP',
ERROR_INVALID_DATA: 'F.SYNC.S.ERROR_INVALID_DATA',
ERROR_NO_REV: 'F.SYNC.S.ERROR_NO_REV',

View file

@ -1182,11 +1182,16 @@
"TITLE": "Sync",
"WEB_DAV": {
"CORS_INFO": "<strong>Making it work in the browser:</strong> To make this work in the browser you need to whitelist Super Productivity for CORS requests for your webdav server. This can have negative security implications! For nextcloud please <a href='https://github.com/nextcloud/server/issues/3131'>refer to this thread for more information</a>. One approach to make this work on mobile is whitelisting \"https://app.super-productivity.com\" via the nextcloud app <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Use at your own risk!</p>",
"D_SYNC_FOLDER_PATH": "Path relative to the WebDAV server root where sync files will be stored (e.g., '/super-productivity' or '/'). This is NOT your server's internal directory path.",
"INFO": "WebDAV implementations differ wildly unfortunately. Super Productivity is known to work well with Nextcloud, <strong>but it might not work with your provider</strong>.",
"L_BASE_URL": "Base Url",
"L_PASSWORD": "Password",
"L_SYNC_FOLDER_PATH": "Sync Folder Path",
"L_USER_NAME": "Username"
"L_TEST_CONNECTION": "Test Connection",
"L_USER_NAME": "Username",
"S_FILL_ALL_FIELDS": "Please fill in all WebDAV fields first",
"S_TEST_FAIL": "Connection test failed: {{error}} - Target URL: {{url}}",
"S_TEST_SUCCESS": "Connection test successful! Target URL: {{url}}"
}
},
"S": {
@ -1202,7 +1207,9 @@
"DATA_REPAIRED": "Data automatically repaired ({{count}} issues fixed)",
"ERROR_CORS": "WebDAV Sync Error: Network request failed.\n\nThis might be a CORS issue. Please ensure:\n• Your WebDAV server allows Cross-Origin requests\n• The server URL is correct and accessible\n• You have a working internet connection",
"ERROR_DATA_IS_CURRENTLY_WRITTEN": "Remote Data is currently being written",
"ERROR_PERMISSION": "File access denied. If using Flatpak/Snap, grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
"ERROR_FALLBACK_TO_BACKUP": "Something went wrong while importing the data. Falling back to local backup.",
"ERROR_INVALID_DATA": "Error while syncing. Invalid data",
"ERROR_NO_REV": "No valid rev for remote file",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
// this file is automatically generated by git.version.ts script
export const versions = {
version: '16.7.2',
version: '16.7.3',
revision: 'NO_REV',
branch: 'NO_BRANCH',
};