Merge branch 'master' into feat/operation-logs

* master: (21 commits)
  test: increase timeout for encryption
  16.8.3
  fix(e2e): use pressSequentially for time input in task-detail tests
  fix(sync): resolve 25-second initial sync timeout race condition
  feat(e2e): add Docker-based E2E test isolation
  fix(schedule): start tracking selected task when pressing Y in schedule view
  fix(tasks): clear reminder when clicking "today" button on already-today tasks
  fix(e2e): use format-agnostic time change in task-detail tests
  16.8.2
  fix(test): reset selector overrides to prevent test pollution
  build: update CLAUDE.md
  fix(calendar): add periodic refresh for planner and scheduler views
  feat(effects): consolidate task update actions in PluginHooksEffects
  fix(sync): show skip button immediately when offline
  fix(db): add missing _afterReady guard to loadAll method
  feat(android): add alarm sound and vibration to task reminders
  feat(sync): add skip button to loading screen when waiting for sync
  fix(pomodoro): allow manual session end to start break early
  fix(i18n): add missing translate pipe to play button tooltip
  fix(tasks): handle undefined tasks in reminder effect
  ...

# Conflicts:
#	CLAUDE.md
#	docker-compose.e2e.yaml
#	e2e/tests/task-detail/task-detail.spec.ts
#	package.json
#	src/app/features/tasks/store/task-reminder.effects.spec.ts
#	src/app/features/tasks/store/task-reminder.effects.ts
#	src/app/plugins/plugin-hooks.effects.ts
This commit is contained in:
Johannes Millan 2026-01-04 18:20:10 +01:00
commit 3f3f0685eb
43 changed files with 3341 additions and 207 deletions

View file

@ -1,3 +1,42 @@
## [16.8.3](https://github.com/johannesjo/super-productivity/compare/v16.8.2...v16.8.3) (2026-01-04)
### Bug Fixes
- **e2e:** use format-agnostic time change in task-detail tests ([098e19f](https://github.com/johannesjo/super-productivity/commit/098e19f9ca7d2ad218d311b9dddfe9709d775b99))
- **e2e:** use pressSequentially for time input in task-detail tests ([333c3a1](https://github.com/johannesjo/super-productivity/commit/333c3a16bc442d71dfe7c6b6f6953123a6bba928))
- **schedule:** start tracking selected task when pressing Y in schedule view ([acedc67](https://github.com/johannesjo/super-productivity/commit/acedc67f2af1d5a86df910a12bf5d3efaec7a1e6)), closes [#5884](https://github.com/johannesjo/super-productivity/issues/5884)
- **sync:** resolve 25-second initial sync timeout race condition ([570a0b5](https://github.com/johannesjo/super-productivity/commit/570a0b590d94a1dab54991808a13e70d99db56d4)), closes [#5868](https://github.com/johannesjo/super-productivity/issues/5868) [#5877](https://github.com/johannesjo/super-productivity/issues/5877)
- **tasks:** clear reminder when clicking "today" button on already-today tasks ([2af57d2](https://github.com/johannesjo/super-productivity/commit/2af57d2b4aba90ee0ccfc2780e4c3cce26c74b43)), closes [#5872](https://github.com/johannesjo/super-productivity/issues/5872)
### Features
- **e2e:** add Docker-based E2E test isolation ([40d7118](https://github.com/johannesjo/super-productivity/commit/40d7118e179ae46670d0180460a1255d4f63f903))
## [16.8.2](https://github.com/johannesjo/super-productivity/compare/v16.8.1...v16.8.2) (2026-01-04)
### Bug Fixes
- **calendar:** add periodic refresh for planner and scheduler views ([77c4c33](https://github.com/johannesjo/super-productivity/commit/77c4c33988577f2e475ea2dd28f257455f684fa3)), closes [#4474](https://github.com/johannesjo/super-productivity/issues/4474)
- **db:** add missing \_afterReady guard to loadAll method ([270eca3](https://github.com/johannesjo/super-productivity/commit/270eca3600270bc1d4a741ece9536dc4c1d33b1f)), closes [#5734](https://github.com/johannesjo/super-productivity/issues/5734)
- **focus-mode:** sync time tracking with Pomodoro breaks and manual end ([55fc855](https://github.com/johannesjo/super-productivity/commit/55fc8551cdf005e754503bd2f8c9ddddcd1fda10)), closes [#5875](https://github.com/johannesjo/super-productivity/issues/5875)
- **i18n:** add missing translate pipe to play button tooltip ([abfff27](https://github.com/johannesjo/super-productivity/commit/abfff278a4329425abd6aedd6b43bc4730e28c03)), closes [#5874](https://github.com/johannesjo/super-productivity/issues/5874)
- **pomodoro:** allow manual session end to start break early ([291d3e8](https://github.com/johannesjo/super-productivity/commit/291d3e8caf66fecde24a664ead4fc91547301d5c)), closes [#5876](https://github.com/johannesjo/super-productivity/issues/5876)
- **sync:** show skip button immediately when offline ([ccd4846](https://github.com/johannesjo/super-productivity/commit/ccd4846b882c24cd399f8c8586904be8dcddb3bd)), closes [#5877](https://github.com/johannesjo/super-productivity/issues/5877)
- **tasks:** handle undefined tasks in reminder effect ([4497aed](https://github.com/johannesjo/super-productivity/commit/4497aed3172bb4423c2680f092a34f78a8818fc2)), closes [#5873](https://github.com/johannesjo/super-productivity/issues/5873)
- **test:** reset selector overrides to prevent test pollution ([d4b40e8](https://github.com/johannesjo/super-productivity/commit/d4b40e80d5ea7ecbb4bbe10fc285e195f9a1d043))
### Features
- **android:** add alarm sound and vibration to task reminders ([b7cbef2](https://github.com/johannesjo/super-productivity/commit/b7cbef2f79f014f997d746c358c5253844d8d853)), closes [#5603](https://github.com/johannesjo/super-productivity/issues/5603)
- **e2e:** streamline e2e test development with improved infrastructure ([402fb69](https://github.com/johannesjo/super-productivity/commit/402fb69a858459c9c1a46f1ea497063d0dc4c04a))
- **effects:** consolidate task update actions in PluginHooksEffects ([386c636](https://github.com/johannesjo/super-productivity/commit/386c636e5fe163bd68f68f52e347a967761fb1d3))
- **sync:** add skip button to loading screen when waiting for sync ([12e68cd](https://github.com/johannesjo/super-productivity/commit/12e68cdb0e321a83cd8425d8ae927a5c51999460)), closes [#5868](https://github.com/johannesjo/super-productivity/issues/5868)
### Performance Improvements
- **e2e:** optimize wait utilities and addTask method for faster test execution ([c0fc56f](https://github.com/johannesjo/super-productivity/commit/c0fc56f729aef697d3f7f37febc5aa9af05d3998))
- **e2e:** remove ineffective waits to speed up test runs ([24c008d](https://github.com/johannesjo/super-productivity/commit/24c008df92959af98498c5ae1a79f1dbc5a16901))
## [16.8.1](https://github.com/johannesjo/super-productivity/compare/v16.8.0...v16.8.1) (2026-01-02)
### Bug Fixes

View file

@ -46,19 +46,18 @@ npm run test:file <filepath>
### Testing
- Unit tests: `npm test` - Uses Jasmine/Karma, tests are co-located with source files (`.spec.ts`)
- E2E tests: `npm run e2e` - Uses Nightwatch, located in `/e2e/src/`
- Playwright E2E tests: Located in `/e2e/`
- E2E tests: `npm run e2e` - Uses Playwright, located in `/e2e/tests/`
- `npm run e2e:playwright` - Run all tests with minimal output (shows failures clearly)
- `npm run e2e:playwright:file <path>` - Run a single test file with detailed output
- Example: `npm run e2e:playwright:file tests/work-view/work-view.spec.ts`
- `npm run e2e` - Run all tests with minimal output (shows failures clearly)
- `npm run e2e:file <path>` - Run a single test file with detailed output
- Example: `npm run e2e:file tests/work-view/work-view.spec.ts`
- `npm run e2e:supersync:file <path>` - Run SuperSync E2E tests (auto-starts the server)
- Example: `npm run e2e:supersync:file e2e/tests/sync/supersync.spec.ts`
- Running tests is slow. When fixing tests always prefer running only the affected test files first. Only when everything seems to work run the full suite to confirm.
- **IMPORTANT for Claude**: When running E2E tests:
- Use `--retries=0` to avoid long waits: `npm run e2e:file <path> -- --retries=0`
- Use `--grep "test name"` to run a single test: `npm run e2e:file <path> -- --grep "test name" --retries=0`
- Tests take ~20s each, don't use excessive timeouts
- Each test run includes a fresh server start (~5s overhead)
- **IMPORTANT for Claude**: When running the full supersync suite, use playwright directly with a line reporter for real-time output (the `npm run e2e:supersync` script buffers output):
```bash
@ -129,12 +128,16 @@ The app uses NgRx (Redux pattern) for state management. Key state slices:
11. **Event Loop Yield After Bulk Dispatches**: When applying many operations to NgRx in rapid succession (e.g., during sync replay), add `await new Promise(resolve => setTimeout(resolve, 0))` after the dispatch loop. `store.dispatch()` is non-blocking and returns immediately. Without yielding, 50+ rapid dispatches can overwhelm the store and cause state updates to be lost. See `OperationApplierService.applyOperations()` for the reference implementation.
12. **SYNC_IMPORT Semantics**: `SYNC_IMPORT` (and `BACKUP_IMPORT`) operations represent a **complete fresh start** - they replace the entire application state. All operations without knowledge of the import (CONCURRENT or LESS_THAN by vector clock) are dropped for all clients. See `SyncImportFilterService.filterOpsInvalidatedBySyncImport()`. This is correct behavior: the import is an explicit user action to restore to a specific state, and concurrent work is intentionally discarded.
## 🚫 Known Anti-Patterns to Avoid
## 🚫 Anti-Patterns → Do This Instead
- `any` or untyped public APIs
- Direct DOM access (use Angular bindings)
- Adding side effects in constructors
- Re-declaring styles that exist in Angular Material theme
- Using deprecated Angular APIs (e.g., `NgModules` when not needed)
- **Using `inject(Actions)` or `ALL_ACTIONS` in effects** - Effects should NEVER run for remote operations. Side effects happen exactly once on the originating client. Always use `inject(LOCAL_ACTIONS)` instead.
- **Selector-based effects that dispatch actions** - Effects starting with `this._store$.select(...)` bypass `LOCAL_ACTIONS` filtering and fire during hydration/sync. Either convert to action-based effects or guard with `HydrationStateService.isApplyingRemoteOps()`.
| Avoid | Do Instead |
| ------------------------------------ | ----------------------------------------------------------------------------------- |
| `any` type | Use proper types, `unknown` if truly unknown |
| Direct DOM access | Use Angular bindings, `viewChild()` if needed |
| Side effects in constructors | Prefer `async` pipe or `toSignal` |
| Mutating NgRx state directly | Return new objects in reducers |
| Subscribing without cleanup | Use `takeUntilDestroyed()` or async pipe |
| `NgModules` for new code | Use standalone components |
| Re-declaring Material theme styles | Use existing theme variables |
| `inject(Actions)` in effects | Use `inject(LOCAL_ACTIONS)` - effects must not run for remote sync ops |
| Selector-based effects that dispatch | Convert to action-based or guard with `HydrationStateService.isApplyingRemoteOps()` |

20
Dockerfile.e2e.dev Normal file
View file

@ -0,0 +1,20 @@
FROM node:22-bookworm
# Install Angular CLI globally and curl for healthcheck
RUN npm install -g @angular/cli && apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy everything (source needed for prepare script during npm install)
COPY . .
# Install dependencies
RUN npm install --legacy-peer-deps
# Default port (can be overridden via environment variable)
ENV APP_PORT=4242
EXPOSE ${APP_PORT}
# Start Angular dev server with dynamic port
CMD ["sh", "-c", "ng serve --port ${APP_PORT} --host 0.0.0.0"]

View file

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

View file

@ -6,6 +6,8 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
@ -26,6 +28,14 @@ object ReminderNotificationHelper {
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
CHANNEL_ID,
"Reminders",
@ -34,6 +44,8 @@ object ReminderNotificationHelper {
description = "Task and note reminders"
setShowBadge(true)
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
setSound(alarmSound, audioAttributes)
}
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
@ -132,7 +144,7 @@ object ReminderNotificationHelper {
.setAutoCancel(true)
.addAction(0, "Snooze 10m", snoozePendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.build()
try {

View file

@ -0,0 +1,23 @@
### Bug Fixes
* **calendar:** add periodic refresh for planner and scheduler views (77c4c33), closes #4474
* **db:** add missing _afterReady guard to loadAll method (270eca3), closes #5734
* **focus-mode:** sync time tracking with Pomodoro breaks and manual end (55fc855), closes #5875
* **i18n:** add missing translate pipe to play button tooltip (abfff27), closes #5874
* **pomodoro:** allow manual session end to start break early (291d3e8), closes #5876
* **sync:** show skip button immediately when offline (ccd4846), closes #5877
* **tasks:** handle undefined tasks in reminder effect (4497aed), closes #5873
* **test:** reset selector overrides to prevent test pollution
### Features
* **android:** add alarm sound and vibration to task reminders (b7cbef2), closes #5603
* **e2e:** streamline e2e test development with improved infrastructure
* **effects:** consolidate task update actions in PluginHooksEffects
* **sync:** add skip button to loading screen when waiting for sync (12e68cd), closes #5868
### Performance Improvements
* **e2e:** optimize wait utilities and addTask method for faster test execution
* **e2e:** remove ineffective waits to speed up test runs

View file

@ -0,0 +1,13 @@
### Bug Fixes
* **e2e:** use format-agnostic time change in task-detail tests
* **e2e:** use pressSequentially for time input in task-detail tests
* **schedule:** start tracking selected task when pressing Y in schedule view (acedc67), closes #5884
* **sync:** resolve 25-second initial sync timeout race condition (570a0b5), closes #5868 #5877
* **tasks:** clear reminder when clicking "today" button on already-today tasks (2af57d2), closes #5872
### Features
* **e2e:** add Docker-based E2E test isolation

View file

@ -4,3 +4,47 @@ services:
supersync:
ports: !override
- '1901:1900'
# Angular development server for E2E tests
app:
build:
context: .
dockerfile: Dockerfile.e2e.dev
ports:
- '${APP_PORT:-4242}:${APP_PORT:-4242}'
environment:
- APP_PORT=${APP_PORT:-4242}
healthcheck:
test: ['CMD', 'curl', '-sf', 'http://localhost:${APP_PORT:-4242}']
interval: 10s
timeout: 5s
retries: 30
start_period: 120s
# WebDAV sync server (for sync tests)
webdav:
image: hacdias/webdav:latest
ports:
- '${WEBDAV_PORT:-2345}:${WEBDAV_PORT:-2345}'
environment:
- PORT=${WEBDAV_PORT:-2345}
volumes:
- ./webdav.yaml:/config.yml:ro
- webdav_data:/data
healthcheck:
test:
[
'CMD',
'wget',
'--quiet',
'--tries=1',
'--spider',
'http://localhost:${WEBDAV_PORT:-2345}/',
]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
volumes:
webdav_data:

View file

@ -3,8 +3,8 @@
## Run Tests
```bash
npm run e2e:playwright:file tests/path/to/test.spec.ts # Single test
npm run e2e:playwright # All tests
npm run e2e:file tests/path/to/test.spec.ts # Single test
npm run e2e # All tests
```
## Test Template

View file

@ -70,7 +70,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:4242',
baseURL: process.env.E2E_BASE_URL || 'http://localhost:4242',
/* Collect trace on failure for better debugging. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
@ -130,15 +130,18 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run startFrontend:e2e',
url: 'http://localhost:4242',
reuseExistingServer: !process.env.CI, // Don't reuse in CI to ensure clean state
// unfortunately for CI we need to wait long for this to go up :(
timeout: 3 * 60 * 1000, // Allow up to 3 minutes for slower CI starts
stdout: 'ignore', // Reduce log noise
stderr: 'pipe',
},
/* When E2E_BASE_URL is set (e.g., when using Docker), skip starting the server */
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: 'npm run startFrontend:e2e',
url: 'http://localhost:4242',
reuseExistingServer: !process.env.CI, // Don't reuse in CI to ensure clean state
// unfortunately for CI we need to wait long for this to go up :(
timeout: 3 * 60 * 1000, // Allow up to 3 minutes for slower CI starts
stdout: 'ignore', // Reduce log noise
stderr: 'pipe',
},
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: path.join(__dirname, '..', '.tmp', 'e2e-test-results', 'test-results'),

View file

@ -38,12 +38,20 @@ test.describe('Task detail', () => {
await createdInfo.click();
const timeInput = await page.getByRole('combobox', { name: 'Time' });
let timeInputText = await timeInput.inputValue();
// Flipping the meridiem should guarantee a change
timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A'));
await timeInput.fill(timeInputText);
// Blur to ensure Angular registers the change before saving
await timeInput.blur();
const timeInputText = await timeInput.inputValue();
// Change the hour by adding 1 - works with both 12h and 24h formats
const match = timeInputText!.match(/^(\d+):(\d+)/);
expect(match).toBeTruthy();
const hour = parseInt(match![1], 10);
const newHour = (hour + 1) % 24;
const newTime = timeInputText!.replace(
/^\d+/,
String(newHour).padStart(match![1].length, '0'),
);
await timeInput.clear();
await timeInput.pressSequentially(newTime);
// Press Tab to commit the change before clicking Save
await timeInput.press('Tab');
await page.getByRole('button', { name: 'Save' }).click();
await expect(createdInfo).not.toHaveText(createdInfoText!);
@ -73,12 +81,20 @@ test.describe('Task detail', () => {
await completedInfo.click();
const timeInput = await page.getByRole('combobox', { name: 'Time' });
let timeInputText = await timeInput.inputValue();
// Flipping the meridiem should guarantee a change
timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A'));
await timeInput.fill(timeInputText);
// Blur to ensure Angular registers the change before saving
await timeInput.blur();
const timeInputText = await timeInput.inputValue();
// Change the hour by adding 1 - works with both 12h and 24h formats
const match = timeInputText!.match(/^(\d+):(\d+)/);
expect(match).toBeTruthy();
const hour = parseInt(match![1], 10);
const newHour = (hour + 1) % 24;
const newTime = timeInputText!.replace(
/^\d+/,
String(newHour).padStart(match![1].length, '0'),
);
await timeInput.clear();
await timeInput.pressSequentially(newTime);
// Press Tab to commit the change before clicking Save
await timeInput.press('Tab');
await page.getByRole('button', { name: 'Save' }).click();
await expect(completedInfo).not.toHaveText(completedInfoText!);

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "superProductivity",
"version": "16.8.1",
"version": "16.8.3",
"description": "ToDo list and Time Tracking",
"keywords": [
"ToDo",
@ -62,6 +62,8 @@
"e2e:supersync": "docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml up -d --build supersync && echo 'Waiting for SuperSync server...' && until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done && echo 'Server ready!' && npm run e2e -- --grep @supersync; docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml down supersync",
"e2e:supersync:file": "docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml up -d --build supersync && echo 'Waiting for SuperSync server...' && until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done && echo 'Server ready!' && E2E_VERBOSE=true npx playwright test --config e2e/playwright.config.ts --reporter=list",
"e2e:supersync:down": "docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml down supersync",
"e2e:docker": "docker compose -f docker-compose.e2e.yaml up -d app && ./scripts/wait-for-app.sh && E2E_BASE_URL=http://localhost:${APP_PORT:-4242} npm run e2e; docker compose -f docker-compose.e2e.yaml down",
"e2e:docker:webdav": "docker compose -f docker-compose.e2e.yaml up -d && ./scripts/wait-for-app.sh && ./scripts/wait-for-webdav.sh && E2E_BASE_URL=http://localhost:${APP_PORT:-4242} npm run e2e; docker compose -f docker-compose.e2e.yaml down",
"electron": "NODE_ENV=PROD electron .",
"electron:build": "tsc -p electron/tsconfig.electron.json",
"electron:watch": "tsc -p electron/tsconfig.electron.json --watch",

21
scripts/wait-for-app.sh Executable file
View file

@ -0,0 +1,21 @@
#!/bin/bash
# Wait for the Angular dev server to be ready
PORT=${APP_PORT:-4242}
MAX_WAIT=${MAX_WAIT:-180}
INTERVAL=2
echo "Waiting for app on port $PORT (max ${MAX_WAIT}s)..."
elapsed=0
until curl -sf "http://localhost:$PORT" > /dev/null 2>&1; do
if [ $elapsed -ge $MAX_WAIT ]; then
echo "Timeout: App did not start within ${MAX_WAIT}s"
exit 1
fi
sleep $INTERVAL
elapsed=$((elapsed + INTERVAL))
echo " Still waiting... (${elapsed}s)"
done
echo "App is ready on port $PORT!"

View file

@ -105,6 +105,15 @@
"
/>
</svg>
@if (showSkipSyncButton()) {
<button
mat-stroked-button
class="skip-sync-btn"
(click)="skipInitialSync()"
>
{{ T.APP.SKIP_SYNC_WAIT | translate }}
</button>
}
</div>
</div>
}

View file

@ -175,3 +175,7 @@ mat-sidenav {
background-position: center !important;
background-attachment: fixed;
}
.skip-sync-btn {
margin-top: 16px;
}

View file

@ -11,9 +11,10 @@ import {
inject,
NgZone,
OnDestroy,
signal,
ViewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { ShortcutService } from './core-ui/shortcut/shortcut.service';
import { GlobalConfigService } from './features/config/global-config.service';
import { LayoutService } from './core-ui/layout/layout.service';
@ -21,7 +22,7 @@ import { SnackService } from './core/snack/snack.service';
import { IS_ELECTRON } from './app.constants';
import { expandAnimation } from './ui/animations/expand.ani';
import { warpRouteAnimation } from './ui/animations/warp-route';
import { combineLatest, Observable, Subscription } from 'rxjs';
import { combineLatest, merge, Observable, Subscription, timer } from 'rxjs';
import { fadeAnimation } from './ui/animations/fade.ani';
import { BannerService } from './core/banner/banner.service';
import { LS } from './core/persistence/storage-keys.const';
@ -33,7 +34,8 @@ import { WorkContextService } from './features/work-context/work-context.service
import { ImexViewService } from './imex/imex-meta/imex-view.service';
import { SyncTriggerService } from './imex/sync/sync-trigger.service';
import { ActivatedRoute, RouterOutlet } from '@angular/router';
import { map, take } from 'rxjs/operators';
import { filter, map, take } from 'rxjs/operators';
import { isOnline$ } from './util/is-online';
import { IS_MOBILE } from './util/is-mobile';
import { warpAnimation, warpInAnimation } from './ui/animations/warp.ani';
import { AddTaskBarComponent } from './features/tasks/add-task-bar/add-task-bar.component';
@ -52,6 +54,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { MarkdownPasteService } from './features/tasks/markdown-paste.service';
import { TaskService } from './features/tasks/task.service';
import { MatButton } from '@angular/material/button';
import { MatMenuItem } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { DialogUnsplashPickerComponent } from './ui/dialog-unsplash-picker/dialog-unsplash-picker.component';
@ -99,6 +102,7 @@ interface BeforeInstallPromptEvent extends Event {
FocusModeOverlayComponent,
ShepherdComponent,
AsyncPipe,
MatButton,
MatMenuItem,
MatIcon,
TranslatePipe,
@ -136,6 +140,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
productivityTipTitle: string = productivityTip?.[0] || '';
productivityTipText: string = productivityTip?.[1] || '';
showSkipSyncButton = signal(false);
@ViewChild('routeWrapper', { read: ElementRef }) routeWrapper?: ElementRef<HTMLElement>;
@ -205,6 +210,25 @@ export class AppComponent implements OnDestroy, AfterViewInit {
.subscribe(() => {
void this._noteStartupBannerService.showLastNoteIfNeeded();
});
// Show skip sync button immediately if offline, otherwise after 3 seconds
merge(
// Immediate trigger if offline
isOnline$.pipe(
filter((isOnline) => !isOnline),
take(1),
),
// Fallback after 3 seconds regardless
timer(3000),
)
.pipe(take(1), takeUntilDestroyed())
.subscribe(() => {
this.showSkipSyncButton.set(true);
});
}
skipInitialSync(): void {
this.syncTriggerService.setInitialSyncDone(true);
}
@HostListener('document:paste', ['$event']) onPaste(ev: ClipboardEvent): void {

View file

@ -74,7 +74,7 @@ import { distinctUntilChanged, observeOn } from 'rxjs/operators';
<button
(click)="taskService.toggleStartTask()"
[color]="currentTaskId() ? 'accent' : 'primary'"
[matTooltip]="tooltipText()"
[matTooltip]="tooltipText() | translate"
matTooltipPosition="below"
class="play-btn tour-playBtn mat-elevation-z3"
mat-mini-fab

View file

@ -18,6 +18,7 @@ import {
merge,
Observable,
of,
timer,
} from 'rxjs';
import { T } from '../../t.const';
import { SnackService } from '../../core/snack/snack.service';
@ -43,6 +44,7 @@ import {
getCalendarEventIdCandidates,
matchesAnyCalendarEventId,
} from './get-calendar-event-id-candidates';
import { getEffectiveCheckInterval } from '../issue/providers/calendar/calendar.const';
const ONE_MONTHS = 60 * 60 * 1000 * 24 * 31;
@ -60,77 +62,96 @@ export class CalendarIntegrationService {
this._store.select(selectCalendarProviders).pipe(
distinctUntilChanged(fastArrayCompare),
switchMap((calendarProviders) => {
return calendarProviders && calendarProviders.length
? forkJoin(
calendarProviders.map((calProvider) => {
if (!calProvider.isEnabled) {
return of({
itemsForProvider: [] as CalendarIntegrationEvent[],
calProvider,
didError: false,
});
}
return this.requestEventsForSchedule$(calProvider, true).pipe(
first(),
map((itemsForProvider: CalendarIntegrationEvent[]) => ({
itemsForProvider,
calProvider,
didError: false,
})),
catchError(() =>
of({
itemsForProvider: [] as CalendarIntegrationEvent[],
calProvider,
didError: true,
}),
),
);
}),
).pipe(
switchMap((resultForProviders) =>
combineLatest([
this._store
.select(selectAllCalendarTaskEventIds)
.pipe(distinctUntilChanged(fastArrayCompare)),
this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)),
]).pipe(
// tap((val) => Log.log('selectAllCalendarTaskEventIds', val)),
map(([allCalendarTaskEventIds, skippedEventIds]) => {
const cachedByProviderId = this._groupCachedEventsByProvider(
this._getCalProviderFromCache(),
);
return resultForProviders.map(
({ itemsForProvider, calProvider, didError }) => {
// Fall back to cached data when the live fetch errored so offline mode keeps showing events.
const sourceItems: ScheduleFromCalendarEvent[] = didError
? (cachedByProviderId.get(calProvider.id) ?? [])
: (itemsForProvider as ScheduleFromCalendarEvent[]);
return {
// filter out items already added as tasks
items: sourceItems.filter(
(calEv) =>
!matchesAnyCalendarEventId(
calEv,
allCalendarTaskEventIds,
) && !matchesAnyCalendarEventId(calEv, skippedEventIds),
),
} as ScheduleCalendarMapEntry;
},
);
}),
),
),
// tap((v) => Log.log('icalEvents$ final', v)),
tap((val) => {
saveToRealLs(LS.CAL_EVENTS_CACHE, val);
}),
)
: (of([]) as Observable<ScheduleCalendarMapEntry[]>);
if (!calendarProviders?.length) {
return of([]) as Observable<ScheduleCalendarMapEntry[]>;
}
// Calculate the minimum refresh interval from all enabled providers
const minInterval = this._getMinRefreshInterval(calendarProviders);
// Use timer to periodically refresh calendar data
return timer(0, minInterval).pipe(
switchMap(() => this._fetchAllProviders(calendarProviders)),
);
}),
shareReplay({ bufferSize: 1, refCount: true }),
),
);
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
private _fetchAllProviders(
calendarProviders: IssueProviderCalendar[],
): Observable<ScheduleCalendarMapEntry[]> {
return forkJoin(
calendarProviders.map((calProvider) => {
if (!calProvider.isEnabled) {
return of({
itemsForProvider: [] as CalendarIntegrationEvent[],
calProvider,
didError: false,
});
}
return this.requestEventsForSchedule$(calProvider, true).pipe(
first(),
map((itemsForProvider: CalendarIntegrationEvent[]) => ({
itemsForProvider,
calProvider,
didError: false,
})),
catchError(() =>
of({
itemsForProvider: [] as CalendarIntegrationEvent[],
calProvider,
didError: true,
}),
),
);
}),
).pipe(
switchMap((resultForProviders) =>
combineLatest([
this._store
.select(selectAllCalendarTaskEventIds)
.pipe(distinctUntilChanged(fastArrayCompare)),
this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)),
]).pipe(
map(([allCalendarTaskEventIds, skippedEventIds]) => {
const cachedByProviderId = this._groupCachedEventsByProvider(
this._getCalProviderFromCache(),
);
return resultForProviders.map(
({ itemsForProvider, calProvider, didError }) => {
// Fall back to cached data when the live fetch errored so offline mode keeps showing events.
const sourceItems: ScheduleFromCalendarEvent[] = didError
? (cachedByProviderId.get(calProvider.id) ?? [])
: (itemsForProvider as ScheduleFromCalendarEvent[]);
return {
// filter out items already added as tasks
items: sourceItems.filter(
(calEv) =>
!matchesAnyCalendarEventId(calEv, allCalendarTaskEventIds) &&
!matchesAnyCalendarEventId(calEv, skippedEventIds),
),
} as ScheduleCalendarMapEntry;
},
);
}),
),
),
tap((val) => {
saveToRealLs(LS.CAL_EVENTS_CACHE, val);
}),
);
}
/**
* Calculate the minimum refresh interval from all enabled providers.
* Uses getEffectiveCheckInterval which returns 5 min for file:// URLs.
*/
private _getMinRefreshInterval(calendarProviders: IssueProviderCalendar[]): number {
const enabledProviders = calendarProviders.filter((p) => p.isEnabled && p.icalUrl);
if (!enabledProviders.length) {
return 2 * 60 * 60 * 1000; // Default 2 hours
}
return Math.min(...enabledProviders.map((p) => getEffectiveCheckInterval(p)));
}
public readonly skippedEventIds$ = new BehaviorSubject<string[]>([]);

View file

@ -26,6 +26,7 @@ import {
matchesAnyCalendarEventId,
shareCalendarEventId,
} from '../get-calendar-event-id-candidates';
import { getEffectiveCheckInterval } from '../../issue/providers/calendar/calendar.const';
const CHECK_TO_SHOW_INTERVAL = 60 * 1000;
@ -72,8 +73,7 @@ export class CalendarIntegrationEffects {
return forkJoin(
activatedProviders.map((calProvider) =>
timer(0, calProvider.checkUpdatesEvery).pipe(
// timer(0, 10000).pipe(
timer(0, getEffectiveCheckInterval(calProvider)).pipe(
// tap(() => Log.log('REQUEST CALENDAR', calProvider)),
switchMap(() =>
this._calendarIntegrationService.requestEvents$(calProvider),

View file

@ -4,7 +4,7 @@
(focusModeService.isBreakLong()
? T.F.FOCUS_MODE.LONG_BREAK_TITLE
: T.F.FOCUS_MODE.SHORT_BREAK_TITLE
) | translate: { cycle: focusModeService.currentCycle() }
) | translate: { cycle: focusModeService.currentCycle() - 1 }
}}
</h1>

View file

@ -0,0 +1,296 @@
/**
* Integration tests for GitHub issue #5875
* https://github.com/johannesjo/super-productivity/issues/5875
*
* Bug: You can break Pomodoro timer syncing
*
* Two scenarios where time tracking and Pomodoro focus session sync can become desynchronized:
*
* Bug 1: Pressing time tracking button during break breaks sync
* When a Pomodoro break is running (with isPauseTrackingDuringBreak enabled),
* pressing the main time tracking button starts tracking even though a break is active.
* This creates an inconsistent state where tracking is running but focus session is on break.
*
* Bug 2: "End Session" button doesn't stop time tracking
* When user manually ends a session via the "End session" button,
* time tracking continues even though the focus session has ended.
*
* Fix:
* 1. syncTrackingStartToSession$ should check if break is active and not start/resume session
* 2. sessionComplete$ should stop time tracking when session is manually completed
*/
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { FocusModeEffects } from './focus-mode.effects';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { FocusModeStrategyFactory } from '../focus-mode-strategies';
import { GlobalConfigService } from '../../config/global-config.service';
import { TaskService } from '../../tasks/task.service';
import { BannerService } from '../../../core/banner/banner.service';
import { MetricService } from '../../metric/metric.service';
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 {
selectFocusModeConfig,
selectIsFocusModeEnabled,
selectPomodoroConfig,
} from '../../config/store/global-config.reducer';
import { take, toArray } from 'rxjs/operators';
describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
let actions$: Observable<any>;
let effects: FocusModeEffects;
let store: MockStore;
let strategyFactoryMock: any;
let taskServiceMock: any;
let globalConfigServiceMock: any;
let metricServiceMock: any;
let currentTaskId$: BehaviorSubject<string | null>;
const createMockTimer = (overrides: Partial<TimerState> = {}): TimerState => ({
isRunning: false,
startedAt: null,
elapsed: 0,
duration: 0,
purpose: null,
...overrides,
});
beforeEach(() => {
currentTaskId$ = new BehaviorSubject<string | null>(null);
strategyFactoryMock = {
getStrategy: jasmine.createSpy('getStrategy').and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: true,
shouldAutoStartNextSession: true,
getBreakDuration: jasmine
.createSpy('getBreakDuration')
.and.returnValue({ duration: 5 * 60 * 1000, isLong: false }),
}),
};
taskServiceMock = {
currentTaskId$: currentTaskId$.asObservable(),
currentTaskId: jasmine.createSpy('currentTaskId').and.returnValue(null),
};
globalConfigServiceMock = {
sound: jasmine.createSpy('sound').and.returnValue({ volume: 75 }),
};
metricServiceMock = {
logFocusSession: jasmine.createSpy('logFocusSession'),
};
TestBed.configureTestingModule({
providers: [
FocusModeEffects,
provideMockActions(() => actions$),
provideMockStore({
initialState: {
focusMode: {
timer: createMockTimer(),
mode: FocusModeMode.Pomodoro,
currentCycle: 1,
lastCompletedDuration: 0,
},
},
selectors: [
{ selector: selectors.selectTimer, value: createMockTimer() },
{ selector: selectors.selectMode, value: FocusModeMode.Pomodoro },
{ selector: selectors.selectCurrentCycle, value: 1 },
{ selector: selectors.selectLastSessionDuration, value: 0 },
{
selector: selectFocusModeConfig,
value: { isSyncSessionWithTracking: true },
},
{ selector: selectPomodoroConfig, value: { duration: 25 * 60 * 1000 } },
{ selector: selectIsFocusModeEnabled, value: true },
],
}),
{ provide: FocusModeStrategyFactory, useValue: strategyFactoryMock },
{ provide: GlobalConfigService, useValue: globalConfigServiceMock },
{ provide: TaskService, useValue: taskServiceMock },
{
provide: BannerService,
useValue: { open: jasmine.createSpy(), dismiss: jasmine.createSpy() },
},
{ provide: MetricService, useValue: metricServiceMock },
{
provide: FocusModeStorageService,
useValue: { setLastCountdownDuration: jasmine.createSpy() },
},
],
});
effects = TestBed.inject(FocusModeEffects);
store = TestBed.inject(MockStore);
});
afterEach(() => {
store.resetSelectors();
});
describe('Bug 1: Time tracking start during break should skip break and start session', () => {
it('should dispatch skipBreak when break is running and user starts tracking', (done) => {
// Setup: Break is running (timer.purpose === 'break', isRunning === true)
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: true,
});
store.overrideSelector(
selectors.selectTimer,
createMockTimer({ isRunning: true, purpose: 'break', duration: 5 * 60 * 1000 }),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentScreen, FocusScreen.Break);
store.overrideSelector(selectors.selectPausedTaskId, null);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
// Simulate user pressing time tracking button during break
setTimeout(() => {
currentTaskId$.next('task-123');
}, 10);
// Should dispatch skipBreak to sync state
effects.syncTrackingStartToSession$.pipe(take(1)).subscribe((action) => {
expect(action.type).toEqual(actions.skipBreak.type);
done();
});
});
it('should dispatch skipBreak when break is paused and user starts tracking', (done) => {
// Setup: Break is paused (timer.purpose === 'break', isRunning === false)
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: true,
});
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'break',
duration: 5 * 60 * 1000,
elapsed: 60 * 1000,
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentScreen, FocusScreen.Break);
store.overrideSelector(selectors.selectPausedTaskId, null);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
// Simulate user pressing time tracking button during paused break
setTimeout(() => {
currentTaskId$.next('task-123');
}, 10);
// Should dispatch skipBreak to sync state
effects.syncTrackingStartToSession$.pipe(take(1)).subscribe((action) => {
expect(action.type).toEqual(actions.skipBreak.type);
done();
});
});
});
describe('Bug 2: Manual End Session should stop time tracking', () => {
it('should dispatch unsetCurrentTask when session is manually ended and sync is enabled', (done) => {
// Setup: Session is running, sync is enabled, task is being tracked
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: false,
isManualBreakStart: true, // No auto-break, so we can isolate the tracking stop behavior
});
currentTaskId$.next('task-123'); // Task is being tracked
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeDefined();
done();
});
});
it('should NOT dispatch unsetCurrentTask when session ends automatically (timer completion)', (done) => {
// Setup: Session completes automatically (not manual), manual break start enabled
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: false, // Don't pause during break
isManualBreakStart: true, // Manual break start, so no auto-break
});
currentTaskId$.next('task-123');
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: false }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
// For automatic completion with manual break start and no pause during break,
// tracking should continue (user explicitly chose not to pause during break)
expect(unsetAction).toBeUndefined();
done();
});
});
it('should NOT dispatch unsetCurrentTask when sync is disabled', (done) => {
// Setup: Sync is disabled
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: false, // Sync disabled
isSkipPreparation: false,
isManualBreakStart: true,
});
currentTaskId$.next('task-123');
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeUndefined();
done();
});
});
it('should NOT dispatch unsetCurrentTask when no task is being tracked', (done) => {
// Setup: No task is being tracked
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isManualBreakStart: true,
});
currentTaskId$.next(null); // No task tracking
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeUndefined();
done();
});
});
});
});

View file

@ -390,16 +390,17 @@ describe('FocusModeEffects', () => {
});
});
it('should NOT dispatch startBreak for manual completions', (done) => {
it('should dispatch startBreak for manual completions (to allow early break start)', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeUndefined();
expect(startBreakAction).toBeDefined();
done();
});
});

View file

@ -94,12 +94,18 @@ export class FocusModeEffects {
this.store.select(selectors.selectTimer),
this.store.select(selectors.selectMode),
this.store.select(selectors.selectCurrentScreen),
this.store.select(selectors.selectPausedTaskId),
),
switchMap(([_taskId, timer, mode, currentScreen]) => {
switchMap(([_taskId, timer, mode, currentScreen, pausedTaskId]) => {
// If session is paused (purpose is 'work' but not running), resume it
if (timer.purpose === 'work' && !timer.isRunning) {
return of(actions.unPauseFocusSession());
}
// If break is active (running or paused), skip it to sync with tracking
// This fixes bug #5875: pressing time tracking button during break
if (timer.purpose === 'break') {
return of(actions.skipBreak({ pausedTaskId }));
}
// If no session active, start a new one (only from Main screen)
if (timer.purpose === null && currentScreen === FocusScreen.Main) {
const strategy = this.strategyFactory.getStrategy(mode);
@ -281,13 +287,18 @@ export class FocusModeEffects {
actionsToDispatch.push(actions.incrementCycle());
}
// Check if we should start a break - only for automatic completions
// Manual completions should stay on SessionDone screen
// Also skip if manual break start is enabled (user must click "Start Break")
// Stop time tracking when session is manually ended and sync is enabled
// This fixes bug #5875: "End Session" button should stop tracking
const shouldStopTrackingOnManualEnd =
action.isManual && focusModeConfig?.isSyncSessionWithTracking && currentTaskId;
if (shouldStopTrackingOnManualEnd) {
actionsToDispatch.push(unsetCurrentTask());
}
// Check if we should start a break after session completion
// Skip if manual break start is enabled (user must click "Start Break")
const shouldAutoStartBreak =
!action.isManual &&
strategy.shouldStartBreakAfterSession &&
!focusModeConfig?.isManualBreakStart;
strategy.shouldStartBreakAfterSession && !focusModeConfig?.isManualBreakStart;
if (shouldAutoStartBreak) {
// Pause task tracking during break if enabled
const shouldPauseTracking =

View file

@ -0,0 +1,109 @@
import {
getEffectiveCheckInterval,
LOCAL_FILE_CHECK_INTERVAL,
DEFAULT_CALENDAR_CFG,
} from './calendar.const';
import { IssueProviderCalendar } from '../../issue.model';
describe('calendar.const', () => {
describe('LOCAL_FILE_CHECK_INTERVAL', () => {
it('should be 5 minutes in milliseconds', () => {
expect(LOCAL_FILE_CHECK_INTERVAL).toBe(5 * 60 * 1000);
});
});
describe('getEffectiveCheckInterval', () => {
const createMockProvider = (
overrides: Partial<IssueProviderCalendar> = {},
): IssueProviderCalendar =>
({
id: 'test-provider',
isEnabled: true,
issueProviderKey: 'ICAL',
icalUrl: 'https://example.com/calendar.ics',
checkUpdatesEvery: DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
showBannerBeforeThreshold: DEFAULT_CALENDAR_CFG.showBannerBeforeThreshold,
isAutoImportForCurrentDay: false,
isDisabledForWebApp: false,
...overrides,
}) as IssueProviderCalendar;
it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs', () => {
const provider = createMockProvider({
icalUrl: 'file:///home/user/calendar.ics',
});
expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL);
});
it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs with different paths', () => {
const provider = createMockProvider({
icalUrl: 'file:///C:/Users/test/calendar.ics',
});
expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL);
});
it('should return checkUpdatesEvery for http:// URLs', () => {
const customInterval = 30 * 60 * 1000; // 30 minutes
const provider = createMockProvider({
icalUrl: 'http://example.com/calendar.ics',
checkUpdatesEvery: customInterval,
});
expect(getEffectiveCheckInterval(provider)).toBe(customInterval);
});
it('should return checkUpdatesEvery for https:// URLs', () => {
const customInterval = 60 * 60 * 1000; // 1 hour
const provider = createMockProvider({
icalUrl: 'https://calendar.google.com/calendar.ics',
checkUpdatesEvery: customInterval,
});
expect(getEffectiveCheckInterval(provider)).toBe(customInterval);
});
it('should return default checkUpdatesEvery when no custom interval set', () => {
const provider = createMockProvider({
icalUrl: 'https://example.com/calendar.ics',
});
expect(getEffectiveCheckInterval(provider)).toBe(
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
);
});
it('should handle undefined icalUrl gracefully', () => {
const provider = createMockProvider({
icalUrl: undefined as unknown as string,
});
expect(getEffectiveCheckInterval(provider)).toBe(
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
);
});
it('should handle empty string icalUrl', () => {
const provider = createMockProvider({
icalUrl: '',
});
expect(getEffectiveCheckInterval(provider)).toBe(
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
);
});
it('should be case-sensitive for file:// protocol', () => {
// file:// should be lowercase per URI spec
const provider = createMockProvider({
icalUrl: 'FILE:///home/user/calendar.ics',
});
// FILE:// doesn't match file://, so should use default interval
expect(getEffectiveCheckInterval(provider)).toBe(
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
);
});
});
});

View file

@ -6,6 +6,16 @@ import { ISSUE_PROVIDER_FF_DEFAULT_PROJECT } from '../../common-issue-form-stuff
import { IS_ELECTRON } from '../../../../app.constants';
import { IssueLog } from '../../../../core/log';
// 5 minutes for local file:// URLs (faster polling for local calendars)
export const LOCAL_FILE_CHECK_INTERVAL = 5 * 60 * 1000;
export const getEffectiveCheckInterval = (calProvider: IssueProviderCalendar): number => {
if (calProvider.icalUrl?.startsWith('file://')) {
return LOCAL_FILE_CHECK_INTERVAL;
}
return calProvider.checkUpdatesEvery;
};
export const DEFAULT_CALENDAR_CFG: CalendarProviderCfg = {
isEnabled: false,
icalUrl: '',

View file

@ -10,6 +10,7 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'
import { DEFAULT_TASK, Task } from '../task.model';
import { TestScheduler } from 'rxjs/testing';
import { T } from '../../../t.const';
import { removeReminderFromTask } from './task.actions';
describe('TaskReminderEffects', () => {
let actions$: Observable<Action>;
@ -19,6 +20,7 @@ describe('TaskReminderEffects', () => {
let store: jasmine.SpyObj<Store>;
let datePipe: jasmine.SpyObj<LocaleDatePipe>;
let testScheduler: TestScheduler;
let taskServiceMock: jasmine.SpyObj<TaskService>;
const mockTask: Task = {
...DEFAULT_TASK,
@ -39,6 +41,11 @@ describe('TaskReminderEffects', () => {
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
taskServiceMock = jasmine.createSpyObj('TaskService', [
'getByIdOnce$',
'getByIdsLive$',
]);
TestBed.configureTestingModule({
providers: [
TaskReminderEffects,
@ -294,4 +301,98 @@ describe('TaskReminderEffects', () => {
});
});
});
describe('removeTaskReminderTrigger1$', () => {
it('should handle undefined tasks in array without crashing (issue #5873)', (done) => {
const taskWithReminder = createMockTask({ id: 'task-1', reminderId: 'rem-1' });
// Simulate a deleted task that returns undefined from the selector
taskServiceMock.getByIdsLive$.and.returnValue(
of([
taskWithReminder,
undefined as unknown as Task,
undefined as unknown as Task,
]),
);
actions$ = of(
TaskSharedActions.planTasksForToday({
taskIds: ['task-1', 'deleted-task', 'another-deleted'],
parentTaskMap: {},
isSkipRemoveReminder: false,
}),
);
const emittedActions: any[] = [];
effects.removeTaskReminderTrigger1$.subscribe({
next: (action) => emittedActions.push(action),
complete: () => {
// Should only emit action for the valid task with reminder
expect(emittedActions.length).toBe(1);
expect(emittedActions[0]).toEqual(
removeReminderFromTask({
id: 'task-1',
reminderId: 'rem-1',
isSkipToast: true,
}),
);
done();
},
});
});
it('should not emit actions when all tasks are undefined', (done) => {
taskServiceMock.getByIdsLive$.and.returnValue(
of([undefined as unknown as Task, undefined as unknown as Task]),
);
actions$ = of(
TaskSharedActions.planTasksForToday({
taskIds: ['deleted-1', 'deleted-2'],
parentTaskMap: {},
isSkipRemoveReminder: false,
}),
);
const emittedActions: any[] = [];
effects.removeTaskReminderTrigger1$.subscribe({
next: (action) => emittedActions.push(action),
complete: () => {
expect(emittedActions.length).toBe(0);
done();
},
});
});
it('should only emit for tasks with reminderId', (done) => {
const taskWithReminder = createMockTask({ id: 'task-1', reminderId: 'rem-1' });
const taskWithoutReminder = createMockTask({ id: 'task-2', reminderId: undefined });
taskServiceMock.getByIdsLive$.and.returnValue(
of([taskWithReminder, taskWithoutReminder]),
);
actions$ = of(
TaskSharedActions.planTasksForToday({
taskIds: ['task-1', 'task-2'],
parentTaskMap: {},
isSkipRemoveReminder: false,
}),
);
const emittedActions: any[] = [];
effects.removeTaskReminderTrigger1$.subscribe({
next: (action) => emittedActions.push(action),
complete: () => {
expect(emittedActions.length).toBe(1);
expect(emittedActions[0]).toEqual(
removeReminderFromTask({
id: 'task-1',
reminderId: 'rem-1',
isSkipToast: true,
}),
);
done();
},
});
});
});
});

View file

@ -612,6 +612,17 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
const newDayDate = new Date(selectedDate);
const newDay = getDbDateStr(newDayDate);
// If task is already scheduled for today with time and we're planning for today,
// just add to my day (which clears the reminder) instead of rescheduling
if (
this.task.dueWithTime &&
isToday(this.task.dueWithTime) &&
newDay === getDbDateStr()
) {
this.addToMyDay();
return;
}
if (isRemoveFromToday) {
this.unschedule();
} else if (this.task.dueDay === newDay) {

View file

@ -1,6 +1,113 @@
import { getDbDateStr } from '../../../../util/get-db-date-str';
import { isToday } from '../../../../util/is-today.util';
describe('TaskContextMenuInnerComponent timezone test', () => {
describe('_schedule method same-day detection for tasks with dueWithTime (issue #5872)', () => {
// This tests the fix for issue #5872 comment:
// When a task is scheduled for today with a time-based reminder, clicking the "today"
// quick access button should clear the reminder (via addToMyDay) instead of
// rescheduling with a new reminder.
//
// The condition in _schedule method (lines 618-622):
// if (this.task.dueWithTime && isToday(this.task.dueWithTime) && newDay === getDbDateStr())
it('should detect same-day scheduling when task has dueWithTime for today', () => {
// Simulate: task is scheduled for today at 3 PM, user clicks "today" button
const todayAt3pm = new Date();
todayAt3pm.setHours(15, 0, 0, 0);
const dueWithTime = todayAt3pm.getTime();
// User selects "today" via quick access button
const selectedDate = new Date();
const newDay = getDbDateStr(new Date(selectedDate));
// The condition that triggers addToMyDay instead of scheduleTask
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBe(true);
});
it('should NOT detect same-day when task has dueWithTime for tomorrow', () => {
// Simulate: task is scheduled for tomorrow at 3 PM, user clicks "today" button
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(15, 0, 0, 0);
const dueWithTime = tomorrow.getTime();
// User selects "today" via quick access button
const selectedDate = new Date();
const newDay = getDbDateStr(new Date(selectedDate));
// This should NOT trigger addToMyDay - should go through scheduleTask path
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBe(false);
});
it('should NOT detect same-day when task has dueWithTime for today but scheduling for tomorrow', () => {
// Simulate: task is scheduled for today at 3 PM, user clicks "tomorrow" button
const todayAt3pm = new Date();
todayAt3pm.setHours(15, 0, 0, 0);
const dueWithTime = todayAt3pm.getTime();
// User selects "tomorrow" via quick access button
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const newDay = getDbDateStr(tomorrow);
// This should NOT trigger addToMyDay - different day selected
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBe(false);
});
it('should handle task scheduled near midnight today, clicking today button', () => {
// Simulate: task is scheduled for today at 11:30 PM, user clicks "today" button
const todayAt1130pm = new Date();
todayAt1130pm.setHours(23, 30, 0, 0);
const dueWithTime = todayAt1130pm.getTime();
// User selects "today" via quick access button
const selectedDate = new Date();
const newDay = getDbDateStr(new Date(selectedDate));
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBe(true);
});
it('should NOT trigger for tasks without dueWithTime (null)', () => {
// Simulate: task has no time-based schedule
const dueWithTime = null;
const selectedDate = new Date();
const newDay = getDbDateStr(new Date(selectedDate));
// @ts-ignore - testing null case
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBeFalsy();
});
it('should NOT trigger for tasks without dueWithTime (undefined)', () => {
// Simulate: task has no time-based schedule
const dueWithTime = undefined;
const selectedDate = new Date();
const newDay = getDbDateStr(new Date(selectedDate));
const shouldCallAddToMyDay =
dueWithTime && isToday(dueWithTime) && newDay === getDbDateStr();
expect(shouldCallAddToMyDay).toBeFalsy();
});
});
describe('_schedule method date handling', () => {
it('should handle scheduled date correctly across timezones', () => {
// This test demonstrates the usage in task-context-menu-inner.component.ts line 590:

View file

@ -0,0 +1,269 @@
import { TestBed } from '@angular/core/testing';
import { TaskShortcutService } from './task-shortcut.service';
import { TaskFocusService } from './task-focus.service';
import { TaskService } from './task.service';
import { GlobalConfigService } from '../config/global-config.service';
import { signal } from '@angular/core';
describe('TaskShortcutService', () => {
let service: TaskShortcutService;
let mockTaskFocusService: {
focusedTaskId: ReturnType<typeof signal<string | null>>;
lastFocusedTaskComponent: ReturnType<typeof signal<any>>;
};
let mockTaskService: jasmine.SpyObj<TaskService> & {
selectedTaskId: ReturnType<typeof signal<string | null>>;
currentTaskId: ReturnType<typeof signal<string | null>>;
};
let mockConfigService: {
cfg: ReturnType<typeof signal<any>>;
};
const defaultKeyboardConfig = {
togglePlay: 'Y',
taskEditTitle: 'Enter',
taskToggleDetailPanelOpen: 'I',
taskOpenEstimationDialog: 'T',
taskSchedule: 'S',
taskToggleDone: 'D',
taskAddSubTask: 'A',
taskAddAttachment: null,
taskDelete: 'Backspace',
taskMoveToProject: 'P',
taskEditTags: 'G',
taskOpenContextMenu: null,
moveToBacklog: 'B',
moveToTodaysTasks: 'F',
selectPreviousTask: 'K',
selectNextTask: 'J',
collapseSubTasks: 'H',
expandSubTasks: 'L',
moveTaskUp: null,
moveTaskDown: null,
moveTaskToTop: null,
moveTaskToBottom: null,
};
const createKeyboardEvent = (key: string, code?: string): KeyboardEvent => {
return new KeyboardEvent('keydown', {
key,
code: code || (key.length === 1 ? `Key${key.toUpperCase()}` : key),
bubbles: true,
cancelable: true,
});
};
beforeEach(() => {
// Create signal-based mocks
mockTaskFocusService = {
focusedTaskId: signal<string | null>(null),
lastFocusedTaskComponent: signal<any>(null),
};
mockTaskService = {
selectedTaskId: signal<string | null>(null),
currentTaskId: signal<string | null>(null),
setCurrentId: jasmine.createSpy('setCurrentId'),
toggleStartTask: jasmine.createSpy('toggleStartTask'),
} as any;
mockConfigService = {
cfg: signal({
keyboard: defaultKeyboardConfig,
appFeatures: {
isTimeTrackingEnabled: true,
},
}),
};
TestBed.configureTestingModule({
providers: [
TaskShortcutService,
{ provide: TaskFocusService, useValue: mockTaskFocusService },
{ provide: TaskService, useValue: mockTaskService },
{ provide: GlobalConfigService, useValue: mockConfigService },
],
});
service = TestBed.inject(TaskShortcutService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('handleTaskShortcuts - togglePlay (Y key)', () => {
describe('when focused task exists', () => {
it('should delegate to focused task component togglePlayPause method', () => {
// Arrange
const mockTaskComponent = {
togglePlayPause: jasmine.createSpy('togglePlayPause'),
taskContextMenu: () => undefined, // No context menu open
};
mockTaskFocusService.focusedTaskId.set('focused-task-1');
mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent);
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockTaskComponent.togglePlayPause).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).not.toHaveBeenCalled();
expect(mockTaskService.toggleStartTask).not.toHaveBeenCalled();
});
});
describe('when no focused task but selected task exists', () => {
it('should start tracking selected task when not currently tracking it', () => {
// Arrange
mockTaskFocusService.focusedTaskId.set(null);
mockTaskService.selectedTaskId.set('selected-task-1');
mockTaskService.currentTaskId.set(null);
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).toHaveBeenCalledWith('selected-task-1');
expect(mockTaskService.toggleStartTask).not.toHaveBeenCalled();
});
it('should start tracking selected task when tracking a different task', () => {
// Arrange
mockTaskFocusService.focusedTaskId.set(null);
mockTaskService.selectedTaskId.set('selected-task-1');
mockTaskService.currentTaskId.set('other-task');
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).toHaveBeenCalledWith('selected-task-1');
});
it('should stop tracking when selected task is already being tracked', () => {
// Arrange
mockTaskFocusService.focusedTaskId.set(null);
mockTaskService.selectedTaskId.set('selected-task-1');
mockTaskService.currentTaskId.set('selected-task-1');
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).toHaveBeenCalledWith(null);
});
});
describe('when neither focused nor selected task exists', () => {
it('should use global toggle behavior', () => {
// Arrange
mockTaskFocusService.focusedTaskId.set(null);
mockTaskService.selectedTaskId.set(null);
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockTaskService.toggleStartTask).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).not.toHaveBeenCalled();
});
});
describe('when time tracking is disabled', () => {
it('should not handle Y key when time tracking is disabled', () => {
// Arrange
mockConfigService.cfg.set({
keyboard: defaultKeyboardConfig,
appFeatures: {
isTimeTrackingEnabled: false,
},
});
mockTaskFocusService.focusedTaskId.set(null);
mockTaskService.selectedTaskId.set('selected-task-1');
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(false);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(mockTaskService.setCurrentId).not.toHaveBeenCalled();
expect(mockTaskService.toggleStartTask).not.toHaveBeenCalled();
});
});
describe('priority: focused task takes priority over selected task', () => {
it('should use focused task even when selected task is different', () => {
// Arrange
const mockTaskComponent = {
togglePlayPause: jasmine.createSpy('togglePlayPause'),
taskContextMenu: () => undefined, // No context menu open
};
mockTaskFocusService.focusedTaskId.set('focused-task-1');
mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent);
mockTaskService.selectedTaskId.set('selected-task-2'); // Different task selected
const event = createKeyboardEvent('Y');
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(true);
expect(mockTaskComponent.togglePlayPause).toHaveBeenCalled();
expect(mockTaskService.setCurrentId).not.toHaveBeenCalled();
});
});
});
describe('other shortcuts require focused task', () => {
it('should return false for non-togglePlay shortcuts when no focused task', () => {
// Arrange
mockTaskFocusService.focusedTaskId.set(null);
// Various other shortcut keys
const otherKeys = ['D', 'Enter', 'ArrowUp', 'ArrowDown'];
for (const key of otherKeys) {
const event = createKeyboardEvent(key);
spyOn(event, 'preventDefault');
// Act
const result = service.handleTaskShortcuts(event);
// Assert
expect(result).toBe(false);
}
});
});
});

View file

@ -1,5 +1,6 @@
import { computed, inject, Injectable } from '@angular/core';
import { TaskFocusService } from './task-focus.service';
import { TaskService } from './task.service';
import { GlobalConfigService } from '../config/global-config.service';
import { checkKeyCombo } from '../../util/check-key-combo';
import { Log } from '../../core/log';
@ -35,6 +36,7 @@ type TaskComponentMethod = keyof TaskComponent;
})
export class TaskShortcutService {
private readonly _taskFocusService = inject(TaskFocusService);
private readonly _taskService = inject(TaskService);
private readonly _configService = inject(GlobalConfigService);
readonly isTimeTrackingEnabled = computed(
() => this._configService.cfg()?.appFeatures.isTimeTrackingEnabled,
@ -47,26 +49,44 @@ export class TaskShortcutService {
* @returns True if the shortcut was handled, false otherwise
*/
handleTaskShortcuts(ev: KeyboardEvent): boolean {
// Handle task-specific shortcuts if a task is focused
const focusedTaskId: TaskId | null = this._taskFocusService.focusedTaskId();
// Log.log('TaskShortcutService.handleTaskShortcuts', {
// focusedTaskId,
// key: ev.key,
// ctrlKey: ev.ctrlKey,
// shiftKey: ev.shiftKey,
// altKey: ev.altKey,
// });
if (!focusedTaskId) {
// Log.log('TaskShortcutService: No focused task ID');
return false;
}
const cfg = this._configService.cfg();
if (!cfg) return false;
const keys = cfg.keyboard;
const focusedTaskId: TaskId | null = this._taskFocusService.focusedTaskId();
// Handle togglePlay specially - it works with focusedTaskId OR selectedTaskId
// This allows starting time tracking from Schedule view where tasks are selected but not focused
if (checkKeyCombo(ev, keys.togglePlay) && this.isTimeTrackingEnabled()) {
if (focusedTaskId) {
// Focused task exists - delegate to the task component
this._handleTaskShortcut(focusedTaskId, 'togglePlayPause');
} else {
// No focused task - check for selected task (e.g., from Schedule view)
const selectedId = this._taskService.selectedTaskId();
if (selectedId) {
const currentTaskId = this._taskService.currentTaskId();
if (currentTaskId === selectedId) {
// Already tracking this task - stop tracking
this._taskService.setCurrentId(null);
} else {
// Start tracking the selected task
this._taskService.setCurrentId(selectedId);
}
} else {
// Neither focused nor selected - use global toggle
this._taskService.toggleStartTask();
}
}
ev.preventDefault();
return true;
}
// All other shortcuts require a focused task
if (!focusedTaskId) {
return false;
}
const isShiftOrCtrlPressed = ev.shiftKey || ev.ctrlKey;
// Check if the focused task's context menu is open - if so, skip arrow navigation shortcuts
@ -193,13 +213,6 @@ export class TaskShortcutService {
return true;
}
// Toggle play/pause
if (checkKeyCombo(ev, keys.togglePlay) && this.isTimeTrackingEnabled()) {
this._handleTaskShortcut(focusedTaskId, 'togglePlayPause');
ev.preventDefault();
return true;
}
// Task movement shortcuts
if (checkKeyCombo(ev, keys.moveTaskUp)) {
this._handleTaskShortcut(focusedTaskId, 'moveTaskUp');

View file

@ -83,6 +83,7 @@ export class IndexedDbAdapter implements DatabaseAdapter {
}
async loadAll<A extends Record<string, unknown>>(): Promise<A> {
await this._afterReady();
const data = await this._db.getAll(this._dbMainName as typeof FAKE);
const keys = await this._db.getAllKeys(this._dbMainName as typeof FAKE);

View file

@ -19,7 +19,7 @@ describe('Encryption', () => {
} catch (e) {
// Success
}
});
}, 10000); // 10s timeout for expensive Argon2id operations
describe('Legacy Compatibility', () => {
// Helper to simulate legacy encryption (PBKDF2)

View file

@ -0,0 +1,205 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of } from 'rxjs';
import { PluginHooksEffects } from './plugin-hooks.effects';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { PluginService } from './plugin.service';
import { TaskSharedActions } from '../root-store/meta/task-shared.actions';
import { PlannerActions } from '../features/planner/store/planner.actions';
import { Task, TaskCopy } from '../features/tasks/task.model';
import { PluginHooks } from './plugin-api.model';
import { selectTaskById } from '../features/tasks/store/task.selectors';
describe('PluginHooksEffects', () => {
let effects: PluginHooksEffects;
let actions$: Observable<any>;
let pluginServiceMock: jasmine.SpyObj<PluginService>;
let store: MockStore;
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: undefined,
dueWithTime: undefined,
dueDay: undefined,
reminderId: null,
repeatCfgId: null,
issueId: null,
issueType: null,
issueProviderId: null,
issueWasUpdated: false,
issueLastUpdated: null,
issueTimeTracked: null,
attachments: [],
created: Date.now(),
_showSubTasksMode: 2,
...overrides,
}) as Task;
let mockTask: Task;
beforeEach(() => {
mockTask = createMockTask();
pluginServiceMock = jasmine.createSpyObj('PluginService', ['dispatchHook']);
TestBed.configureTestingModule({
providers: [
PluginHooksEffects,
provideMockActions(() => actions$),
provideMockStore({}),
{ provide: PluginService, useValue: pluginServiceMock },
],
});
effects = TestBed.inject(PluginHooksEffects);
store = TestBed.inject(MockStore);
// Override selector to return our mock task
store.overrideSelector(selectTaskById, mockTask);
});
afterEach(() => {
store.resetSelectors();
});
describe('taskUpdate$', () => {
it('should dispatch TASK_UPDATE hook on updateTask action', (done) => {
const changes = { title: 'Updated Title' };
actions$ = of(
TaskSharedActions.updateTask({
task: { id: mockTask.id, changes },
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes,
}),
);
done();
});
});
it('should dispatch TASK_UPDATE hook on scheduleTaskWithTime action', (done) => {
const dueWithTime = Date.now() + 3600000;
actions$ = of(
TaskSharedActions.scheduleTaskWithTime({
task: mockTask,
dueWithTime,
isMoveToBacklog: false,
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes: { dueWithTime, dueDay: undefined },
}),
);
done();
});
});
it('should dispatch TASK_UPDATE hook on reScheduleTaskWithTime action', (done) => {
const dueWithTime = Date.now() + 7200000;
actions$ = of(
TaskSharedActions.reScheduleTaskWithTime({
task: mockTask,
dueWithTime,
isMoveToBacklog: false,
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes: { dueWithTime, dueDay: undefined },
}),
);
done();
});
});
it('should dispatch TASK_UPDATE hook on unscheduleTask action', (done) => {
actions$ = of(
TaskSharedActions.unscheduleTask({
id: mockTask.id,
reminderId: 'reminder-1',
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes: { dueWithTime: undefined, reminderId: undefined },
}),
);
done();
});
});
it('should dispatch TASK_UPDATE hook on planTaskForDay action', (done) => {
const day = '2024-01-15';
actions$ = of(
PlannerActions.planTaskForDay({
task: mockTask as TaskCopy,
day,
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes: { dueDay: day, dueWithTime: undefined },
}),
);
done();
});
});
it('should dispatch TASK_UPDATE hook on transferTask action', (done) => {
const newDay = '2024-01-16';
actions$ = of(
PlannerActions.transferTask({
task: mockTask as TaskCopy,
prevDay: '2024-01-15',
newDay,
targetIndex: 0,
today: '2024-01-14',
}),
);
effects.taskUpdate$.subscribe(() => {
expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith(
PluginHooks.TASK_UPDATE,
jasmine.objectContaining({
taskId: mockTask.id,
changes: { dueDay: newDay },
}),
);
done();
});
});
});
});

View file

@ -34,6 +34,7 @@ import {
moveTaskUpInTodayList,
} from '../features/work-context/store/work-context-meta.actions';
import { LOCAL_ACTIONS } from '../util/local-actions.token';
import { PlannerActions } from '../features/planner/store/planner.actions';
@Injectable()
export class PluginHooksEffects {
@ -83,23 +84,56 @@ export class PluginHooksEffects {
taskUpdate$ = createEffect(
() =>
this.actions$.pipe(
ofType(TaskSharedActions.updateTask),
switchMap((action) =>
this.store.pipe(
select(selectTaskById, { id: action.task.id as string }),
ofType(
TaskSharedActions.updateTask,
TaskSharedActions.scheduleTaskWithTime,
TaskSharedActions.reScheduleTaskWithTime,
TaskSharedActions.unscheduleTask,
PlannerActions.planTaskForDay,
PlannerActions.transferTask,
),
switchMap((action) => {
// Extract task ID and changes based on action type
let taskId: string;
let changes: Partial<Task>;
if (action.type === TaskSharedActions.updateTask.type) {
taskId = action.task.id as string;
changes = action.task.changes;
} else if (
action.type === TaskSharedActions.scheduleTaskWithTime.type ||
action.type === TaskSharedActions.reScheduleTaskWithTime.type
) {
taskId = action.task.id;
changes = { dueWithTime: action.dueWithTime, dueDay: undefined };
} else if (action.type === TaskSharedActions.unscheduleTask.type) {
taskId = action.id;
changes = { dueWithTime: undefined, reminderId: undefined };
} else if (action.type === PlannerActions.planTaskForDay.type) {
taskId = action.task.id;
changes = { dueDay: action.day, dueWithTime: undefined };
} else if (action.type === PlannerActions.transferTask.type) {
taskId = action.task.id;
changes = { dueDay: action.newDay as string };
} else {
return EMPTY;
}
return this.store.pipe(
select(selectTaskById, { id: taskId }),
take(1),
tap((task: Task | undefined) => {
if (task) {
this.pluginService.dispatchHook(PluginHooks.TASK_UPDATE, {
taskId: task.id,
task,
changes: action.task.changes,
changes,
});
}
}),
map(() => EMPTY),
),
),
);
}),
),
{ dispatch: false },
);

View file

@ -20,6 +20,7 @@ const T = {
CONTEXT_MENU: {
CHANGE_BACKGROUND: 'APP.CONTEXT_MENU.CHANGE_BACKGROUND',
},
SKIP_SYNC_WAIT: 'APP.SKIP_SYNC_WAIT',
UPDATE_MAIN_MODEL: 'APP.UPDATE_MAIN_MODEL',
UPDATE_MAIN_MODEL_NO_UPDATE: 'APP.UPDATE_MAIN_MODEL_NO_UPDATE',
UPDATE_WEB_APP: 'APP.UPDATE_WEB_APP',

View file

@ -1,5 +1,7 @@
import { fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
import { isOnline, isOnline$ } from './is-online';
import { withLatestFrom } from 'rxjs/operators';
import { Subject } from 'rxjs';
describe('isOnline utilities', () => {
describe('isOnline()', () => {
@ -20,15 +22,16 @@ describe('isOnline utilities', () => {
});
describe('isOnline$', () => {
it('should emit a boolean value when subscribed', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
// Note: isOnline$ is a module-level singleton with shareReplay(1).
// The initial value is captured at module load time via startWith(navigator.onLine).
// Tests should use online/offline events to change the cached value.
it('should emit a boolean value when subscribed', fakeAsync(() => {
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// Due to shareReplay(1), a value may already be cached
// After debounce time, we should have a value
tick(1000);
// With startWith, value should be emitted immediately
tick(0);
expect(values.length).toBeGreaterThanOrEqual(1);
expect(typeof values[0]).toBe('boolean');
@ -37,19 +40,18 @@ describe('isOnline utilities', () => {
}));
it('should share the same stream across multiple subscribers (shareReplay)', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values1: boolean[] = [];
const values2: boolean[] = [];
const sub1 = isOnline$.subscribe((v) => values1.push(v));
const sub2 = isOnline$.subscribe((v) => values2.push(v));
tick(1000);
tick(0);
// Both subscribers should receive the same value
expect(values1).toEqual([true]);
expect(values2).toEqual([true]);
// Both subscribers should receive the same cached value
expect(values1.length).toBe(1);
expect(values2.length).toBe(1);
expect(values1[0]).toBe(values2[0]);
sub1.unsubscribe();
sub2.unsubscribe();
@ -57,11 +59,13 @@ describe('isOnline utilities', () => {
}));
it('should debounce rapid state changes', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// Initial value emits immediately via startWith
tick(0);
const initialLength = values.length;
// Simulate rapid online/offline events (faster than debounce time)
window.dispatchEvent(new Event('offline'));
tick(200);
@ -71,32 +75,218 @@ describe('isOnline utilities', () => {
tick(200);
window.dispatchEvent(new Event('online'));
// Still within debounce window, no emissions yet except possibly initial
tick(1000);
// Events are being debounced, so no new emissions yet
expect(values.length).toBe(initialLength);
// After debounce, only the final state should be emitted
// The exact behavior depends on timing, but we verify no rapid flip-flopping
expect(values.length).toBeLessThanOrEqual(2);
// After debounce, at most one new value should emit
tick(1000);
expect(values.length).toBeLessThanOrEqual(initialLength + 1);
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should not emit duplicate values due to distinctUntilChanged', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
tick(1000);
expect(values).toEqual([true]);
tick(0);
const initialValue = values[values.length - 1];
const initialLength = values.length;
// Dispatch online event when already online - should not emit duplicate
// Dispatch event matching current state - should not emit duplicate
if (initialValue) {
window.dispatchEvent(new Event('online'));
} else {
window.dispatchEvent(new Event('offline'));
}
tick(1000);
// No new emission due to distinctUntilChanged
expect(values.length).toBe(initialLength);
sub.unsubscribe();
discardPeriodicTasks();
}));
});
/**
* Tests for the race condition fix (issues #5868, #5877)
*
* The original bug: debounceTime(1000) delayed ALL emissions by 1 second,
* including the initial value. When sync.effects.ts used withLatestFrom(isOnline$)
* and the app initialized faster than 1 second, the observable chain hung forever
* because withLatestFrom requires the other observable to have already emitted.
*
* The fix: startWith(navigator.onLine) provides an immediate initial value,
* ensuring withLatestFrom always has a value to work with.
*/
describe('isOnline$ race condition fix (#5868, #5877)', () => {
it('should emit IMMEDIATELY on subscription (before debounce time)', fakeAsync(() => {
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// CRITICAL: Value must be emitted IMMEDIATELY, not after 1 second
// This is the key fix for the 25-second loading issue
tick(0);
expect(values.length).toBe(1);
expect(typeof values[0]).toBe('boolean');
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should work with withLatestFrom without waiting for debounce (sync.effects.ts pattern)', fakeAsync(() => {
// This simulates the pattern used in sync.effects.ts:
// trigger$.pipe(withLatestFrom(isOnline$))
const trigger$ = new Subject<string>();
const results: Array<[string, boolean]> = [];
const sub = trigger$
.pipe(withLatestFrom(isOnline$))
.subscribe(([trigger, online]) => {
results.push([trigger, online]);
});
// Trigger IMMEDIATELY - before 1 second debounce would have passed
// This is the exact scenario that caused the 25-second hang
tick(0);
trigger$.next('SYNC_INITIAL_TRIGGER');
// CRITICAL: withLatestFrom should have a value immediately
// Without the startWith fix, this would hang forever
expect(results.length).toBe(1);
expect(results[0][0]).toBe('SYNC_INITIAL_TRIGGER');
expect(typeof results[0][1]).toBe('boolean');
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should still debounce subsequent online/offline events (preserves #5738 fix)', fakeAsync(() => {
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// Initial value emits immediately
tick(0);
const initialLength = values.length;
// Rapid offline/online events should be debounced
window.dispatchEvent(new Event('offline'));
tick(100);
window.dispatchEvent(new Event('online'));
tick(1000);
tick(100);
window.dispatchEvent(new Event('offline'));
tick(100);
// Still only one value due to distinctUntilChanged
expect(values).toEqual([true]);
// Only initial value so far - events are being debounced
expect(values.length).toBe(initialLength);
// After debounce time, the final state should emit (if different from current)
tick(1000);
// At most one new value (offline) should have been added
expect(values.length).toBeLessThanOrEqual(initialLength + 1);
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should provide consistent value to multiple withLatestFrom consumers', fakeAsync(() => {
const trigger1$ = new Subject<string>();
const trigger2$ = new Subject<string>();
const results1: Array<[string, boolean]> = [];
const results2: Array<[string, boolean]> = [];
const sub1 = trigger1$
.pipe(withLatestFrom(isOnline$))
.subscribe(([t, o]) => results1.push([t, o]));
const sub2 = trigger2$
.pipe(withLatestFrom(isOnline$))
.subscribe(([t, o]) => results2.push([t, o]));
tick(0);
// Both triggers fire at different times, both should work immediately
trigger1$.next('TRIGGER_1');
tick(50);
trigger2$.next('TRIGGER_2');
// Both should have received results
expect(results1.length).toBe(1);
expect(results2.length).toBe(1);
// Both should have the same online status (from shared cache)
expect(results1[0][1]).toBe(results2[0][1]);
sub1.unsubscribe();
sub2.unsubscribe();
discardPeriodicTasks();
}));
it('should update withLatestFrom consumers when online status changes via events', fakeAsync(() => {
const trigger$ = new Subject<string>();
const results: Array<[string, boolean]> = [];
const sub = trigger$
.pipe(withLatestFrom(isOnline$))
.subscribe(([t, o]) => results.push([t, o]));
tick(0);
trigger$.next('FIRST');
expect(results.length).toBe(1);
// Dispatch offline event and wait for debounce
window.dispatchEvent(new Event('offline'));
tick(1100);
trigger$.next('AFTER_OFFLINE');
expect(results.length).toBe(2);
// Dispatch online event and wait for debounce
window.dispatchEvent(new Event('online'));
tick(1100);
trigger$.next('AFTER_ONLINE');
expect(results.length).toBe(3);
// The key test: online status should have changed between triggers
// (the exact values depend on initial state, but they should reflect the events)
// Most importantly, withLatestFrom continues to work after status changes
expect(typeof results[0][1]).toBe('boolean');
expect(typeof results[1][1]).toBe('boolean');
expect(typeof results[2][1]).toBe('boolean');
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should not hang withLatestFrom when triggered within first second (regression test for #5868)', fakeAsync(() => {
// This is the CRITICAL regression test
// Before the fix: if trigger fired within 1 second, withLatestFrom would hang
// because isOnline$ hadn't emitted due to debounceTime(1000)
const trigger$ = new Subject<string>();
let receivedValue = false;
const sub = trigger$.pipe(withLatestFrom(isOnline$)).subscribe(() => {
receivedValue = true;
});
// Fire trigger at various times within the first second
// All should work immediately thanks to startWith
tick(0);
trigger$.next('at_0ms');
expect(receivedValue).toBe(true);
receivedValue = false;
tick(100);
trigger$.next('at_100ms');
expect(receivedValue).toBe(true);
receivedValue = false;
tick(400);
trigger$.next('at_500ms');
expect(receivedValue).toBe(true);
sub.unsubscribe();
discardPeriodicTasks();

View file

@ -1,16 +1,24 @@
import { fromEvent, merge, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, mapTo, shareReplay } from 'rxjs/operators';
import { fromEvent, merge } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
mapTo,
shareReplay,
startWith,
} from 'rxjs/operators';
export const isOnline = (): boolean => navigator.onLine !== false;
export const isOnline$ = merge(
fromEvent(window, 'offline').pipe(mapTo(false)),
fromEvent(window, 'online').pipe(mapTo(true)),
of(navigator.onLine),
).pipe(
// Debounce to prevent rapid oscillations from triggering repeated banner changes
// This is especially important on Linux/Electron where navigator.onLine can be unreliable
debounceTime(1000),
// startWith provides an immediate initial value, ensuring withLatestFrom(isOnline$)
// in sync.effects.ts doesn't hang waiting for the debounce to complete (fixes #5868, #5877)
startWith(navigator.onLine),
distinctUntilChanged(),
shareReplay(1),
);

View file

@ -16,13 +16,16 @@ describe('playSound', () => {
mockGainNode = {
connect: jasmine.createSpy('connect'),
disconnect: jasmine.createSpy('disconnect'),
gain: { value: 1 },
};
mockBufferSource = {
connect: jasmine.createSpy('connect'),
disconnect: jasmine.createSpy('disconnect'),
start: jasmine.createSpy('start'),
buffer: null,
onended: null as (() => void) | null,
};
mockAudioBuffer = {} as AudioBuffer;
@ -167,4 +170,43 @@ describe('playSound', () => {
}, 10);
}, 10);
});
it('should set onended handler to clean up audio nodes', (done) => {
playSound('test.mp3');
setTimeout(() => {
expect(mockBufferSource.onended).toBeDefined();
expect(typeof mockBufferSource.onended).toBe('function');
done();
}, 10);
});
it('should disconnect source node when onended is called', (done) => {
playSound('test.mp3', 100);
setTimeout(() => {
// Trigger the onended handler
if (mockBufferSource.onended) {
mockBufferSource.onended();
}
expect(mockBufferSource.disconnect).toHaveBeenCalled();
done();
}, 10);
});
it('should disconnect both source and gain nodes when using volume adjustment', (done) => {
playSound('test.mp3', 50);
setTimeout(() => {
// Trigger the onended handler
if (mockBufferSource.onended) {
mockBufferSource.onended();
}
expect(mockBufferSource.disconnect).toHaveBeenCalled();
expect(mockGainNode.disconnect).toHaveBeenCalled();
done();
}, 10);
});
});

View file

@ -18,8 +18,9 @@ export const playSound = (filePath: string, vol = 100): void => {
const source = audioCtx.createBufferSource();
source.buffer = buffer;
let gainNode: GainNode | null = null;
if (vol !== 100) {
const gainNode = audioCtx.createGain();
gainNode = audioCtx.createGain();
gainNode.gain.value = vol / 100;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
@ -27,6 +28,14 @@ export const playSound = (filePath: string, vol = 100): void => {
source.connect(audioCtx.destination);
}
// Clean up audio nodes after playback to prevent memory leaks
source.onended = (): void => {
source.disconnect();
if (gainNode) {
gainNode.disconnect();
}
};
source.start(0);
})
.catch((e) => {

View file

@ -20,6 +20,7 @@
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Change Background"
},
"SKIP_SYNC_WAIT": "Skip waiting for sync",
"UPDATE_MAIN_MODEL": "Super Productivity has gotten a major update! Some migrations for your data are required. Please note that this renders your data incompatible with older versions of the app.",
"UPDATE_MAIN_MODEL_NO_UPDATE": "No model update chosen. Please note that you either have to downgrade to the last version, if you do not want to perform the model upgrade.",
"UPDATE_WEB_APP": "New version available. Load New Version?"

View file

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