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: (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:
commit
3f3f0685eb
43 changed files with 3341 additions and 207 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -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
20
Dockerfile.e2e.dev
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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/*"
|
||||
|
|
|
|||
|
|
@ -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
21
scripts/wait-for-app.sh
Executable 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!"
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,3 +175,7 @@ mat-sidenav {
|
|||
background-position: center !important;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.skip-sync-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
296
src/app/features/focus-mode/store/focus-mode.bug-5875.spec.ts
Normal file
296
src/app/features/focus-mode/store/focus-mode.bug-5875.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
109
src/app/features/issue/providers/calendar/calendar.const.spec.ts
Normal file
109
src/app/features/issue/providers/calendar/calendar.const.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
269
src/app/features/tasks/task-shortcut.service.spec.ts
Normal file
269
src/app/features/tasks/task-shortcut.service.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
205
src/app/plugins/plugin-hooks.effects.spec.ts
Normal file
205
src/app/plugins/plugin-hooks.effects.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue