mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
commit
501b8b5a32
39 changed files with 2476 additions and 946 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
* es.json
|
||||
2
electron/electronAPI.d.ts
vendored
2
electron/electronAPI.d.ts
vendored
|
|
@ -85,6 +85,8 @@ export interface ElectronAPI {
|
|||
|
||||
isSnap(): boolean;
|
||||
|
||||
isFlatpak(): boolean;
|
||||
|
||||
// SEND
|
||||
// ----
|
||||
reloadMainWin(): void;
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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/*"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "superProductivity",
|
||||
"version": "16.7.2",
|
||||
"version": "16.7.3",
|
||||
"description": "ToDo list and Time Tracking",
|
||||
"keywords": [
|
||||
"ToDo",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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$', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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$', () => {
|
||||
|
|
|
|||
242
src/app/features/tasks/store/task-ui.effects.spec.ts
Normal file
242
src/app/features/tasks/store/task-ui.effects.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -216,6 +216,6 @@ export class TrackingReminderService {
|
|||
}
|
||||
|
||||
private _focusWindow(): void {
|
||||
this._uiHelperService.focusApp();
|
||||
this._uiHelperService.focusAppAfterNotification();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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$(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue