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: (41 commits) 16.7.2 16.7.1 16.7.0 fix(focus-mode): show start button when break time is up in banner mode fix(focus-mode): hide dismiss button in banner-only mode (#5737) fix(focus-mode): add start button in banner after session completes (#5737) refactor(focus-mode): remove isAlwaysUseFocusMode setting (#5737) feat(focus-mode): add task existence check before resuming tracking (#5737) feat(focus-mode): add icon buttons for banner and sync session with tracking (#5753) fix(repeat): schedule tasks for correct day and remove from Today when needed (#5594) feat(focus-mode): sync duration when Pomodoro settings change (#5753) feat(focus-mode): add new settings and fix pomodoro dialog (#5753) fix(focus-mode): fix pomodoro long break timing and add ticking sound option (#5753) feat(android): add better notifications and permanent notification for focus mode feat(android): add background time tracking via foreground service build(ci): add i18n JSON validation step to lint-and-test workflow docs: add archived tasks viewer to community plugins fix(android): make schedule dialog scrollable on small screens (#5741) fix(focus-mode): preserve existing notes when opening notes panel (#5752) fix(sync): show user-friendly error for Flatpak/Snap permission issues ... # Conflicts: # src/app/features/android/store/android.effects.ts # src/app/features/focus-mode/store/focus-mode.effects.ts # src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts # src/app/features/reminder/reminder.module.ts # src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts
This commit is contained in:
commit
79853eee3f
109 changed files with 7288 additions and 805 deletions
1
.github/workflows/lint-and-test-pr.yml
vendored
1
.github/workflows/lint-and-test-pr.yml
vendored
|
|
@ -48,6 +48,7 @@ jobs:
|
|||
|
||||
- run: npm run env # Generate env.generated.ts from environment variables
|
||||
- run: npm run lint
|
||||
- run: npm run int:test # Validate i18n JSON files
|
||||
- run: npm run test
|
||||
- name: Start WebDAV Server
|
||||
run: |
|
||||
|
|
|
|||
93
CHANGELOG.md
93
CHANGELOG.md
|
|
@ -1,3 +1,96 @@
|
|||
## [16.7.2](https://github.com/johannesjo/super-productivity/compare/v16.6.1...v16.7.2) (2025-12-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add retry for rate limiting ([f965301](https://github.com/johannesjo/super-productivity/commit/f9653010f53616e673b4421a071a2d491980359b))
|
||||
- **android:** make schedule dialog scrollable on small screens ([#5741](https://github.com/johannesjo/super-productivity/issues/5741)) ([b6a7660](https://github.com/johannesjo/super-productivity/commit/b6a7660645e0a91469b198ddd6a97ddd2b58b217))
|
||||
- **docker:** use Debian-based nginx for ARM64 QEMU compatibility ([5ca6434](https://github.com/johannesjo/super-productivity/commit/5ca64347eff2dd24a25d304c06e14b948f43ae93))
|
||||
- **electron:** use includes() instead of in operator for hostname check ([52fd0df](https://github.com/johannesjo/super-productivity/commit/52fd0dfc75b951ad93add7836900c64dc52c18e5))
|
||||
- **focus-mode:** add start button in banner after session completes ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([fa46aa5](https://github.com/johannesjo/super-productivity/commit/fa46aa5c364d325f18afc45b4e1ac92b90a49561))
|
||||
- **focus-mode:** fix pomodoro long break timing and add ticking sound option ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([88405ea](https://github.com/johannesjo/super-productivity/commit/88405eaecb4ffa36bba5da2fd64f3d38264a2eb8))
|
||||
- **focus-mode:** hide dismiss button in banner-only mode ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([4c38486](https://github.com/johannesjo/super-productivity/commit/4c38486eb62cb9f75bb6f54828002c32745f904d))
|
||||
- **focus-mode:** preserve existing notes when opening notes panel ([#5752](https://github.com/johannesjo/super-productivity/issues/5752)) ([2aebc2c](https://github.com/johannesjo/super-productivity/commit/2aebc2c21607eee6720cd48a34486259efd8a06a))
|
||||
- **focus-mode:** show start button when break time is up in banner mode ([97eb781](https://github.com/johannesjo/super-productivity/commit/97eb781c9c71996842ba0e9e772e365d41f4de23))
|
||||
- **i18n:** use correct variable in TASK_CREATED translation ([#5743](https://github.com/johannesjo/super-productivity/issues/5743)) ([68f3c6a](https://github.com/johannesjo/super-productivity/commit/68f3c6a5d9066e98e0068969b70c5ce85e02f6b7))
|
||||
- **linear:** show status name property ([f306adb](https://github.com/johannesjo/super-productivity/commit/f306adb6b7441d68174dc8fb44f36229746fb8b2))
|
||||
- remove deprecated toPromise calls ([de06693](https://github.com/johannesjo/super-productivity/commit/de06693bf619960d8b49e1e82679f1d30a669918))
|
||||
- **repeat:** schedule tasks for correct day and remove from Today when needed ([#5594](https://github.com/johannesjo/super-productivity/issues/5594)) ([9e3159d](https://github.com/johannesjo/super-productivity/commit/9e3159dbb22d0d2ecc97a09b91a2fca417df3aa7))
|
||||
- **sync:** show user-friendly error for Flatpak/Snap permission issues ([495abcb](https://github.com/johannesjo/super-productivity/commit/495abcb4cf87d18d37f775ddbf2d122a60ef8acf)), closes [#4078](https://github.com/johannesjo/super-productivity/issues/4078)
|
||||
- update workspace selection api key ([e05d78f](https://github.com/johannesjo/super-productivity/commit/e05d78f5ffb0f6f9a733ebb311dd271bd99ed718))
|
||||
|
||||
### Features
|
||||
|
||||
- **2356:** add clickup support ([8f79477](https://github.com/johannesjo/super-productivity/commit/8f7947753c4d583d741b7b1598f7db766877d3c0))
|
||||
- Add generic subtask support for issue providers, implement for ClickUp ([4c113cd](https://github.com/johannesjo/super-productivity/commit/4c113cdeb5467065b44d1f35a9aa5e72460a3194))
|
||||
- **android:** add background time tracking via foreground service ([ffa7122](https://github.com/johannesjo/super-productivity/commit/ffa7122aea96be5f69076d890efeee9f486d7ac4))
|
||||
- **android:** add better notifications and permanent notification for focus mode ([f7901ba](https://github.com/johannesjo/super-productivity/commit/f7901ba47f5bd6c27917917a1b9969ea87a94a27))
|
||||
- **focus-mode:** add icon buttons for banner and sync session with tracking ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([4f9c514](https://github.com/johannesjo/super-productivity/commit/4f9c5146e1480ab15c24533a2ba8c93dd45ed024))
|
||||
- **focus-mode:** add new settings and fix pomodoro dialog ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([7b099af](https://github.com/johannesjo/super-productivity/commit/7b099af796de85b8d1015d91aa91d364d75a9e98))
|
||||
- **focus-mode:** add task existence check before resuming tracking ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([7c21b3b](https://github.com/johannesjo/super-productivity/commit/7c21b3bf5cfce1b1f0d8cdcb572beb4f73761f80))
|
||||
- **focus-mode:** sync duration when Pomodoro settings change ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([2bcd4b9](https://github.com/johannesjo/super-productivity/commit/2bcd4b911f1d626c10c6fc3b752da4d2fbbcadbe))
|
||||
- Introduce typia for ClickUp API runtime type validation, refactor ClickUp models, and simplify task title display. ([cb6edd3](https://github.com/johannesjo/super-productivity/commit/cb6edd3debba1f14c3c4b9e646015056bcd9fe99))
|
||||
- refactor ClickUp error logging to use IssueLog ([3ff45cb](https://github.com/johannesjo/super-productivity/commit/3ff45cb7087492394c148188c38aef3235dc71e6))
|
||||
- update ClickUp issue content comments ([e77fdbc](https://github.com/johannesjo/super-productivity/commit/e77fdbc0e8ef0a53247a22d2b260fbabdec2f775))
|
||||
|
||||
## [16.7.1](https://github.com/johannesjo/super-productivity/compare/v16.6.1...v16.7.1) (2025-12-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add retry for rate limiting ([f965301](https://github.com/johannesjo/super-productivity/commit/f9653010f53616e673b4421a071a2d491980359b))
|
||||
- **android:** make schedule dialog scrollable on small screens ([#5741](https://github.com/johannesjo/super-productivity/issues/5741)) ([19e76ac](https://github.com/johannesjo/super-productivity/commit/19e76ac0aa2813623cd07013e3b4de2f11001060))
|
||||
- **docker:** use Debian-based nginx for ARM64 QEMU compatibility ([5ca6434](https://github.com/johannesjo/super-productivity/commit/5ca64347eff2dd24a25d304c06e14b948f43ae93))
|
||||
- **electron:** use includes() instead of in operator for hostname check ([6597e23](https://github.com/johannesjo/super-productivity/commit/6597e233d22a9400a7f522b66f4cdc37b323f6d2))
|
||||
- **focus-mode:** add start button in banner after session completes ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([25de8ee](https://github.com/johannesjo/super-productivity/commit/25de8ee1ff810bd8d3075380986169c65d00c76e))
|
||||
- **focus-mode:** fix pomodoro long break timing and add ticking sound option ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([9fdc1fc](https://github.com/johannesjo/super-productivity/commit/9fdc1fc609efd9adecd765c4b6317440e06265f5))
|
||||
- **focus-mode:** hide dismiss button in banner-only mode ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([c571700](https://github.com/johannesjo/super-productivity/commit/c571700cfa88c00e83123e423faa4130b2210542))
|
||||
- **focus-mode:** preserve existing notes when opening notes panel ([#5752](https://github.com/johannesjo/super-productivity/issues/5752)) ([0a915d9](https://github.com/johannesjo/super-productivity/commit/0a915d901beca1f3d5c29f992831695899062c8e))
|
||||
- **focus-mode:** show start button when break time is up in banner mode ([ee54b4e](https://github.com/johannesjo/super-productivity/commit/ee54b4e11e8006a1cec496b41ac23e328cedb249))
|
||||
- **i18n:** use correct variable in TASK_CREATED translation ([#5743](https://github.com/johannesjo/super-productivity/issues/5743)) ([529f885](https://github.com/johannesjo/super-productivity/commit/529f88517abbc5d36543a859e6852675c36d9015))
|
||||
- **linear:** show status name property ([f306adb](https://github.com/johannesjo/super-productivity/commit/f306adb6b7441d68174dc8fb44f36229746fb8b2))
|
||||
- remove deprecated toPromise calls ([de06693](https://github.com/johannesjo/super-productivity/commit/de06693bf619960d8b49e1e82679f1d30a669918))
|
||||
- **repeat:** schedule tasks for correct day and remove from Today when needed ([#5594](https://github.com/johannesjo/super-productivity/issues/5594)) ([ffc086d](https://github.com/johannesjo/super-productivity/commit/ffc086dd0078003ecbfcc03098ebd0a41871317f))
|
||||
- **sync:** show user-friendly error for Flatpak/Snap permission issues ([196f84b](https://github.com/johannesjo/super-productivity/commit/196f84b40f5698f39b15f2e61bbbc5000d5e74ba)), closes [#4078](https://github.com/johannesjo/super-productivity/issues/4078)
|
||||
- update workspace selection api key ([e05d78f](https://github.com/johannesjo/super-productivity/commit/e05d78f5ffb0f6f9a733ebb311dd271bd99ed718))
|
||||
|
||||
### Features
|
||||
|
||||
- **2356:** add clickup support ([8f79477](https://github.com/johannesjo/super-productivity/commit/8f7947753c4d583d741b7b1598f7db766877d3c0))
|
||||
- Add generic subtask support for issue providers, implement for ClickUp ([4c113cd](https://github.com/johannesjo/super-productivity/commit/4c113cdeb5467065b44d1f35a9aa5e72460a3194))
|
||||
- **android:** add background time tracking via foreground service ([543b63c](https://github.com/johannesjo/super-productivity/commit/543b63c131c78866e83fc3e779fe2ec1ef578726))
|
||||
- **android:** add better notifications and permanent notification for focus mode ([5000f08](https://github.com/johannesjo/super-productivity/commit/5000f081fed7be262dcadd4ff3b751d466677a64))
|
||||
- **focus-mode:** add icon buttons for banner and sync session with tracking ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([b551d07](https://github.com/johannesjo/super-productivity/commit/b551d07895a5119dfe5789bc5aa802f4ee13b32f))
|
||||
- **focus-mode:** add new settings and fix pomodoro dialog ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([bbbb082](https://github.com/johannesjo/super-productivity/commit/bbbb082ea4067fb29da8f4979bccd1a8518f13e8))
|
||||
- **focus-mode:** add task existence check before resuming tracking ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([404df5f](https://github.com/johannesjo/super-productivity/commit/404df5f3132c841b1a98dc20d73c82871bc30c10))
|
||||
- **focus-mode:** sync duration when Pomodoro settings change ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([ed2b4d8](https://github.com/johannesjo/super-productivity/commit/ed2b4d89ee041529e76da93227d4f76791b63485))
|
||||
- Introduce typia for ClickUp API runtime type validation, refactor ClickUp models, and simplify task title display. ([cb6edd3](https://github.com/johannesjo/super-productivity/commit/cb6edd3debba1f14c3c4b9e646015056bcd9fe99))
|
||||
- refactor ClickUp error logging to use IssueLog ([3ff45cb](https://github.com/johannesjo/super-productivity/commit/3ff45cb7087492394c148188c38aef3235dc71e6))
|
||||
- update ClickUp issue content comments ([e77fdbc](https://github.com/johannesjo/super-productivity/commit/e77fdbc0e8ef0a53247a22d2b260fbabdec2f775))
|
||||
|
||||
# [16.7.0](https://github.com/johannesjo/super-productivity/compare/v16.6.1...v16.7.0) (2025-12-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **android:** make schedule dialog scrollable on small screens ([#5741](https://github.com/johannesjo/super-productivity/issues/5741)) ([b6a7660](https://github.com/johannesjo/super-productivity/commit/b6a7660645e0a91469b198ddd6a97ddd2b58b217))
|
||||
- **docker:** use Debian-based nginx for ARM64 QEMU compatibility ([5ca6434](https://github.com/johannesjo/super-productivity/commit/5ca64347eff2dd24a25d304c06e14b948f43ae93))
|
||||
- **electron:** use includes() instead of in operator for hostname check ([52fd0df](https://github.com/johannesjo/super-productivity/commit/52fd0dfc75b951ad93add7836900c64dc52c18e5))
|
||||
- **focus-mode:** add start button in banner after session completes ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([fa46aa5](https://github.com/johannesjo/super-productivity/commit/fa46aa5c364d325f18afc45b4e1ac92b90a49561))
|
||||
- **focus-mode:** fix pomodoro long break timing and add ticking sound option ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([88405ea](https://github.com/johannesjo/super-productivity/commit/88405eaecb4ffa36bba5da2fd64f3d38264a2eb8))
|
||||
- **focus-mode:** hide dismiss button in banner-only mode ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([4c38486](https://github.com/johannesjo/super-productivity/commit/4c38486eb62cb9f75bb6f54828002c32745f904d))
|
||||
- **focus-mode:** preserve existing notes when opening notes panel ([#5752](https://github.com/johannesjo/super-productivity/issues/5752)) ([2aebc2c](https://github.com/johannesjo/super-productivity/commit/2aebc2c21607eee6720cd48a34486259efd8a06a))
|
||||
- **focus-mode:** show start button when break time is up in banner mode ([97eb781](https://github.com/johannesjo/super-productivity/commit/97eb781c9c71996842ba0e9e772e365d41f4de23))
|
||||
- **i18n:** use correct variable in TASK_CREATED translation ([#5743](https://github.com/johannesjo/super-productivity/issues/5743)) ([68f3c6a](https://github.com/johannesjo/super-productivity/commit/68f3c6a5d9066e98e0068969b70c5ce85e02f6b7))
|
||||
- **repeat:** schedule tasks for correct day and remove from Today when needed ([#5594](https://github.com/johannesjo/super-productivity/issues/5594)) ([9e3159d](https://github.com/johannesjo/super-productivity/commit/9e3159dbb22d0d2ecc97a09b91a2fca417df3aa7))
|
||||
- **sync:** show user-friendly error for Flatpak/Snap permission issues ([495abcb](https://github.com/johannesjo/super-productivity/commit/495abcb4cf87d18d37f775ddbf2d122a60ef8acf)), closes [#4078](https://github.com/johannesjo/super-productivity/issues/4078)
|
||||
|
||||
### Features
|
||||
|
||||
- **android:** add background time tracking via foreground service ([ffa7122](https://github.com/johannesjo/super-productivity/commit/ffa7122aea96be5f69076d890efeee9f486d7ac4))
|
||||
- **android:** add better notifications and permanent notification for focus mode ([f7901ba](https://github.com/johannesjo/super-productivity/commit/f7901ba47f5bd6c27917917a1b9969ea87a94a27))
|
||||
- **focus-mode:** add icon buttons for banner and sync session with tracking ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([4f9c514](https://github.com/johannesjo/super-productivity/commit/4f9c5146e1480ab15c24533a2ba8c93dd45ed024))
|
||||
- **focus-mode:** add new settings and fix pomodoro dialog ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([7b099af](https://github.com/johannesjo/super-productivity/commit/7b099af796de85b8d1015d91aa91d364d75a9e98))
|
||||
- **focus-mode:** add task existence check before resuming tracking ([#5737](https://github.com/johannesjo/super-productivity/issues/5737)) ([7c21b3b](https://github.com/johannesjo/super-productivity/commit/7c21b3bf5cfce1b1f0d8cdcb572beb4f73761f80))
|
||||
- **focus-mode:** sync duration when Pomodoro settings change ([#5753](https://github.com/johannesjo/super-productivity/issues/5753)) ([2bcd4b9](https://github.com/johannesjo/super-productivity/commit/2bcd4b911f1d626c10c6fc3b752da4d2fbbcadbe))
|
||||
|
||||
## [16.6.1](https://github.com/johannesjo/super-productivity/compare/v16.6.0...v16.6.1) (2025-12-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
|
|
|||
21
Gemfile.lock
21
Gemfile.lock
|
|
@ -9,24 +9,28 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1001.0)
|
||||
aws-sdk-core (3.211.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1196.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.95.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.169.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
|
|
@ -157,6 +161,7 @@ GEM
|
|||
json (2.7.6)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ android {
|
|||
minSdkVersion 24
|
||||
targetSdkVersion 35
|
||||
compileSdk 35
|
||||
versionCode 16_06_01_0000
|
||||
versionName "16.6.1"
|
||||
versionCode 16_07_02_0000
|
||||
versionName "16.7.2"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
hostName : "app.super-productivity.com",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<application
|
||||
|
|
@ -76,5 +77,35 @@
|
|||
android:name=".webview.WebViewBlockActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<service
|
||||
android:name=".service.TrackingForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="time_tracking" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.FocusModeForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="focus_timer" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.ReminderAlarmReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.ReminderActionReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import androidx.activity.addCallback
|
|||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.superproductivity.superproductivity.plugins.SafBridgePlugin
|
||||
import com.superproductivity.superproductivity.service.FocusModeForegroundService
|
||||
import com.superproductivity.superproductivity.service.TrackingForegroundService
|
||||
import com.superproductivity.superproductivity.util.printWebViewVersion
|
||||
import com.superproductivity.superproductivity.webview.JavaScriptInterface
|
||||
import com.superproductivity.superproductivity.webview.WebHelper
|
||||
|
|
@ -144,6 +146,43 @@ class CapacitorMainActivity : BridgeActivity() {
|
|||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
Log.d("SP_SHARE", "handleIntent action: ${intent.action} type: ${intent.type}")
|
||||
|
||||
// Handle tracking notification actions
|
||||
when (intent.action) {
|
||||
TrackingForegroundService.ACTION_PAUSE -> {
|
||||
Log.d("SP_TRACKING", "Pause action received from notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onPauseTracking$")
|
||||
return
|
||||
}
|
||||
TrackingForegroundService.ACTION_DONE -> {
|
||||
Log.d("SP_TRACKING", "Done action received from notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onMarkTaskDone$")
|
||||
return
|
||||
}
|
||||
// Handle focus mode notification actions
|
||||
FocusModeForegroundService.ACTION_PAUSE -> {
|
||||
Log.d("SP_FOCUS", "Pause action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusPause$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_RESUME -> {
|
||||
Log.d("SP_FOCUS", "Resume action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusResume$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_SKIP -> {
|
||||
Log.d("SP_FOCUS", "Skip action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusSkip$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_COMPLETE -> {
|
||||
Log.d("SP_FOCUS", "Complete action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusComplete$")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle share intent
|
||||
if (Intent.ACTION_SEND == intent.action && intent.type != null) {
|
||||
if (intent.type?.startsWith("text/") == true) {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package com.superproductivity.superproductivity.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
|
||||
/**
|
||||
* Handles reminder snooze action in the background by simply rescheduling the alarm.
|
||||
* No app involvement needed - just dismiss notification and schedule new alarm.
|
||||
*/
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReminderActionReceiver"
|
||||
const val ACTION_SNOOZE = "com.superproductivity.REMINDER_SNOOZE"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
const val EXTRA_REMINDER_ID = "reminder_id"
|
||||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
|
||||
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_SNOOZE) return
|
||||
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
|
||||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
|
||||
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
|
||||
|
||||
// Dismiss the notification
|
||||
if (notificationId != -1) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
// Reschedule alarm for 10 minutes from now
|
||||
val newTriggerTime = System.currentTimeMillis() + SNOOZE_DURATION_MS
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
context,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
newTriggerTime
|
||||
)
|
||||
|
||||
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.superproductivity.superproductivity.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
|
||||
/**
|
||||
* Receives alarm broadcasts and shows reminder notifications.
|
||||
*/
|
||||
class ReminderAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReminderAlarmReceiver"
|
||||
const val ACTION_SHOW_REMINDER = "com.superproductivity.ACTION_SHOW_REMINDER"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
const val EXTRA_REMINDER_ID = "reminder_id"
|
||||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_SHOW_REMINDER) return
|
||||
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
|
||||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
|
||||
|
||||
ReminderNotificationHelper.showNotification(
|
||||
context,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
class FocusModeForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "FocusModeService"
|
||||
|
||||
const val ACTION_START = "com.superproductivity.ACTION_START_FOCUS"
|
||||
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_FOCUS"
|
||||
const val ACTION_UPDATE = "com.superproductivity.ACTION_UPDATE_FOCUS"
|
||||
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_FOCUS"
|
||||
const val ACTION_RESUME = "com.superproductivity.ACTION_RESUME_FOCUS"
|
||||
const val ACTION_SKIP = "com.superproductivity.ACTION_SKIP_FOCUS"
|
||||
const val ACTION_COMPLETE = "com.superproductivity.ACTION_COMPLETE_FOCUS"
|
||||
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_TASK_TITLE = "task_title"
|
||||
const val EXTRA_DURATION_MS = "duration_ms"
|
||||
const val EXTRA_REMAINING_MS = "remaining_ms"
|
||||
const val EXTRA_IS_BREAK = "is_break"
|
||||
const val EXTRA_IS_PAUSED = "is_paused"
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
}
|
||||
|
||||
private var title: String = ""
|
||||
private var taskTitle: String? = null
|
||||
private var durationMs: Long = 0
|
||||
private var remainingMs: Long = 0
|
||||
private var isBreak: Boolean = false
|
||||
private var isPaused: Boolean = false
|
||||
private var lastUpdateTimestamp: Long = 0
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isRunning && !isPaused) {
|
||||
// Update remaining time (countdown mode)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastUpdateTimestamp
|
||||
lastUpdateTimestamp = now
|
||||
|
||||
if (durationMs > 0) {
|
||||
// Countdown mode: decrease remaining time
|
||||
remainingMs = (remainingMs - elapsed).coerceAtLeast(0)
|
||||
} else {
|
||||
// Flowtime mode: increase elapsed time (remainingMs is actually elapsed)
|
||||
remainingMs += elapsed
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
FocusModeNotificationHelper.createChannel(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand: action=${intent?.action}")
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
title = intent.getStringExtra(EXTRA_TITLE) ?: "Focus"
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE)
|
||||
durationMs = intent.getLongExtra(EXTRA_DURATION_MS, 0L)
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, 0L)
|
||||
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, false)
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false)
|
||||
|
||||
startFocusMode()
|
||||
}
|
||||
|
||||
ACTION_UPDATE -> {
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs)
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused)
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
ACTION_STOP -> {
|
||||
stopFocusMode()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Service restarted by system - we have no state to restore
|
||||
Log.d(TAG, "Service started without action, stopping")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startFocusMode() {
|
||||
Log.d(TAG, "Starting focus mode: title=$title, durationMs=$durationMs, remainingMs=$remainingMs, isBreak=$isBreak, isPaused=$isPaused")
|
||||
|
||||
isRunning = true
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Start foreground immediately to avoid ANR
|
||||
val notification = FocusModeNotificationHelper.buildNotification(
|
||||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
)
|
||||
startForeground(FocusModeNotificationHelper.NOTIFICATION_ID, notification)
|
||||
|
||||
// Start update loop if not paused
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
if (!isPaused) {
|
||||
handler.post(updateRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFocusMode() {
|
||||
Log.d(TAG, "Stopping focus mode")
|
||||
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
if (!isRunning) return
|
||||
|
||||
try {
|
||||
val notification = FocusModeNotificationHelper.buildNotification(
|
||||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
)
|
||||
NotificationManagerCompat.from(this).notify(
|
||||
FocusModeNotificationHelper.NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "No permission to post notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d(TAG, "Service destroyed")
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(TAG, "Task removed, stopping service")
|
||||
stopFocusMode()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.superproductivity.superproductivity.CapacitorMainActivity
|
||||
import com.superproductivity.superproductivity.R
|
||||
|
||||
object FocusModeNotificationHelper {
|
||||
const val CHANNEL_ID = "sp_focus_mode_channel"
|
||||
const val NOTIFICATION_ID = 1002
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Focus Mode",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows focus mode timer status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildNotification(
|
||||
context: Context,
|
||||
title: String,
|
||||
taskTitle: String?,
|
||||
remainingMs: Long,
|
||||
isPaused: Boolean,
|
||||
isBreak: Boolean
|
||||
): Notification {
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
10,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(buildTitle(title, taskTitle))
|
||||
.setContentText(formatDuration(remainingMs))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
// Add Pause/Resume action
|
||||
if (isPaused) {
|
||||
val resumeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_RESUME
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val resumePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
11,
|
||||
resumeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Resume", resumePendingIntent)
|
||||
} else {
|
||||
val pauseIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_PAUSE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pausePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
12,
|
||||
pauseIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Pause", pausePendingIntent)
|
||||
}
|
||||
|
||||
// Add Skip (for breaks) or Complete (for work sessions) action
|
||||
if (isBreak) {
|
||||
val skipIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_SKIP
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val skipPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
13,
|
||||
skipIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Skip", skipPendingIntent)
|
||||
} else {
|
||||
val completeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_COMPLETE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val completePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
14,
|
||||
completeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Complete", completePendingIntent)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildTitle(focusTitle: String, taskTitle: String?): String {
|
||||
return if (taskTitle.isNullOrBlank()) {
|
||||
focusTitle
|
||||
} else {
|
||||
"$focusTitle: $taskTitle"
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = ms / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return String.format("%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.superproductivity.superproductivity.CapacitorMainActivity
|
||||
import com.superproductivity.superproductivity.R
|
||||
import com.superproductivity.superproductivity.receiver.ReminderActionReceiver
|
||||
import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
|
||||
|
||||
/**
|
||||
* Simple helper for native reminder notifications.
|
||||
* Snooze works entirely in background (just reschedules alarm).
|
||||
* Tapping notification opens app.
|
||||
*/
|
||||
object ReminderNotificationHelper {
|
||||
const val TAG = "ReminderNotifHelper"
|
||||
const val CHANNEL_ID = "sp_reminders_channel"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Task and note reminders"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminder(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
) {
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
|
||||
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
val intent = Intent(context, ReminderAlarmReceiver::class.java).apply {
|
||||
action = ReminderAlarmReceiver.ACTION_SHOW_REMINDER
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_ID, reminderId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
|
||||
} else {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule reminder", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelReminder(context: Context, notificationId: Int) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, ReminderAlarmReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
fun showNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String
|
||||
) {
|
||||
createChannel(context)
|
||||
|
||||
// Tapping notification opens app
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context, notificationId, contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// Snooze - handled by BroadcastReceiver, no app needed
|
||||
val snoozeIntent = Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
action = ReminderActionReceiver.ACTION_SNOOZE
|
||||
putExtra(ReminderActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_ID, reminderId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId * 10 + 1, snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(title)
|
||||
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, "Snooze 10m", snoozePendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No permission to show notification", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
class TrackingForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "TrackingService"
|
||||
|
||||
const val ACTION_START = "com.superproductivity.ACTION_START_TRACKING"
|
||||
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_TRACKING"
|
||||
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_TRACKING"
|
||||
const val ACTION_DONE = "com.superproductivity.ACTION_MARK_DONE"
|
||||
const val ACTION_GET_ELAPSED = "com.superproductivity.ACTION_GET_ELAPSED"
|
||||
|
||||
const val EXTRA_TASK_ID = "task_id"
|
||||
const val EXTRA_TASK_TITLE = "task_title"
|
||||
const val EXTRA_TIME_SPENT = "time_spent_ms"
|
||||
|
||||
// Static state accessible from JavaScriptInterface
|
||||
@Volatile
|
||||
var currentTaskId: String? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var startTimestamp: Long = 0
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var accumulatedMs: Long = 0
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var isTracking: Boolean = false
|
||||
private set
|
||||
|
||||
fun getElapsedMs(): Long {
|
||||
return if (isTracking && startTimestamp > 0) {
|
||||
(System.currentTimeMillis() - startTimestamp) + accumulatedMs
|
||||
} else {
|
||||
accumulatedMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var taskTitle: String = ""
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isTracking) {
|
||||
updateNotification()
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
TrackingNotificationHelper.createChannel(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand: action=${intent?.action}")
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val taskId = intent.getStringExtra(EXTRA_TASK_ID) ?: return START_NOT_STICKY
|
||||
val title = intent.getStringExtra(EXTRA_TASK_TITLE) ?: "Task"
|
||||
val timeSpentMs = intent.getLongExtra(EXTRA_TIME_SPENT, 0L)
|
||||
|
||||
startTracking(taskId, title, timeSpentMs)
|
||||
}
|
||||
|
||||
ACTION_STOP -> {
|
||||
stopTracking()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Service restarted by system - we have no state to restore
|
||||
Log.d(TAG, "Service started without action, stopping")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startTracking(taskId: String, title: String, timeSpentMs: Long) {
|
||||
Log.d(TAG, "Starting tracking: taskId=$taskId, title=$title, timeSpentMs=$timeSpentMs")
|
||||
|
||||
currentTaskId = taskId
|
||||
taskTitle = title
|
||||
accumulatedMs = timeSpentMs
|
||||
startTimestamp = System.currentTimeMillis()
|
||||
isTracking = true
|
||||
|
||||
// Start foreground immediately to avoid ANR
|
||||
val notification = TrackingNotificationHelper.buildNotification(
|
||||
this,
|
||||
taskTitle,
|
||||
getElapsedMs()
|
||||
)
|
||||
startForeground(TrackingNotificationHelper.NOTIFICATION_ID, notification)
|
||||
|
||||
// Start update loop
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.post(updateRunnable)
|
||||
}
|
||||
|
||||
private fun stopTracking() {
|
||||
Log.d(TAG, "Stopping tracking, elapsed=${getElapsedMs()}ms")
|
||||
|
||||
isTracking = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
|
||||
// Reset state
|
||||
currentTaskId = null
|
||||
startTimestamp = 0
|
||||
accumulatedMs = 0
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
if (!isTracking) return
|
||||
|
||||
try {
|
||||
val notification = TrackingNotificationHelper.buildNotification(
|
||||
this,
|
||||
taskTitle,
|
||||
getElapsedMs()
|
||||
)
|
||||
NotificationManagerCompat.from(this).notify(
|
||||
TrackingNotificationHelper.NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "No permission to post notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d(TAG, "Service destroyed")
|
||||
isTracking = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(TAG, "Task removed, stopping service")
|
||||
stopTracking()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.superproductivity.superproductivity.CapacitorMainActivity
|
||||
import com.superproductivity.superproductivity.R
|
||||
|
||||
object TrackingNotificationHelper {
|
||||
const val CHANNEL_ID = "sp_time_tracking_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Time Tracking",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows active time tracking status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildNotification(
|
||||
context: Context,
|
||||
taskTitle: String,
|
||||
elapsedMs: Long
|
||||
): Notification {
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val pauseIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = TrackingForegroundService.ACTION_PAUSE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pausePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
1,
|
||||
pauseIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val doneIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = TrackingForegroundService.ACTION_DONE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val donePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
2,
|
||||
doneIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(taskTitle)
|
||||
.setContentText(formatDuration(elapsedMs))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Pause", pausePendingIntent)
|
||||
.addAction(0, "Done", donePendingIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = ms / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
return when {
|
||||
hours > 0 -> String.format("%dh %dm %ds", hours, minutes, seconds)
|
||||
minutes > 0 -> String.format("%dm %ds", minutes, seconds)
|
||||
else -> String.format("%ds", seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
package com.superproductivity.superproductivity.webview
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.superproductivity.superproductivity.App
|
||||
import com.superproductivity.superproductivity.BuildConfig
|
||||
import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY
|
||||
import com.superproductivity.superproductivity.app.LaunchDecider
|
||||
import com.superproductivity.superproductivity.service.FocusModeForegroundService
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
import com.superproductivity.superproductivity.service.TrackingForegroundService
|
||||
|
||||
|
||||
class JavaScriptInterface(
|
||||
|
|
@ -71,6 +76,109 @@ class JavaScriptInterface(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun startTrackingService(taskId: String, taskTitle: String, timeSpentMs: Long) {
|
||||
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
|
||||
action = TrackingForegroundService.ACTION_START
|
||||
putExtra(TrackingForegroundService.EXTRA_TASK_ID, taskId)
|
||||
putExtra(TrackingForegroundService.EXTRA_TASK_TITLE, taskTitle)
|
||||
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
|
||||
}
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun stopTrackingService() {
|
||||
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
|
||||
action = TrackingForegroundService.ACTION_STOP
|
||||
}
|
||||
activity.startService(intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun getTrackingElapsed(): String {
|
||||
val taskId = TrackingForegroundService.currentTaskId
|
||||
val elapsedMs = TrackingForegroundService.getElapsedMs()
|
||||
val isTracking = TrackingForegroundService.isTracking
|
||||
return if (isTracking && taskId != null) {
|
||||
"""{"taskId":"$taskId","elapsedMs":$elapsedMs}"""
|
||||
} else {
|
||||
"null"
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun startFocusModeService(
|
||||
title: String,
|
||||
durationMs: Long,
|
||||
remainingMs: Long,
|
||||
isBreak: Boolean,
|
||||
isPaused: Boolean,
|
||||
taskTitle: String?
|
||||
) {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_START
|
||||
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
|
||||
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
|
||||
putExtra(FocusModeForegroundService.EXTRA_DURATION_MS, durationMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
|
||||
}
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun stopFocusModeService() {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_STOP
|
||||
}
|
||||
activity.startService(intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun updateFocusModeService(remainingMs: Long, isPaused: Boolean, taskTitle: String?) {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_UPDATE
|
||||
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
|
||||
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
|
||||
}
|
||||
activity.startService(intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun scheduleNativeReminder(
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
) {
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
activity,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
triggerAtMs
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun cancelNativeReminder(notificationId: Int) {
|
||||
ReminderNotificationHelper.cancelReminder(activity, notificationId)
|
||||
}
|
||||
|
||||
fun callJavaScriptFunction(script: String) {
|
||||
webView.post { webView.evaluateJavascript(script) { } }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** make schedule dialog scrollable on small screens (#5741)
|
||||
* **docker:** use Debian-based nginx for ARM64 QEMU compatibility
|
||||
* **electron:** use includes() instead of in operator for hostname check
|
||||
* **focus-mode:** add start button in banner after session completes (#5737)
|
||||
* **focus-mode:** fix pomodoro long break timing and add ticking sound option (#5753)
|
||||
* **focus-mode:** hide dismiss button in banner-only mode (#5737)
|
||||
* **focus-mode:** preserve existing notes when opening notes panel (#5752)
|
||||
* **focus-mode:** show start button when break time is up in banner mode
|
||||
* **i18n:** use correct variable in TASK_CREATED translation (#5743)
|
||||
* **repeat:** schedule tasks for correct day and remove from Today when needed (#5594)
|
||||
* **sync:** show user-friendly error for Flatpak/Snap permission issues (495abcb), closes #4078
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **android:** add background time tracking via foreground service
|
||||
* **android:** add better notifications and permanent notification for focus mode
|
||||
* **focus-mode:** add icon buttons for banner and sync session with tracking (#5753)
|
||||
* **focus-mode:** add new settings and fix pomodoro dialog (#5753)
|
||||
* **focus-mode:** add task existence check before resuming tracking (#5737)
|
||||
* **focus-mode:** sync duration when Pomodoro settings change (#5753)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry for rate limiting
|
||||
* **android:** make schedule dialog scrollable on small screens (#5741)
|
||||
* **docker:** use Debian-based nginx for ARM64 QEMU compatibility
|
||||
* **electron:** use includes() instead of in operator for hostname check
|
||||
* **focus-mode:** add start button in banner after session completes (#5737)
|
||||
* **focus-mode:** fix pomodoro long break timing and add ticking sound option (#5753)
|
||||
* **focus-mode:** hide dismiss button in banner-only mode (#5737)
|
||||
* **focus-mode:** preserve existing notes when opening notes panel (#5752)
|
||||
* **focus-mode:** show start button when break time is up in banner mode
|
||||
* **i18n:** use correct variable in TASK_CREATED translation (#5743)
|
||||
* **linear:** show status name property
|
||||
* remove deprecated toPromise calls
|
||||
* **repeat:** schedule tasks for correct day and remove from Today when needed (#5594)
|
||||
* **sync:** show user-friendly error for Flatpak/Snap permission issues (196f84b), closes #4078
|
||||
* update workspace selection api key
|
||||
### Features
|
||||
|
||||
* **2356:** add clickup support
|
||||
* Add generic subtask support for issue providers, implement for ClickUp
|
||||
* **android:** add background time tracking via foreground service
|
||||
* **android:** add better notifications and permanent notification for focus mode
|
||||
* **focus-mode:** add icon buttons for banner and sync session with tracking (#5753)
|
||||
* **focus-mode:** add new settings and fix pomodoro dialog (#5753)
|
||||
* **focus-mode:** add task existence check before resuming tracking (#5737)
|
||||
* **focus-mode:** sync duration when Pomodoro settings change (#5753)
|
||||
* Introduce typia for ClickUp API runtime type validation, refactor ClickUp models, and simplify task title display.
|
||||
* refactor ClickUp error logging to use IssueLog
|
||||
* update ClickUp issue content comments
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry for rate limiting
|
||||
* **android:** make schedule dialog scrollable on small screens (#5741)
|
||||
* **docker:** use Debian-based nginx for ARM64 QEMU compatibility
|
||||
* **electron:** use includes() instead of in operator for hostname check
|
||||
* **focus-mode:** add start button in banner after session completes (#5737)
|
||||
* **focus-mode:** fix pomodoro long break timing and add ticking sound option (#5753)
|
||||
* **focus-mode:** hide dismiss button in banner-only mode (#5737)
|
||||
* **focus-mode:** preserve existing notes when opening notes panel (#5752)
|
||||
* **focus-mode:** show start button when break time is up in banner mode
|
||||
* **i18n:** use correct variable in TASK_CREATED translation (#5743)
|
||||
* **linear:** show status name property
|
||||
* remove deprecated toPromise calls
|
||||
* **repeat:** schedule tasks for correct day and remove from Today when needed (#5594)
|
||||
* **sync:** show user-friendly error for Flatpak/Snap permission issues (495abcb), closes #4078
|
||||
* update workspace selection api key
|
||||
### Features
|
||||
|
||||
* **2356:** add clickup support
|
||||
* Add generic subtask support for issue providers, implement for ClickUp
|
||||
* **android:** add background time tracking via foreground service
|
||||
* **android:** add better notifications and permanent notification for focus mode
|
||||
* **focus-mode:** add icon buttons for banner and sync session with tracking (#5753)
|
||||
* **focus-mode:** add new settings and fix pomodoro dialog (#5753)
|
||||
* **focus-mode:** add task existence check before resuming tracking (#5737)
|
||||
* **focus-mode:** sync duration when Pomodoro settings change (#5753)
|
||||
* Introduce typia for ClickUp API runtime type validation, refactor ClickUp models, and simplify task title display.
|
||||
* refactor ClickUp error logging to use IssueLog
|
||||
* update ClickUp issue content comments
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "superProductivity",
|
||||
"version": "16.6.1",
|
||||
"version": "16.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "superProductivity",
|
||||
"version": "16.6.1",
|
||||
"version": "16.7.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "superProductivity",
|
||||
"version": "16.6.1",
|
||||
"version": "16.7.2",
|
||||
"description": "ToDo list and Time Tracking",
|
||||
"keywords": [
|
||||
"ToDo",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const BANNER_SORT_PRIO_MAP = {
|
|||
export interface BannerAction {
|
||||
label: string;
|
||||
fn: () => void;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface Banner {
|
||||
|
|
|
|||
|
|
@ -49,34 +49,70 @@
|
|||
</button>
|
||||
}
|
||||
@if (banner.action) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
@if (banner.action.icon) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action)"
|
||||
[attr.aria-label]="banner.action.label | translate: banner.translateParams"
|
||||
[title]="banner.action.label | translate: banner.translateParams"
|
||||
mat-icon-button
|
||||
tabindex="1"
|
||||
>
|
||||
<mat-icon>{{ banner.action.icon }}</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (banner.action2) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action2)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action2.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
@if (banner.action2.icon) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action2)"
|
||||
[attr.aria-label]="banner.action2.label | translate: banner.translateParams"
|
||||
[title]="banner.action2.label | translate: banner.translateParams"
|
||||
mat-icon-button
|
||||
tabindex="1"
|
||||
>
|
||||
<mat-icon>{{ banner.action2.icon }}</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action2)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action2.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (banner.action3) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action3)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action3.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
@if (banner.action3.icon) {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action3)"
|
||||
[attr.aria-label]="banner.action3.label | translate: banner.translateParams"
|
||||
[title]="banner.action3.label | translate: banner.translateParams"
|
||||
mat-icon-button
|
||||
tabindex="1"
|
||||
>
|
||||
<mat-icon>{{ banner.action3.icon }}</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="action(banner.id, banner.action3)"
|
||||
color="primary"
|
||||
mat-button
|
||||
tabindex="1"
|
||||
>
|
||||
{{ banner.action3.label | translate: banner.translateParams }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { bannerAnimation } from './banner.ani';
|
|||
import { fadeAnimation } from '../../../ui/animations/fade.ani';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MsToMinuteClockStringPipe } from '../../../ui/duration/ms-to-minute-clock-string.pipe';
|
||||
|
|
@ -23,6 +23,7 @@ import { MsToMinuteClockStringPipe } from '../../../ui/duration/ms-to-minute-clo
|
|||
MatProgressBar,
|
||||
MatIcon,
|
||||
MatButton,
|
||||
MatIconButton,
|
||||
AsyncPipe,
|
||||
TranslatePipe,
|
||||
MsToMinuteClockStringPipe,
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export class GlobalThemeService {
|
|||
['gitea', 'assets/icons/gitea.svg'],
|
||||
['redmine', 'assets/icons/redmine.svg'],
|
||||
['linear', 'assets/icons/linear.svg'],
|
||||
['clickup', 'assets/icons/clickup.svg'],
|
||||
// trello icon
|
||||
['trello', 'assets/icons/trello.svg'],
|
||||
['calendar', 'assets/icons/calendar.svg'],
|
||||
|
|
|
|||
|
|
@ -38,6 +38,38 @@ export interface AndroidInterface {
|
|||
|
||||
triggerGetShareData?(): void;
|
||||
|
||||
// Foreground service methods for background time tracking
|
||||
startTrackingService?(taskId: string, taskTitle: string, timeSpentMs: number): void;
|
||||
stopTrackingService?(): void;
|
||||
getTrackingElapsed?(): string;
|
||||
|
||||
// Foreground service methods for focus mode timer
|
||||
startFocusModeService?(
|
||||
title: string,
|
||||
durationMs: number,
|
||||
remainingMs: number,
|
||||
isBreak: boolean,
|
||||
isPaused: boolean,
|
||||
taskTitle: string | null,
|
||||
): void;
|
||||
stopFocusModeService?(): void;
|
||||
updateFocusModeService?(
|
||||
remainingMs: number,
|
||||
isPaused: boolean,
|
||||
taskTitle: string | null,
|
||||
): void;
|
||||
|
||||
// Native reminder scheduling (snooze handled entirely in background)
|
||||
scheduleNativeReminder?(
|
||||
notificationId: number,
|
||||
reminderId: string,
|
||||
relatedId: string,
|
||||
title: string,
|
||||
reminderType: string,
|
||||
triggerAtMs: number,
|
||||
): void;
|
||||
cancelNativeReminder?(notificationId: number): void;
|
||||
|
||||
// added here only
|
||||
onResume$: Subject<void>;
|
||||
onPause$: Subject<void>;
|
||||
|
|
@ -50,9 +82,15 @@ export interface AndroidInterface {
|
|||
path: string;
|
||||
}>;
|
||||
|
||||
// onPauseCurrentTask$: Subject<void>;
|
||||
// onMarkCurrentTaskAsDone$: Subject<void>;
|
||||
// onAddNewTask$: Subject<void>;
|
||||
// Notification action callbacks
|
||||
onPauseTracking$: Subject<void>;
|
||||
onMarkTaskDone$: Subject<void>;
|
||||
|
||||
// Focus mode notification action callbacks
|
||||
onFocusPause$: Subject<void>;
|
||||
onFocusResume$: Subject<void>;
|
||||
onFocusSkip$: Subject<void>;
|
||||
onFocusComplete$: Subject<void>;
|
||||
}
|
||||
|
||||
// setInterval(() => {
|
||||
|
|
@ -68,9 +106,12 @@ if (IS_ANDROID_WEB_VIEW) {
|
|||
|
||||
androidInterface.onResume$ = new Subject();
|
||||
androidInterface.onPause$ = new Subject();
|
||||
// androidInterface.onPauseCurrentTask$ = new Subject();
|
||||
// androidInterface.onMarkCurrentTaskAsDone$ = new Subject();
|
||||
// androidInterface.onAddNewTask$ = new Subject();
|
||||
androidInterface.onPauseTracking$ = new Subject();
|
||||
androidInterface.onMarkTaskDone$ = new Subject();
|
||||
androidInterface.onFocusPause$ = new Subject();
|
||||
androidInterface.onFocusResume$ = new Subject();
|
||||
androidInterface.onFocusSkip$ = new Subject();
|
||||
androidInterface.onFocusComplete$ = new Subject();
|
||||
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
|
||||
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
|
||||
|
||||
|
|
|
|||
198
src/app/features/android/store/android-focus-mode.effects.ts
Normal file
198
src/app/features/android/store/android-focus-mode.effects.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { map, pairwise, startWith, tap, withLatestFrom } from 'rxjs/operators';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { androidInterface } from '../android-interface';
|
||||
import {
|
||||
selectIsBreakActive,
|
||||
selectIsLongBreak,
|
||||
selectMode,
|
||||
selectTimeRemaining,
|
||||
selectTimer,
|
||||
} from '../../focus-mode/store/focus-mode.selectors';
|
||||
import * as focusModeActions from '../../focus-mode/store/focus-mode.actions';
|
||||
import { selectCurrentTask, selectCurrentTaskId } from '../../tasks/store/task.selectors';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { FocusModeMode, TimerState } from '../../focus-mode/focus-mode.model';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
|
||||
@Injectable()
|
||||
export class AndroidFocusModeEffects {
|
||||
private _store = inject(Store);
|
||||
|
||||
// Start/stop focus mode notification when timer state changes
|
||||
syncFocusModeToNotification$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
combineLatest([
|
||||
this._store.select(selectTimer),
|
||||
this._store.select(selectMode),
|
||||
this._store.select(selectCurrentTask),
|
||||
this._store.select(selectIsBreakActive),
|
||||
this._store.select(selectIsLongBreak),
|
||||
this._store.select(selectTimeRemaining),
|
||||
]).pipe(
|
||||
map(
|
||||
([timer, mode, currentTask, isBreakActive, isLongBreak, timeRemaining]) => ({
|
||||
timer,
|
||||
mode,
|
||||
currentTask,
|
||||
isBreakActive,
|
||||
isLongBreak,
|
||||
timeRemaining,
|
||||
}),
|
||||
),
|
||||
startWith(null),
|
||||
pairwise(),
|
||||
tap(([prev, curr]) => {
|
||||
if (!curr) return;
|
||||
|
||||
const {
|
||||
timer,
|
||||
mode,
|
||||
currentTask,
|
||||
isBreakActive,
|
||||
isLongBreak,
|
||||
timeRemaining,
|
||||
} = curr;
|
||||
const taskTitle = currentTask?.title || null;
|
||||
|
||||
// Check if focus mode is active (has a purpose)
|
||||
const isFocusModeActive = timer.purpose !== null;
|
||||
const wasFocusModeActive = prev?.timer?.purpose !== null;
|
||||
|
||||
if (isFocusModeActive) {
|
||||
const title = this._getNotificationTitle(mode, isBreakActive, isLongBreak);
|
||||
const remainingMs = timer.duration > 0 ? timeRemaining : timer.elapsed; // Flowtime shows elapsed
|
||||
|
||||
// Start service if just became active, otherwise update
|
||||
if (!wasFocusModeActive) {
|
||||
DroidLog.log('AndroidFocusModeEffects: Starting focus mode service', {
|
||||
title,
|
||||
duration: timer.duration,
|
||||
remaining: remainingMs,
|
||||
isBreak: isBreakActive,
|
||||
isPaused: !timer.isRunning,
|
||||
});
|
||||
androidInterface.startFocusModeService?.(
|
||||
title,
|
||||
timer.duration,
|
||||
remainingMs,
|
||||
isBreakActive,
|
||||
!timer.isRunning,
|
||||
taskTitle,
|
||||
);
|
||||
} else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) {
|
||||
// Only update if something significant changed
|
||||
DroidLog.log('AndroidFocusModeEffects: Updating focus mode service', {
|
||||
remaining: remainingMs,
|
||||
isPaused: !timer.isRunning,
|
||||
});
|
||||
androidInterface.updateFocusModeService?.(
|
||||
remainingMs,
|
||||
!timer.isRunning,
|
||||
taskTitle,
|
||||
);
|
||||
}
|
||||
} else if (wasFocusModeActive && !isFocusModeActive) {
|
||||
// Focus mode ended, stop the service
|
||||
DroidLog.log('AndroidFocusModeEffects: Stopping focus mode service');
|
||||
androidInterface.stopFocusModeService?.();
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Handle notification action callbacks
|
||||
handleFocusPause$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusPause$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Pause action received')),
|
||||
withLatestFrom(this._store.select(selectCurrentTaskId)),
|
||||
map(([_, currentTaskId]) =>
|
||||
focusModeActions.pauseFocusSession({ pausedTaskId: currentTaskId }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusResume$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusResume$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Resume action received')),
|
||||
map(() => focusModeActions.unPauseFocusSession()),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusSkip$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusSkip$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Skip action received')),
|
||||
map(() => focusModeActions.skipBreak()),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusComplete$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusComplete$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Complete action received')),
|
||||
map(() => focusModeActions.completeFocusSession({ isManual: true })),
|
||||
),
|
||||
);
|
||||
|
||||
private _getNotificationTitle(
|
||||
mode: FocusModeMode,
|
||||
isBreak: boolean,
|
||||
isLongBreak: boolean,
|
||||
): string {
|
||||
if (isBreak) {
|
||||
return isLongBreak ? 'Long Break' : 'Break';
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'Pomodoro':
|
||||
return 'Pomodoro';
|
||||
case 'Flowtime':
|
||||
return 'Flow';
|
||||
case 'Countdown':
|
||||
return 'Focus';
|
||||
default:
|
||||
return 'Focus';
|
||||
}
|
||||
}
|
||||
|
||||
private _hasStateChanged(
|
||||
prevTimer: TimerState | undefined,
|
||||
currTimer: TimerState,
|
||||
taskTitle: string | null,
|
||||
curr: {
|
||||
timer: TimerState;
|
||||
mode: FocusModeMode;
|
||||
currentTask: { title: string } | null;
|
||||
isBreakActive: boolean;
|
||||
isLongBreak: boolean;
|
||||
timeRemaining: number;
|
||||
},
|
||||
): boolean {
|
||||
if (!prevTimer) return true;
|
||||
|
||||
// Check if pause state changed
|
||||
if (prevTimer.isRunning !== currTimer.isRunning) return true;
|
||||
|
||||
// Check if purpose changed (work -> break or vice versa)
|
||||
if (prevTimer.purpose !== currTimer.purpose) return true;
|
||||
|
||||
// Only update notification every 5 seconds to reduce overhead
|
||||
// (native service already updates every second)
|
||||
const elapsedDiff = Math.abs(currTimer.elapsed - prevTimer.elapsed);
|
||||
if (elapsedDiff >= 5000) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
pairwise,
|
||||
startWith,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { androidInterface } from '../android-interface';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { selectCurrentTask } from '../../tasks/store/task.selectors';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { selectTimer } from '../../focus-mode/store/focus-mode.selectors';
|
||||
import { combineLatest } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AndroidForegroundTrackingEffects {
|
||||
private _store = inject(Store);
|
||||
private _taskService = inject(TaskService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
/**
|
||||
* Start/stop the native foreground service when the current task changes.
|
||||
* Also handles syncing time when switching tasks directly.
|
||||
* NOTE: When focus mode is active, we hide the tracking notification
|
||||
* to avoid showing two notifications (focus mode notification takes priority).
|
||||
*/
|
||||
syncTrackingToService$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
combineLatest([
|
||||
this._store.select(selectCurrentTask),
|
||||
this._store.select(selectTimer),
|
||||
]).pipe(
|
||||
map(([currentTask, timer]) => ({
|
||||
currentTask,
|
||||
isFocusModeActive: timer.purpose !== null,
|
||||
})),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.currentTask?.id === b.currentTask?.id &&
|
||||
a.isFocusModeActive === b.isFocusModeActive,
|
||||
),
|
||||
startWith({ currentTask: null as Task | null, isFocusModeActive: false }),
|
||||
pairwise(),
|
||||
tap(([prev, curr]) => {
|
||||
const { currentTask, isFocusModeActive } = curr;
|
||||
const prevTask = prev.currentTask;
|
||||
const wasFocusModeActive = prev.isFocusModeActive;
|
||||
|
||||
// If switching from one task to another (or stopping), sync the previous task's time first
|
||||
// Also sync when focus mode just started (to capture time tracked before focus mode)
|
||||
const focusModeJustStarted = isFocusModeActive && !wasFocusModeActive;
|
||||
if (prevTask && (!wasFocusModeActive || focusModeJustStarted)) {
|
||||
this._syncElapsedTimeForTask(prevTask.id);
|
||||
}
|
||||
|
||||
// Don't show tracking notification when focus mode is active
|
||||
// (focus mode notification takes priority)
|
||||
if (isFocusModeActive) {
|
||||
DroidLog.log(
|
||||
'Focus mode active, stopping tracking service to avoid duplicate notification',
|
||||
);
|
||||
androidInterface.stopTrackingService?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTask) {
|
||||
DroidLog.log('Starting tracking service', {
|
||||
taskId: currentTask.id,
|
||||
title: currentTask.title,
|
||||
timeSpent: currentTask.timeSpent,
|
||||
});
|
||||
androidInterface.startTrackingService?.(
|
||||
currentTask.id,
|
||||
currentTask.title,
|
||||
currentTask.timeSpent || 0,
|
||||
);
|
||||
} else {
|
||||
DroidLog.log('Stopping tracking service');
|
||||
androidInterface.stopTrackingService?.();
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* When the app resumes from background, sync the elapsed time from the native service.
|
||||
*/
|
||||
syncOnResume$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
androidInterface.onResume$.pipe(
|
||||
withLatestFrom(this._store.select(selectCurrentTask)),
|
||||
filter(([, currentTask]) => !!currentTask),
|
||||
tap(([, currentTask]) => {
|
||||
this._syncElapsedTimeForTask(currentTask!.id);
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle pause action from the notification.
|
||||
*/
|
||||
handlePauseAction$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
androidInterface.onPauseTracking$.pipe(
|
||||
withLatestFrom(this._store.select(selectCurrentTask)),
|
||||
filter(([, currentTask]) => !!currentTask),
|
||||
tap(([, currentTask]) => {
|
||||
DroidLog.log('Pause action from notification');
|
||||
// Sync elapsed time first, then pause
|
||||
this._syncElapsedTimeForTask(currentTask!.id);
|
||||
this._taskService.pauseCurrent();
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle done action from the notification.
|
||||
*/
|
||||
handleDoneAction$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
androidInterface.onMarkTaskDone$.pipe(
|
||||
withLatestFrom(this._store.select(selectCurrentTask)),
|
||||
filter(([, currentTask]) => !!currentTask),
|
||||
tap(([, currentTask]) => {
|
||||
DroidLog.log('Done action from notification', { taskId: currentTask!.id });
|
||||
// Sync elapsed time, mark as done, then pause
|
||||
this._syncElapsedTimeForTask(currentTask!.id);
|
||||
this._taskService.setDone(currentTask!.id);
|
||||
this._taskService.pauseCurrent();
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Sync elapsed time from native service to the task.
|
||||
* Only syncs if the native service is tracking the specified task.
|
||||
*/
|
||||
private _syncElapsedTimeForTask(taskId: string): void {
|
||||
const elapsedJson = androidInterface.getTrackingElapsed?.();
|
||||
DroidLog.log('Syncing elapsed time for task', { taskId, elapsedJson });
|
||||
|
||||
if (!elapsedJson || elapsedJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nativeData = JSON.parse(elapsedJson) as {
|
||||
taskId: string;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
// Only sync if native is tracking the same task
|
||||
if (nativeData.taskId !== taskId) {
|
||||
DroidLog.log('Native tracking different task, skipping sync', {
|
||||
nativeTaskId: nativeData.taskId,
|
||||
expectedTaskId: taskId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the task to find its current timeSpent
|
||||
this._taskService
|
||||
.getByIdOnce$(taskId)
|
||||
.subscribe((task) => {
|
||||
if (!task) {
|
||||
DroidLog.log('Task not found for sync', { taskId });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeSpent = task.timeSpent || 0;
|
||||
const duration = nativeData.elapsedMs - currentTimeSpent;
|
||||
|
||||
DroidLog.log('Calculated sync duration', {
|
||||
taskId,
|
||||
nativeElapsed: nativeData.elapsedMs,
|
||||
currentTimeSpent,
|
||||
duration,
|
||||
});
|
||||
|
||||
if (duration > 0) {
|
||||
this._taskService.addTimeSpent(task, duration, this._dateService.todayStr());
|
||||
}
|
||||
})
|
||||
.unsubscribe();
|
||||
} catch (e) {
|
||||
DroidLog.err('Failed to parse elapsed time', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { timer } from 'rxjs';
|
|||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { LocalNotificationSchema } from '@capacitor/local-notifications/dist/esm/definitions';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
import { generateNotificationId } from '../android-notification-id.util';
|
||||
import { androidInterface } from '../android-interface';
|
||||
|
|
@ -56,6 +55,8 @@ export class AndroidEffects {
|
|||
},
|
||||
);
|
||||
|
||||
// Use native reminder scheduling with BroadcastReceiver for actions
|
||||
// This allows snooze/done to work without opening the app
|
||||
scheduleNotifications$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
|
|
@ -68,16 +69,15 @@ export class AndroidEffects {
|
|||
// Nothing to schedule yet, so avoid triggering the runtime permission dialog prematurely.
|
||||
return;
|
||||
}
|
||||
DroidLog.log('AndroidEffects: scheduling reminders', {
|
||||
DroidLog.log('AndroidEffects: scheduling reminders natively', {
|
||||
reminderCount: tasksWithReminders.length,
|
||||
});
|
||||
|
||||
// Check permissions first
|
||||
const checkResult = await LocalNotifications.checkPermissions();
|
||||
DroidLog.log('AndroidEffects: pre-schedule permission check', checkResult);
|
||||
let displayPermissionGranted = checkResult.display === 'granted';
|
||||
if (!displayPermissionGranted) {
|
||||
// Reminder scheduling only works after the runtime permission is accepted.
|
||||
const requestResult = await LocalNotifications.requestPermissions();
|
||||
DroidLog.log({ requestResult });
|
||||
displayPermissionGranted = requestResult.display === 'granted';
|
||||
if (!displayPermissionGranted) {
|
||||
this._notifyPermissionIssue();
|
||||
|
|
@ -85,42 +85,24 @@ export class AndroidEffects {
|
|||
}
|
||||
}
|
||||
await this._ensureExactAlarmAccess();
|
||||
const pendingNotifications = await LocalNotifications.getPending();
|
||||
DroidLog.log({ pendingNotifications });
|
||||
if (pendingNotifications.notifications.length > 0) {
|
||||
await LocalNotifications.cancel({
|
||||
notifications: pendingNotifications.notifications.map((n) => ({
|
||||
id: n.id,
|
||||
})),
|
||||
});
|
||||
|
||||
// Schedule each reminder using native Android AlarmManager
|
||||
for (const task of tasksWithReminders) {
|
||||
const id = generateNotificationId(task.id);
|
||||
const now = Date.now();
|
||||
const scheduleAt = task.remindAt! <= now ? now + 1000 : task.remindAt!;
|
||||
|
||||
androidInterface.scheduleNativeReminder?.(
|
||||
id,
|
||||
task.id,
|
||||
task.id,
|
||||
task.title,
|
||||
'TASK',
|
||||
scheduleAt,
|
||||
);
|
||||
}
|
||||
// Re-schedule the full set so the native alarm manager is always in sync.
|
||||
await LocalNotifications.schedule({
|
||||
notifications: tasksWithReminders.map((task) => {
|
||||
// Use deterministic ID based on task id to prevent duplicate notifications
|
||||
const id = generateNotificationId(task.id);
|
||||
const now = Date.now();
|
||||
const scheduleAt = task.remindAt <= now ? now + 1000 : task.remindAt; // push overdue reminders into the immediate future
|
||||
const mapped: LocalNotificationSchema = {
|
||||
id,
|
||||
title: task.title,
|
||||
body: '',
|
||||
extra: {
|
||||
taskId: task.id,
|
||||
remindAt: task.remindAt,
|
||||
},
|
||||
schedule: {
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
at: new Date(scheduleAt),
|
||||
allowWhileIdle: true,
|
||||
repeats: false,
|
||||
every: undefined,
|
||||
},
|
||||
};
|
||||
return mapped;
|
||||
}),
|
||||
});
|
||||
DroidLog.log('AndroidEffects: scheduled local notifications', {
|
||||
|
||||
DroidLog.log('AndroidEffects: scheduled native reminders', {
|
||||
reminderCount: tasksWithReminders.length,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { FileImexComponent } from '../../imex/file-imex/file-imex.component';
|
|||
import { SimpleCounterCfgComponent } from '../simple-counter/simple-counter-cfg/simple-counter-cfg.component';
|
||||
import { SyncSafetyBackupsComponent } from '../../imex/sync/sync-safety-backups/sync-safety-backups.component';
|
||||
import { CustomCfgSection } from './global-config.model';
|
||||
import { ClickUpAdditionalCfgComponent } from '../issue/providers/clickup/clickup-view-components/clickup-cfg/clickup-additional-cfg.component';
|
||||
|
||||
export const customConfigFormSectionComponent = (
|
||||
customSection: CustomCfgSection,
|
||||
|
|
@ -16,6 +17,9 @@ export const customConfigFormSectionComponent = (
|
|||
case 'SIMPLE_COUNTER_CFG':
|
||||
return SimpleCounterCfgComponent;
|
||||
|
||||
case 'CLICKUP_CFG':
|
||||
return ClickUpAdditionalCfgComponent;
|
||||
|
||||
default:
|
||||
throw new Error('Invalid component');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,11 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
voice: defaultVoice,
|
||||
},
|
||||
focusMode: {
|
||||
isAlwaysUseFocusMode: false,
|
||||
isSkipPreparation: false,
|
||||
isPlayTick: false,
|
||||
isPauseTrackingDuringBreak: false,
|
||||
isSyncSessionWithTracking: false,
|
||||
isStartInBackground: false,
|
||||
},
|
||||
pomodoro: {
|
||||
duration: 25 * minute,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,17 @@ export const FOCUS_MODE_FORM_CFG: ConfigFormSection<FocusModeConfig> = {
|
|||
help: T.GCF.FOCUS_MODE.HELP,
|
||||
items: [
|
||||
{
|
||||
key: 'isAlwaysUseFocusMode',
|
||||
key: 'isSyncSessionWithTracking',
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.FOCUS_MODE.L_ALWAYS_OPEN_FOCUS_MODE,
|
||||
label: T.GCF.FOCUS_MODE.L_SYNC_SESSION_WITH_TRACKING,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isStartInBackground',
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.FOCUS_MODE.L_START_IN_BACKGROUND,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -20,5 +27,19 @@ export const FOCUS_MODE_FORM_CFG: ConfigFormSection<FocusModeConfig> = {
|
|||
label: T.GCF.FOCUS_MODE.L_SKIP_PREPARATION_SCREEN,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isPlayTick',
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.FOCUS_MODE.L_IS_PLAY_TICK,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isPauseTrackingDuringBreak',
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.FOCUS_MODE.L_PAUSE_TRACKING_DURING_BREAK,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -194,8 +194,11 @@ export type DominaModeConfig = Readonly<{
|
|||
}>;
|
||||
|
||||
export type FocusModeConfig = Readonly<{
|
||||
isAlwaysUseFocusMode: boolean;
|
||||
isSkipPreparation: boolean;
|
||||
isPlayTick?: boolean;
|
||||
isPauseTrackingDuringBreak?: boolean;
|
||||
isSyncSessionWithTracking?: boolean;
|
||||
isStartInBackground?: boolean;
|
||||
}>;
|
||||
|
||||
export type DailySummaryNote = Readonly<{
|
||||
|
|
@ -248,7 +251,8 @@ export type CustomCfgSection =
|
|||
| 'SYNC_SAFETY_BACKUPS'
|
||||
| 'JIRA_CFG'
|
||||
| 'SIMPLE_COUNTER_CFG'
|
||||
| 'OPENPROJECT_CFG';
|
||||
| 'OPENPROJECT_CFG'
|
||||
| 'CLICKUP_CFG';
|
||||
|
||||
// Intermediate model
|
||||
export interface ConfigFormSection<FormModel> {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const POMODORO_DURATION_FIELDS: FormlyFieldConfig[] = [
|
|||
[fields]="fields"
|
||||
[form]="form"
|
||||
[model]="model"
|
||||
(modelChange)="model = $event"
|
||||
></formly-form>
|
||||
</form>
|
||||
<div class="dialog-actions">
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
<!-- Task title -->
|
||||
<div class="task-section">
|
||||
@if (ct) {
|
||||
@if (ct.parentId && parentTaskTitle()) {
|
||||
<div class="parent-title">
|
||||
<div class="title">{{ parentTaskTitle() }}</div>
|
||||
</div>
|
||||
}
|
||||
<task-title
|
||||
@fade
|
||||
(valueEdited)="updateTaskTitleIfChanged($event.wasChanged, $event.newVal)"
|
||||
|
|
@ -302,6 +307,7 @@
|
|||
(changed)="changeTaskNotes($event); isFocusNotes.set(false)"
|
||||
[isFocus]="isFocusNotes()"
|
||||
[isShowControls]="true"
|
||||
[isDefaultText]="!ct.notes"
|
||||
[model]="ct.notes || defaultTaskNotes()"
|
||||
></inline-markdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,12 +82,28 @@
|
|||
font-size: 24px;
|
||||
}
|
||||
|
||||
@media (max-height: 250px) {
|
||||
@media (max-width: 333px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.parent-title {
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.8;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
max-width: 90%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (max-width: 499px) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 333px) {
|
||||
font-size: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ import { SimpleCounter } from '../../simple-counter/simple-counter.model';
|
|||
import * as actions from '../store/focus-mode.actions';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, signal, WritableSignal } from '@angular/core';
|
||||
import { FocusModeTaskSelectorComponent } from '../focus-mode-task-selector/focus-mode-task-selector.component';
|
||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DialogPomodoroSettingsComponent } from '../dialog-pomodoro-settings/dialog-pomodoro-settings.component';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { InlineMarkdownComponent } from '../../../ui/inline-markdown/inline-markdown.component';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
|
||||
@Component({
|
||||
selector: 'focus-mode-task-selector',
|
||||
|
|
@ -104,7 +107,6 @@ describe('FocusModeMainComponent', () => {
|
|||
mainState: jasmine.createSpy().and.returnValue(FocusMainUIState.Preparation),
|
||||
focusModeConfig: jasmine.createSpy().and.returnValue({
|
||||
isSkipPreparation: false,
|
||||
isAlwaysUseFocusMode: false,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -406,14 +408,12 @@ describe('FocusModeMainComponent', () => {
|
|||
focusModeServiceSpy.mode.and.returnValue(FocusModeMode.Pomodoro);
|
||||
focusModeServiceSpy.focusModeConfig.and.returnValue({
|
||||
isSkipPreparation: false,
|
||||
isAlwaysUseFocusMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch startFocusPreparation when skip is disabled', () => {
|
||||
focusModeServiceSpy.focusModeConfig.and.returnValue({
|
||||
isSkipPreparation: false,
|
||||
isAlwaysUseFocusMode: false,
|
||||
});
|
||||
|
||||
component.startSession();
|
||||
|
|
@ -425,7 +425,6 @@ describe('FocusModeMainComponent', () => {
|
|||
component.displayDuration.set(900000);
|
||||
focusModeServiceSpy.focusModeConfig.and.returnValue({
|
||||
isSkipPreparation: true,
|
||||
isAlwaysUseFocusMode: false,
|
||||
});
|
||||
|
||||
component.startSession();
|
||||
|
|
@ -438,7 +437,6 @@ describe('FocusModeMainComponent', () => {
|
|||
it('should use zero duration for Flowtime when skipping preparation', () => {
|
||||
focusModeServiceSpy.focusModeConfig.and.returnValue({
|
||||
isSkipPreparation: true,
|
||||
isAlwaysUseFocusMode: false,
|
||||
});
|
||||
focusModeServiceSpy.mode.and.returnValue(FocusModeMode.Flowtime);
|
||||
|
||||
|
|
@ -552,3 +550,187 @@ describe('FocusModeMainComponent', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Separate test suite for notes panel tests that need InProgress state
|
||||
* Uses signal-based mocks to properly trigger computed signals
|
||||
*/
|
||||
describe('FocusModeMainComponent - notes panel (issue #5752)', () => {
|
||||
let component: FocusModeMainComponent;
|
||||
let fixture: ComponentFixture<FocusModeMainComponent>;
|
||||
let currentTaskSubject: BehaviorSubject<TaskCopy | null>;
|
||||
let mainStateSignal: WritableSignal<FocusMainUIState>;
|
||||
let isSessionRunningSignal: WritableSignal<boolean>;
|
||||
|
||||
const mockTask: TaskCopy = {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
notes: 'Test notes',
|
||||
timeSpent: 0,
|
||||
timeEstimate: 0,
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
projectId: 'project-1',
|
||||
timeSpentOnDay: {},
|
||||
attachments: [],
|
||||
tagIds: [],
|
||||
} as TaskCopy;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create writable signals for state that affects template rendering
|
||||
mainStateSignal = signal(FocusMainUIState.InProgress);
|
||||
isSessionRunningSignal = signal(true);
|
||||
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch', 'select']);
|
||||
storeSpy.select.and.returnValue(of([]));
|
||||
|
||||
const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], {
|
||||
misc: jasmine.createSpy().and.returnValue({
|
||||
taskNotesTpl: 'Default task notes template',
|
||||
}),
|
||||
});
|
||||
|
||||
currentTaskSubject = new BehaviorSubject<TaskCopy | null>(mockTask);
|
||||
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['update'], {
|
||||
currentTask$: currentTaskSubject.asObservable(),
|
||||
});
|
||||
|
||||
const taskAttachmentServiceSpy = jasmine.createSpyObj('TaskAttachmentService', [
|
||||
'createFromDrop',
|
||||
]);
|
||||
|
||||
const issueServiceSpy = jasmine.createSpyObj('IssueService', ['issueLink']);
|
||||
issueServiceSpy.issueLink.and.returnValue(Promise.resolve('https://example.com'));
|
||||
|
||||
const simpleCounterServiceSpy = jasmine.createSpyObj('SimpleCounterService', [''], {
|
||||
enabledSimpleCounters$: of([]),
|
||||
});
|
||||
|
||||
const mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||
mockMatDialog.open.and.returnValue({
|
||||
afterClosed: () => of(null),
|
||||
} as MatDialogRef<any>);
|
||||
|
||||
// Use signals instead of spies for properties that affect computed signals
|
||||
const focusModeServiceMock = {
|
||||
timeElapsed: signal(60000),
|
||||
isCountTimeDown: signal(true),
|
||||
progress: signal(0),
|
||||
timeRemaining: signal(1500000),
|
||||
isSessionRunning: isSessionRunningSignal,
|
||||
isSessionPaused: signal(false),
|
||||
isBreakActive: signal(false),
|
||||
currentCycle: signal(1),
|
||||
sessionDuration: signal(0),
|
||||
mode: signal(FocusModeMode.Pomodoro),
|
||||
mainState: mainStateSignal,
|
||||
focusModeConfig: signal({
|
||||
isSkipPreparation: false,
|
||||
}),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
FocusModeMainComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
EffectsModule.forRoot([]),
|
||||
MarkdownModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: TaskAttachmentService, useValue: taskAttachmentServiceSpy },
|
||||
{ provide: IssueService, useValue: issueServiceSpy },
|
||||
{ provide: SimpleCounterService, useValue: simpleCounterServiceSpy },
|
||||
{ provide: FocusModeService, useValue: focusModeServiceMock },
|
||||
{ provide: MatDialog, useValue: mockMatDialog },
|
||||
],
|
||||
})
|
||||
.overrideComponent(FocusModeMainComponent, {
|
||||
remove: { imports: [FocusModeTaskSelectorComponent] },
|
||||
add: { imports: [MockFocusModeTaskSelectorComponent] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FocusModeMainComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should pass isDefaultText=false to inline-markdown when task has notes', () => {
|
||||
// Arrange: task has existing notes
|
||||
const taskWithNotes = { ...mockTask, notes: 'My existing notes' };
|
||||
currentTaskSubject.next(taskWithNotes);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act: open notes panel
|
||||
component.isFocusNotes.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert: inline-markdown should receive isDefaultText=false
|
||||
const inlineMarkdown = fixture.debugElement.query(
|
||||
By.directive(InlineMarkdownComponent),
|
||||
);
|
||||
expect(inlineMarkdown).toBeTruthy();
|
||||
expect(inlineMarkdown.componentInstance.isDefaultText()).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass isDefaultText=true to inline-markdown when task has no notes', () => {
|
||||
// Arrange: task has no notes (undefined)
|
||||
const taskWithoutNotes = { ...mockTask, notes: undefined };
|
||||
currentTaskSubject.next(taskWithoutNotes);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act: open notes panel
|
||||
component.isFocusNotes.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert: inline-markdown should receive isDefaultText=true
|
||||
const inlineMarkdown = fixture.debugElement.query(
|
||||
By.directive(InlineMarkdownComponent),
|
||||
);
|
||||
expect(inlineMarkdown).toBeTruthy();
|
||||
expect(inlineMarkdown.componentInstance.isDefaultText()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass isDefaultText=true to inline-markdown when task has empty notes', () => {
|
||||
// Arrange: task has empty string notes
|
||||
const taskWithEmptyNotes = { ...mockTask, notes: '' };
|
||||
currentTaskSubject.next(taskWithEmptyNotes);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act: open notes panel
|
||||
component.isFocusNotes.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert: inline-markdown should receive isDefaultText=true
|
||||
const inlineMarkdown = fixture.debugElement.query(
|
||||
By.directive(InlineMarkdownComponent),
|
||||
);
|
||||
expect(inlineMarkdown).toBeTruthy();
|
||||
expect(inlineMarkdown.componentInstance.isDefaultText()).toBe(true);
|
||||
});
|
||||
|
||||
it('should display existing notes instead of default template when task has notes', () => {
|
||||
// Arrange: task has existing notes, default template is set
|
||||
const existingNotes = 'My important existing notes';
|
||||
const taskWithNotes = { ...mockTask, notes: existingNotes };
|
||||
currentTaskSubject.next(taskWithNotes);
|
||||
component.defaultTaskNotes.set('Default task notes template');
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act: open notes panel
|
||||
component.isFocusNotes.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert: inline-markdown model should be the existing notes, not the template
|
||||
const inlineMarkdown = fixture.debugElement.query(
|
||||
By.directive(InlineMarkdownComponent),
|
||||
);
|
||||
expect(inlineMarkdown).toBeTruthy();
|
||||
expect(inlineMarkdown.componentInstance.model).toBe(existingNotes);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -130,6 +130,22 @@ export class FocusModeMainComponent {
|
|||
mainState = this.focusModeService.mainState;
|
||||
currentTask = toSignal(this.taskService.currentTask$);
|
||||
|
||||
readonly parentTask = toSignal(
|
||||
this.taskService.currentTask$.pipe(
|
||||
switchMap((t) =>
|
||||
t && t.parentId ? this.taskService.getByIdLive$(t.parentId) : of(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
readonly parentTaskTitle = computed(() => {
|
||||
const parent = this.parentTask();
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
return parent.title;
|
||||
});
|
||||
|
||||
private readonly _isPreparation = computed(
|
||||
() => this.mainState() === FocusMainUIState.Preparation,
|
||||
);
|
||||
|
|
@ -346,7 +362,8 @@ export class FocusModeMainComponent {
|
|||
}
|
||||
|
||||
pauseSession(): void {
|
||||
this._store.dispatch(pauseFocusSession());
|
||||
const currentTaskId = this.taskService.currentTaskId();
|
||||
this._store.dispatch(pauseFocusSession({ pausedTaskId: currentTaskId }));
|
||||
}
|
||||
|
||||
resumeSession(): void {
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ describe('FocusModeStrategies', () => {
|
|||
|
||||
describe('getBreakDuration', () => {
|
||||
it('should return short break for cycles 1-3', () => {
|
||||
const result1 = strategy.getBreakDuration(0); // Next cycle will be 1
|
||||
const result2 = strategy.getBreakDuration(1); // Next cycle will be 2
|
||||
const result3 = strategy.getBreakDuration(2); // Next cycle will be 3
|
||||
const result1 = strategy.getBreakDuration(1);
|
||||
const result2 = strategy.getBreakDuration(2);
|
||||
const result3 = strategy.getBreakDuration(3);
|
||||
|
||||
expect(result1).toEqual({ duration: 300000, isLong: false });
|
||||
expect(result2).toEqual({ duration: 300000, isLong: false });
|
||||
|
|
@ -83,20 +83,20 @@ describe('FocusModeStrategies', () => {
|
|||
});
|
||||
|
||||
it('should return long break for cycle 4', () => {
|
||||
const result = strategy.getBreakDuration(3); // Next cycle will be 4
|
||||
const result = strategy.getBreakDuration(4);
|
||||
|
||||
expect(result).toEqual({ duration: 900000, isLong: true });
|
||||
});
|
||||
|
||||
it('should return short break after long break cycle', () => {
|
||||
const result = strategy.getBreakDuration(4); // Next cycle will be 5
|
||||
const result = strategy.getBreakDuration(5);
|
||||
|
||||
expect(result).toEqual({ duration: 300000, isLong: false });
|
||||
});
|
||||
|
||||
it('should return long break every 4th cycle', () => {
|
||||
const result8 = strategy.getBreakDuration(7); // Next cycle will be 8
|
||||
const result12 = strategy.getBreakDuration(11); // Next cycle will be 12
|
||||
const result8 = strategy.getBreakDuration(8);
|
||||
const result12 = strategy.getBreakDuration(12);
|
||||
|
||||
expect(result8).toEqual({ duration: 900000, isLong: true });
|
||||
expect(result12).toEqual({ duration: 900000, isLong: true });
|
||||
|
|
@ -106,8 +106,8 @@ describe('FocusModeStrategies', () => {
|
|||
// Replace the spy to return empty object
|
||||
(mockGlobalConfigService.pomodoroConfig as jasmine.Spy).and.returnValue({});
|
||||
|
||||
const shortBreak = strategy.getBreakDuration(0);
|
||||
const longBreak = strategy.getBreakDuration(3);
|
||||
const shortBreak = strategy.getBreakDuration(1);
|
||||
const longBreak = strategy.getBreakDuration(4);
|
||||
|
||||
expect(shortBreak).toEqual({
|
||||
duration: FOCUS_MODE_DEFAULTS.SHORT_BREAK_DURATION,
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ export class PomodoroStrategy implements FocusModeStrategy {
|
|||
const config = this.globalConfigService.pomodoroConfig();
|
||||
const cyclesBeforeLong =
|
||||
config?.cyclesBeforeLongerBreak ?? FOCUS_MODE_DEFAULTS.CYCLES_BEFORE_LONG_BREAK;
|
||||
// The next cycle will be cycle + 1, check if that should be a long break
|
||||
const nextCycle = cycle + 1;
|
||||
const isLong = nextCycle % cyclesBeforeLong === 0;
|
||||
// Long break after every Nth session (e.g., after sessions 4, 8, 12...)
|
||||
const isLong = cycle % cyclesBeforeLong === 0;
|
||||
|
||||
const duration = isLong
|
||||
? (config?.longerBreakDuration ?? FOCUS_MODE_DEFAULTS.LONG_BREAK_DURATION)
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ describe('FocusModeModel', () => {
|
|||
mode: FocusModeMode.Pomodoro,
|
||||
currentCycle: 0,
|
||||
lastCompletedDuration: 0,
|
||||
pausedTaskId: null,
|
||||
};
|
||||
|
||||
expect(state.timer).toEqual(timer);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export interface FocusModeState {
|
|||
mode: FocusModeMode;
|
||||
currentCycle: number;
|
||||
lastCompletedDuration: number;
|
||||
// TODO maybe add today total
|
||||
|
||||
// Task tracking during breaks
|
||||
pausedTaskId: string | null;
|
||||
}
|
||||
|
||||
// Mode strategy interface
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ export const startFocusSession = createAction(
|
|||
|
||||
export const navigateToMainScreen = createAction('[FocusMode] Navigate To Main Screen');
|
||||
|
||||
export const pauseFocusSession = createAction('[FocusMode] Pause Session');
|
||||
export const pauseFocusSession = createAction(
|
||||
'[FocusMode] Pause Session',
|
||||
props<{ pausedTaskId?: string | null }>(),
|
||||
);
|
||||
export const unPauseFocusSession = createAction('[FocusMode] Resume Session');
|
||||
|
||||
export const completeFocusSession = createAction(
|
||||
|
|
@ -37,7 +40,7 @@ export const cancelFocusSession = createAction('[FocusMode] Cancel Session');
|
|||
|
||||
export const startBreak = createAction(
|
||||
'[FocusMode] Start Break',
|
||||
props<{ duration?: number; isLongBreak?: boolean }>(),
|
||||
props<{ duration?: number; isLongBreak?: boolean; pausedTaskId?: string | null }>(),
|
||||
);
|
||||
export const skipBreak = createAction('[FocusMode] Skip Break');
|
||||
export const completeBreak = createAction('[FocusMode] Complete Break');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,38 +2,44 @@ import { inject, Injectable } from '@angular/core';
|
|||
import { createEffect, ofType } from '@ngrx/effects';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { combineLatest, EMPTY, of } from 'rxjs';
|
||||
import { skipDuringSync } from '../../../util/skip-during-sync.operator';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
pairwise,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import * as actions from './focus-mode.actions';
|
||||
import { cancelFocusSession, showFocusOverlay } from './focus-mode.actions';
|
||||
import * as selectors from './focus-mode.selectors';
|
||||
import { FocusModeStrategyFactory } from '../focus-mode-strategies';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { playSound } from '../../../util/play-sound';
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { unsetCurrentTask } from '../../tasks/store/task.actions';
|
||||
import { setCurrentTask, unsetCurrentTask } from '../../tasks/store/task.actions';
|
||||
import { selectTaskById } from '../../tasks/store/task.selectors';
|
||||
import { openIdleDialog } from '../../idle/store/idle.actions';
|
||||
import { LS } from '../../../core/persistence/storage-keys.const';
|
||||
import { selectFocusModeConfig } from '../../config/store/global-config.reducer';
|
||||
import { FocusModeMode, TimerState } from '../focus-mode.model';
|
||||
import {
|
||||
selectFocusModeConfig,
|
||||
selectPomodoroConfig,
|
||||
} from '../../config/store/global-config.reducer';
|
||||
import { updateGlobalConfigSection } from '../../config/store/global-config.actions';
|
||||
import { FocusModeMode, FocusScreen, TimerState } from '../focus-mode.model';
|
||||
import { BannerService } from '../../../core/banner/banner.service';
|
||||
import { BannerId } from '../../../core/banner/banner.model';
|
||||
import { Banner, BannerId } from '../../../core/banner/banner.model';
|
||||
import { T } from '../../../t.const';
|
||||
import { showFocusOverlay } from './focus-mode.actions';
|
||||
import { cancelFocusSession } from './focus-mode.actions';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { MetricService } from '../../metric/metric.service';
|
||||
import { FocusModeStorageService } from '../focus-mode-storage.service';
|
||||
|
||||
const SESSION_DONE_SOUND = 'positive.ogg';
|
||||
const TICK_SOUND = 'tick.mp3';
|
||||
|
||||
@Injectable()
|
||||
export class FocusModeEffects {
|
||||
|
|
@ -46,12 +52,13 @@ export class FocusModeEffects {
|
|||
private metricService = inject(MetricService);
|
||||
private storageService = inject(FocusModeStorageService);
|
||||
|
||||
// Auto-show overlay when task is selected (if always use focus mode is enabled)
|
||||
// Auto-show overlay when task is selected (if sync session with tracking is enabled)
|
||||
// Skip showing overlay if isStartInBackground is enabled
|
||||
autoShowOverlay$ = createEffect(() =>
|
||||
this.store.select(selectFocusModeConfig).pipe(
|
||||
skipDuringSync(),
|
||||
switchMap((cfg) =>
|
||||
cfg?.isAlwaysUseFocusMode
|
||||
cfg?.isSyncSessionWithTracking && !cfg?.isStartInBackground
|
||||
? this.taskService.currentTaskId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((id) => !!id),
|
||||
|
|
@ -62,11 +69,142 @@ export class FocusModeEffects {
|
|||
),
|
||||
);
|
||||
|
||||
// Sync: When tracking starts → start/unpause focus session
|
||||
// Only triggers when isSyncSessionWithTracking is enabled
|
||||
syncTrackingStartToSession$ = createEffect(() =>
|
||||
this.store.select(selectFocusModeConfig).pipe(
|
||||
switchMap((cfg) =>
|
||||
cfg?.isSyncSessionWithTracking
|
||||
? this.taskService.currentTaskId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((taskId) => !!taskId),
|
||||
withLatestFrom(
|
||||
this.store.select(selectors.selectTimer),
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectCurrentScreen),
|
||||
),
|
||||
switchMap(([_taskId, timer, mode, currentScreen]) => {
|
||||
// If session is paused (purpose is 'work' but not running), resume it
|
||||
if (timer.purpose === 'work' && !timer.isRunning) {
|
||||
return of(actions.unPauseFocusSession());
|
||||
}
|
||||
// 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);
|
||||
const duration = strategy.initialSessionDuration;
|
||||
return of(actions.startFocusSession({ duration }));
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
)
|
||||
: EMPTY,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync: When tracking stops → pause focus session
|
||||
// Uses pairwise to capture the previous task ID before it's lost
|
||||
syncTrackingStopToSession$ = createEffect(() =>
|
||||
this.taskService.currentTaskId$.pipe(
|
||||
pairwise(),
|
||||
withLatestFrom(
|
||||
this.store.select(selectFocusModeConfig),
|
||||
this.store.select(selectors.selectTimer),
|
||||
),
|
||||
filter(
|
||||
([[prevTaskId, currTaskId], cfg, timer]) =>
|
||||
!!cfg?.isSyncSessionWithTracking &&
|
||||
timer.purpose === 'work' &&
|
||||
timer.isRunning &&
|
||||
!!prevTaskId &&
|
||||
!currTaskId, // Was tracking (prevTaskId exists) and now stopped (currTaskId is null)
|
||||
),
|
||||
map(([[prevTaskId]]) => actions.pauseFocusSession({ pausedTaskId: prevTaskId })),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync: When focus session pauses → stop tracking
|
||||
// Note: This effect fires AFTER the reducer runs, and the pausedTaskId is already stored
|
||||
// in the action/reducer, so we just need to dispatch unsetCurrentTask
|
||||
syncSessionPauseToTracking$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.pauseFocusSession),
|
||||
withLatestFrom(
|
||||
this.store.select(selectFocusModeConfig),
|
||||
this.store.select(selectors.selectTimer),
|
||||
),
|
||||
filter(
|
||||
([action, cfg, timer]) =>
|
||||
!!cfg?.isSyncSessionWithTracking &&
|
||||
timer.purpose === 'work' &&
|
||||
!!action.pausedTaskId,
|
||||
),
|
||||
map(() => unsetCurrentTask()),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync: When focus session resumes → start tracking
|
||||
// Checks that the paused task still exists before resuming tracking
|
||||
syncSessionResumeToTracking$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.unPauseFocusSession),
|
||||
withLatestFrom(
|
||||
this.store.select(selectFocusModeConfig),
|
||||
this.store.select(selectors.selectTimer),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
this.taskService.currentTaskId$,
|
||||
),
|
||||
filter(
|
||||
([_action, cfg, timer, pausedTaskId, currentTaskId]) =>
|
||||
!!cfg?.isSyncSessionWithTracking &&
|
||||
timer.purpose === 'work' &&
|
||||
!currentTaskId &&
|
||||
!!pausedTaskId,
|
||||
),
|
||||
switchMap(([_action, _cfg, _timer, pausedTaskId]) =>
|
||||
this.store.select(selectTaskById, { id: pausedTaskId! }).pipe(
|
||||
take(1),
|
||||
map((task) => (task ? setCurrentTask({ id: pausedTaskId! }) : null)),
|
||||
),
|
||||
),
|
||||
filter((action): action is ReturnType<typeof setCurrentTask> => action !== null),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync: When focus session starts → start tracking (if not already tracking)
|
||||
// Checks that the paused task still exists before starting tracking
|
||||
syncSessionStartToTracking$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.startFocusSession),
|
||||
withLatestFrom(
|
||||
this.store.select(selectFocusModeConfig),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
this.taskService.currentTaskId$,
|
||||
),
|
||||
filter(
|
||||
([_action, cfg, pausedTaskId, currentTaskId]) =>
|
||||
!!cfg?.isSyncSessionWithTracking && !currentTaskId && !!pausedTaskId,
|
||||
),
|
||||
switchMap(([_action, _cfg, pausedTaskId]) =>
|
||||
this.store.select(selectTaskById, { id: pausedTaskId! }).pipe(
|
||||
take(1),
|
||||
map((task) => (task ? setCurrentTask({ id: pausedTaskId! }) : null)),
|
||||
),
|
||||
),
|
||||
filter((action): action is ReturnType<typeof setCurrentTask> => action !== null),
|
||||
),
|
||||
);
|
||||
|
||||
// Detect when work session timer completes and dispatch completeFocusSession
|
||||
// Only triggers when timer STOPS (isRunning becomes false) with elapsed >= duration
|
||||
detectSessionCompletion$ = createEffect(() =>
|
||||
this.store.select(selectors.selectTimer).pipe(
|
||||
skipDuringSync(),
|
||||
withLatestFrom(this.store.select(selectors.selectMode)),
|
||||
// Only consider emissions where timer just stopped running
|
||||
distinctUntilChanged(
|
||||
([prevTimer], [currTimer]) => prevTimer.isRunning === currTimer.isRunning,
|
||||
),
|
||||
filter(
|
||||
([timer, mode]) =>
|
||||
timer.purpose === 'work' &&
|
||||
|
|
@ -75,11 +213,6 @@ export class FocusModeEffects {
|
|||
timer.elapsed >= timer.duration &&
|
||||
mode !== FocusModeMode.Flowtime, // Flowtime sessions should never auto-complete
|
||||
),
|
||||
distinctUntilChanged(
|
||||
([prevTimer], [currTimer]) =>
|
||||
prevTimer.elapsed === currTimer.elapsed &&
|
||||
prevTimer.startedAt === currTimer.startedAt,
|
||||
),
|
||||
map(() => actions.completeFocusSession({ isManual: false })),
|
||||
),
|
||||
);
|
||||
|
|
@ -114,8 +247,10 @@ export class FocusModeEffects {
|
|||
withLatestFrom(
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectCurrentCycle),
|
||||
this.store.select(selectFocusModeConfig),
|
||||
this.taskService.currentTaskId$,
|
||||
),
|
||||
switchMap(([action, mode, cycle]) => {
|
||||
switchMap(([action, mode, cycle, focusModeConfig, currentTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
const actionsToDispatch: any[] = [];
|
||||
|
||||
|
|
@ -130,6 +265,13 @@ export class FocusModeEffects {
|
|||
// Check if we should start a break - only for automatic completions
|
||||
// Manual completions should stay on SessionDone screen
|
||||
if (!action.isManual && strategy.shouldStartBreakAfterSession) {
|
||||
// Pause task tracking during break if enabled
|
||||
const shouldPauseTracking =
|
||||
focusModeConfig?.isPauseTrackingDuringBreak && currentTaskId;
|
||||
if (shouldPauseTracking) {
|
||||
actionsToDispatch.push(unsetCurrentTask());
|
||||
}
|
||||
|
||||
// Get break duration from strategy
|
||||
const breakInfo = strategy.getBreakDuration(cycle);
|
||||
if (breakInfo) {
|
||||
|
|
@ -137,11 +279,16 @@ export class FocusModeEffects {
|
|||
actions.startBreak({
|
||||
duration: breakInfo.duration,
|
||||
isLongBreak: breakInfo.isLong,
|
||||
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Fallback if no break info (shouldn't happen for Pomodoro)
|
||||
actionsToDispatch.push(actions.startBreak({}));
|
||||
actionsToDispatch.push(
|
||||
actions.startBreak({
|
||||
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,20 +301,29 @@ export class FocusModeEffects {
|
|||
breakComplete$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.completeBreak),
|
||||
withLatestFrom(this.store.select(selectors.selectMode)),
|
||||
switchMap(([_, mode]) => {
|
||||
withLatestFrom(
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
),
|
||||
switchMap(([_, mode, pausedTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
const actionsToDispatch: any[] = [];
|
||||
|
||||
// Show notification (sound + window focus)
|
||||
this._notifyUser();
|
||||
|
||||
// Resume task tracking if we paused it during break
|
||||
if (pausedTaskId) {
|
||||
actionsToDispatch.push(setCurrentTask({ id: pausedTaskId }));
|
||||
}
|
||||
|
||||
// Auto-start next session if configured
|
||||
if (strategy.shouldAutoStartNextSession) {
|
||||
const duration = strategy.initialSessionDuration;
|
||||
return of(actions.startFocusSession({ duration }));
|
||||
actionsToDispatch.push(actions.startFocusSession({ duration }));
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
return actionsToDispatch.length > 0 ? of(...actionsToDispatch) : EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
@ -176,17 +332,26 @@ export class FocusModeEffects {
|
|||
skipBreak$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.skipBreak),
|
||||
withLatestFrom(this.store.select(selectors.selectMode)),
|
||||
switchMap(([_, mode]) => {
|
||||
withLatestFrom(
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
),
|
||||
switchMap(([_, mode, pausedTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
const actionsToDispatch: any[] = [];
|
||||
|
||||
// Resume task tracking if we paused it during break
|
||||
if (pausedTaskId) {
|
||||
actionsToDispatch.push(setCurrentTask({ id: pausedTaskId }));
|
||||
}
|
||||
|
||||
// Auto-start next session if configured
|
||||
if (strategy.shouldAutoStartNextSession) {
|
||||
const duration = strategy.initialSessionDuration;
|
||||
return of(actions.startFocusSession({ duration }));
|
||||
actionsToDispatch.push(actions.startFocusSession({ duration }));
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
return actionsToDispatch.length > 0 ? of(...actionsToDispatch) : EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
@ -203,7 +368,10 @@ export class FocusModeEffects {
|
|||
pauseOnIdle$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(openIdleDialog),
|
||||
map(() => actions.pauseFocusSession()),
|
||||
withLatestFrom(this.taskService.currentTaskId$),
|
||||
map(([_, currentTaskId]) =>
|
||||
actions.pauseFocusSession({ pausedTaskId: currentTaskId }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -289,6 +457,44 @@ export class FocusModeEffects {
|
|||
),
|
||||
);
|
||||
|
||||
// Sync duration when Pomodoro settings change (only for unstarted sessions)
|
||||
syncDurationWithPomodoroConfig$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(updateGlobalConfigSection),
|
||||
filter(({ sectionKey }) => sectionKey === 'pomodoro'),
|
||||
withLatestFrom(
|
||||
this.store.select(selectors.selectTimer),
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectPomodoroConfig),
|
||||
),
|
||||
switchMap(([_action, timer, mode, pomodoroConfig]) => {
|
||||
// Only sync if session hasn't started yet
|
||||
if (timer.purpose !== null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
// Only sync for Pomodoro mode
|
||||
if (mode !== FocusModeMode.Pomodoro) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const newDuration = pomodoroConfig?.duration;
|
||||
|
||||
// Only sync if duration is valid and divisible by 1000 (whole seconds)
|
||||
if (
|
||||
typeof newDuration !== 'number' ||
|
||||
newDuration <= 0 ||
|
||||
newDuration % 1000 !== 0 ||
|
||||
newDuration === timer.duration
|
||||
) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return of(actions.setFocusSessionDuration({ focusSessionDuration: newDuration }));
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Electron-specific effects
|
||||
setTaskBarProgress$ =
|
||||
IS_ELECTRON &&
|
||||
|
|
@ -327,35 +533,25 @@ export class FocusModeEffects {
|
|||
this.store.select(selectors.selectIsSessionRunning),
|
||||
this.store.select(selectors.selectIsBreakActive),
|
||||
this.store.select(selectors.selectIsSessionCompleted),
|
||||
this.store.select(selectors.selectIsSessionPaused),
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectCurrentCycle),
|
||||
this.store.select(selectors.selectIsOverlayShown),
|
||||
this.store.select(selectors.selectTimer),
|
||||
this.store.select(selectFocusModeConfig),
|
||||
]).pipe(
|
||||
skipDuringSync(),
|
||||
map(
|
||||
(
|
||||
values,
|
||||
): [boolean, boolean, boolean, FocusModeMode, number, boolean, TimerState] =>
|
||||
values as [
|
||||
boolean,
|
||||
boolean,
|
||||
boolean,
|
||||
FocusModeMode,
|
||||
number,
|
||||
boolean,
|
||||
TimerState,
|
||||
],
|
||||
),
|
||||
tap(
|
||||
([
|
||||
isSessionRunning,
|
||||
isOnBreak,
|
||||
isSessionCompleted,
|
||||
isSessionPaused,
|
||||
mode,
|
||||
cycle,
|
||||
isOverlayShown,
|
||||
timer,
|
||||
focusModeConfig,
|
||||
]) => {
|
||||
// Only show banner when overlay is hidden
|
||||
if (isOverlayShown) {
|
||||
|
|
@ -363,7 +559,22 @@ export class FocusModeEffects {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isSessionRunning || isOnBreak || isSessionCompleted) {
|
||||
// In background mode, also show banner when paused
|
||||
const useIconButtons = focusModeConfig?.isStartInBackground;
|
||||
const shouldShowBanner =
|
||||
isSessionRunning ||
|
||||
isOnBreak ||
|
||||
isSessionCompleted ||
|
||||
(useIconButtons && isSessionPaused);
|
||||
|
||||
// Check if break time is up (needed for both banner display and button actions)
|
||||
const isBreakTimeUp =
|
||||
timer.purpose === 'break' &&
|
||||
!timer.isRunning &&
|
||||
timer.duration > 0 &&
|
||||
timer.elapsed >= timer.duration;
|
||||
|
||||
if (shouldShowBanner) {
|
||||
// Determine banner message based on session type
|
||||
let translationKey: string;
|
||||
let icon: string;
|
||||
|
|
@ -380,13 +591,6 @@ export class FocusModeEffects {
|
|||
timer$ = undefined; // No timer needed for completed state
|
||||
progress$ = undefined; // No progress bar needed
|
||||
} else if (isOnBreak) {
|
||||
// Check if break time is up
|
||||
const isBreakTimeUp =
|
||||
timer.purpose === 'break' &&
|
||||
!timer.isRunning &&
|
||||
timer.duration > 0 &&
|
||||
timer.elapsed >= timer.duration;
|
||||
|
||||
if (isBreakTimeUp) {
|
||||
// Break is done - time is up
|
||||
translationKey = T.F.POMODORO.BREAK_IS_DONE;
|
||||
|
|
@ -429,23 +633,16 @@ export class FocusModeEffects {
|
|||
translateParams,
|
||||
timer$,
|
||||
progress$,
|
||||
action2: {
|
||||
label: T.F.FOCUS_MODE.B.TO_FOCUS_OVERLAY,
|
||||
fn: () => {
|
||||
this.store.dispatch(showFocusOverlay());
|
||||
},
|
||||
},
|
||||
// Only show Cancel button when session is not completed
|
||||
...(isSessionCompleted
|
||||
? {}
|
||||
: {
|
||||
action: {
|
||||
label: T.G.CANCEL,
|
||||
fn: () => {
|
||||
this.store.dispatch(cancelFocusSession());
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Hide dismiss button in icon button mode (banner-only mode)
|
||||
isHideDismissBtn: useIconButtons,
|
||||
...(useIconButtons
|
||||
? this._getIconButtonActions(
|
||||
timer,
|
||||
isOnBreak,
|
||||
isSessionCompleted,
|
||||
isBreakTimeUp,
|
||||
)
|
||||
: this._getTextButtonActions(isSessionCompleted)),
|
||||
});
|
||||
} else {
|
||||
this.bannerService.dismiss(BannerId.FocusMode);
|
||||
|
|
@ -456,6 +653,130 @@ export class FocusModeEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
private _getTextButtonActions(
|
||||
isSessionCompleted: boolean,
|
||||
): Pick<Banner, 'action' | 'action2' | 'action3'> {
|
||||
return {
|
||||
action2: {
|
||||
label: T.F.FOCUS_MODE.B.TO_FOCUS_OVERLAY,
|
||||
fn: () => {
|
||||
this.store.dispatch(showFocusOverlay());
|
||||
},
|
||||
},
|
||||
// Only show Cancel button when session is not completed
|
||||
...(isSessionCompleted
|
||||
? {}
|
||||
: {
|
||||
action: {
|
||||
label: T.G.CANCEL,
|
||||
fn: () => {
|
||||
this.store.dispatch(cancelFocusSession());
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private _getIconButtonActions(
|
||||
timer: TimerState,
|
||||
isOnBreak: boolean,
|
||||
isSessionCompleted: boolean,
|
||||
isBreakTimeUp: boolean,
|
||||
): Pick<Banner, 'action' | 'action2' | 'action3'> {
|
||||
const isPaused = !timer.isRunning && timer.purpose !== null;
|
||||
|
||||
// Show "Start" button when session completed OR break time is up
|
||||
// Otherwise show play/pause button
|
||||
const shouldShowStartButton = isSessionCompleted || isBreakTimeUp;
|
||||
|
||||
const playPauseAction = shouldShowStartButton
|
||||
? {
|
||||
label: T.F.FOCUS_MODE.B.START,
|
||||
icon: 'play_arrow',
|
||||
fn: () => {
|
||||
// Start a new session using the current mode's strategy
|
||||
this.store
|
||||
.select(selectors.selectMode)
|
||||
.pipe(take(1))
|
||||
.subscribe((mode) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
this.store.dispatch(
|
||||
actions.startFocusSession({
|
||||
duration: strategy.initialSessionDuration,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: isPaused ? T.F.FOCUS_MODE.B.RESUME : T.F.FOCUS_MODE.B.PAUSE,
|
||||
icon: isPaused ? 'play_arrow' : 'pause',
|
||||
fn: () => {
|
||||
if (isPaused) {
|
||||
this.store.dispatch(actions.unPauseFocusSession());
|
||||
} else {
|
||||
// Pass current task ID so it can be restored on resume
|
||||
const currentTaskId = this.taskService.currentTaskId();
|
||||
this.store.dispatch(
|
||||
actions.pauseFocusSession({ pausedTaskId: currentTaskId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// End session button - complete for work, skip for break (while running)
|
||||
// Hide when session is completed, break time is up, or during active break
|
||||
const endAction =
|
||||
shouldShowStartButton || isOnBreak
|
||||
? undefined
|
||||
: {
|
||||
label: T.F.FOCUS_MODE.B.END_SESSION,
|
||||
icon: 'done_all',
|
||||
fn: () => {
|
||||
this.store.dispatch(actions.completeFocusSession({ isManual: true }));
|
||||
},
|
||||
};
|
||||
|
||||
// Open overlay button
|
||||
const overlayAction = {
|
||||
label: T.F.FOCUS_MODE.B.TO_FOCUS_OVERLAY,
|
||||
icon: 'fullscreen',
|
||||
fn: () => {
|
||||
this.store.dispatch(showFocusOverlay());
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
action: playPauseAction,
|
||||
action2: endAction,
|
||||
action3: overlayAction,
|
||||
};
|
||||
}
|
||||
|
||||
// Play ticking sound during focus sessions if enabled
|
||||
playTickSound$ = createEffect(
|
||||
() =>
|
||||
this.store.select(selectors.selectTimer).pipe(
|
||||
filter(
|
||||
(timer) => timer.isRunning && timer.purpose === 'work' && timer.elapsed > 0,
|
||||
),
|
||||
// Only emit when we cross a second boundary
|
||||
distinctUntilChanged(
|
||||
(prev, curr) =>
|
||||
Math.floor(prev.elapsed / 1000) === Math.floor(curr.elapsed / 1000),
|
||||
),
|
||||
withLatestFrom(this.store.select(selectFocusModeConfig)),
|
||||
tap(([, focusModeConfig]) => {
|
||||
const soundVolume = this.globalConfigService.sound()?.volume || 0;
|
||||
if (focusModeConfig?.isPlayTick && soundVolume > 0) {
|
||||
// Play at reduced volume (40% of main volume) to not be too intrusive
|
||||
playSound(TICK_SOUND, Math.round(soundVolume * 0.4));
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
private _notifyUser(isHideBar = false): void {
|
||||
const soundVolume = this.globalConfigService.sound()?.volume || 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -154,13 +154,13 @@ describe('FocusModeReducer', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const action = a.pauseFocusSession();
|
||||
const action = a.pauseFocusSession({ pausedTaskId: null });
|
||||
const result = focusModeReducer(runningState, action);
|
||||
|
||||
expect(result.timer.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should not pause non-work sessions', () => {
|
||||
it('should pause break sessions', () => {
|
||||
const breakState = {
|
||||
...initialState,
|
||||
timer: {
|
||||
|
|
@ -172,10 +172,48 @@ describe('FocusModeReducer', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const action = a.pauseFocusSession();
|
||||
const action = a.pauseFocusSession({ pausedTaskId: null });
|
||||
const result = focusModeReducer(breakState, action);
|
||||
|
||||
expect(result.timer.isRunning).toBe(true);
|
||||
expect(result.timer.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should not pause sessions with no purpose (idle)', () => {
|
||||
const idleState = {
|
||||
...initialState,
|
||||
timer: {
|
||||
isRunning: false,
|
||||
startedAt: null,
|
||||
elapsed: 0,
|
||||
duration: 0,
|
||||
purpose: null,
|
||||
},
|
||||
};
|
||||
|
||||
const action = a.pauseFocusSession({ pausedTaskId: null });
|
||||
const result = focusModeReducer(idleState, action);
|
||||
|
||||
expect(result).toBe(idleState);
|
||||
});
|
||||
|
||||
it('should store pausedTaskId when provided', () => {
|
||||
const runningState = {
|
||||
...initialState,
|
||||
timer: {
|
||||
isRunning: true,
|
||||
startedAt: Date.now(),
|
||||
elapsed: 0,
|
||||
duration: 1500000,
|
||||
purpose: 'work' as const,
|
||||
},
|
||||
pausedTaskId: null,
|
||||
};
|
||||
|
||||
const action = a.pauseFocusSession({ pausedTaskId: 'task-123' });
|
||||
const result = focusModeReducer(runningState, action);
|
||||
|
||||
expect(result.timer.isRunning).toBe(false);
|
||||
expect(result.pausedTaskId).toBe('task-123');
|
||||
});
|
||||
|
||||
it('should unpause focus session', () => {
|
||||
|
|
@ -196,6 +234,42 @@ describe('FocusModeReducer', () => {
|
|||
expect(result.timer.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('should unpause break session', () => {
|
||||
const pausedBreakState = {
|
||||
...initialState,
|
||||
timer: {
|
||||
isRunning: false,
|
||||
startedAt: Date.now() - 60000,
|
||||
elapsed: 60000,
|
||||
duration: 300000,
|
||||
purpose: 'break' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const action = a.unPauseFocusSession();
|
||||
const result = focusModeReducer(pausedBreakState, action);
|
||||
|
||||
expect(result.timer.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('should not unpause sessions with no purpose (idle)', () => {
|
||||
const idleState = {
|
||||
...initialState,
|
||||
timer: {
|
||||
isRunning: false,
|
||||
startedAt: null,
|
||||
elapsed: 0,
|
||||
duration: 0,
|
||||
purpose: null,
|
||||
},
|
||||
};
|
||||
|
||||
const action = a.unPauseFocusSession();
|
||||
const result = focusModeReducer(idleState, action);
|
||||
|
||||
expect(result).toBe(idleState);
|
||||
});
|
||||
|
||||
it('should complete focus session', () => {
|
||||
const runningState = {
|
||||
...initialState,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const initialState: FocusModeState = {
|
|||
: FocusModeMode.Countdown,
|
||||
currentCycle: 1,
|
||||
lastCompletedDuration: 0,
|
||||
pausedTaskId: null,
|
||||
};
|
||||
|
||||
const createWorkTimer = (duration: number): TimerState => ({
|
||||
|
|
@ -117,8 +118,9 @@ export const focusModeReducer = createReducer(
|
|||
};
|
||||
}),
|
||||
|
||||
on(a.pauseFocusSession, (state) => {
|
||||
if (state.timer.purpose !== 'work') return state;
|
||||
on(a.pauseFocusSession, (state, { pausedTaskId }) => {
|
||||
// Allow pausing both work sessions and breaks
|
||||
if (state.timer.purpose === null) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -126,11 +128,14 @@ export const focusModeReducer = createReducer(
|
|||
...state.timer,
|
||||
isRunning: false,
|
||||
},
|
||||
// Store paused task ID if provided (for sync feature)
|
||||
pausedTaskId: pausedTaskId ?? state.pausedTaskId,
|
||||
};
|
||||
}),
|
||||
|
||||
on(a.unPauseFocusSession, (state) => {
|
||||
if (state.timer.purpose !== 'work') return state;
|
||||
// Allow resuming both work sessions and breaks
|
||||
if (state.timer.purpose === null) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -160,10 +165,11 @@ export const focusModeReducer = createReducer(
|
|||
currentScreen: FocusScreen.Main,
|
||||
mainState: FocusMainUIState.Preparation,
|
||||
isOverlayShown: false,
|
||||
pausedTaskId: null,
|
||||
})),
|
||||
|
||||
// Break handling
|
||||
on(a.startBreak, (state, { duration, isLongBreak }) => {
|
||||
on(a.startBreak, (state, { duration, isLongBreak, pausedTaskId }) => {
|
||||
const timer = createBreakTimer(
|
||||
duration || FOCUS_MODE_DEFAULTS.SHORT_BREAK_DURATION,
|
||||
isLongBreak || false,
|
||||
|
|
@ -174,6 +180,7 @@ export const focusModeReducer = createReducer(
|
|||
timer,
|
||||
currentScreen: FocusScreen.Break,
|
||||
mainState: FocusMainUIState.Preparation,
|
||||
pausedTaskId: pausedTaskId ?? state.pausedTaskId,
|
||||
};
|
||||
}),
|
||||
|
||||
|
|
@ -182,6 +189,7 @@ export const focusModeReducer = createReducer(
|
|||
timer: createIdleTimer(),
|
||||
currentScreen: FocusScreen.Main,
|
||||
mainState: FocusMainUIState.Preparation,
|
||||
pausedTaskId: null,
|
||||
})),
|
||||
|
||||
// Timer updates - much simpler!
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe('FocusModeSelectors', () => {
|
|||
mode: FocusModeMode.Pomodoro,
|
||||
currentCycle: 1,
|
||||
lastCompletedDuration: 0,
|
||||
pausedTaskId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -84,3 +84,9 @@ export const selectIsSessionCompleted = createSelector(
|
|||
selectCurrentScreen,
|
||||
(currentScreen) => currentScreen === 'SessionDone',
|
||||
);
|
||||
|
||||
// Paused task during breaks
|
||||
export const selectPausedTaskId = createSelector(
|
||||
selectFocusModeState,
|
||||
(state) => state.pausedTaskId,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,4 +89,11 @@
|
|||
<mat-icon svgIcon="linear"></mat-icon>
|
||||
<span>Linear</span>
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
(click)="openSetupDialog('CLICKUP')"
|
||||
>
|
||||
<mat-icon svgIcon="clickup"></mat-icon>
|
||||
<span>ClickUp</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -125,6 +125,14 @@
|
|||
></trello-additional-cfg>
|
||||
<!-- -->
|
||||
}
|
||||
@case ('CLICKUP') {
|
||||
<clickup-additional-cfg
|
||||
[section]="configFormSection"
|
||||
[cfg]="model"
|
||||
(modelChange)="customCfgCmpSave($event)"
|
||||
></clickup-additional-cfg>
|
||||
<!-- -->
|
||||
}
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
|||
import { devError } from '../../../util/dev-error';
|
||||
import { IssueLog } from '../../../core/log';
|
||||
import { TrelloAdditionalCfgComponent } from '../providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component';
|
||||
import { ClickUpAdditionalCfgComponent } from '../providers/clickup/clickup-view-components/clickup-cfg/clickup-additional-cfg.component';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
|
|
@ -70,6 +71,7 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'
|
|||
MatIcon,
|
||||
MatDialogTitle,
|
||||
TrelloAdditionalCfgComponent, // added for custom trello board loading support
|
||||
ClickUpAdditionalCfgComponent, // added for custom clickup workspace selection
|
||||
],
|
||||
templateUrl: './dialog-edit-issue-provider.component.html',
|
||||
styleUrl: './dialog-edit-issue-provider.component.scss',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { REDMINE_ISSUE_CONTENT_CONFIG } from '../providers/redmine/redmine-issue
|
|||
import { OPEN_PROJECT_ISSUE_CONTENT_CONFIG } from '../providers/open-project/open-project-issue-content.const';
|
||||
import { TRELLO_ISSUE_CONTENT_CONFIG } from '../providers/trello/trello-issue-content.const';
|
||||
import { LINEAR_ISSUE_CONTENT_CONFIG } from '../providers/linear/linear-issue-content.const';
|
||||
import { CLICKUP_ISSUE_CONTENT_CONFIG } from '../providers/clickup/clickup-issue-content.const';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export { IssueFieldType, IssueFieldConfig, IssueCommentConfig, IssueContentConfig };
|
||||
|
|
@ -28,6 +29,7 @@ export const ISSUE_CONTENT_CONFIGS: Record<IssueProviderKey, IssueContentConfig<
|
|||
OPEN_PROJECT: OPEN_PROJECT_ISSUE_CONTENT_CONFIG,
|
||||
TRELLO: TRELLO_ISSUE_CONTENT_CONFIG,
|
||||
LINEAR: LINEAR_ISSUE_CONTENT_CONFIG,
|
||||
CLICKUP: CLICKUP_ISSUE_CONTENT_CONFIG,
|
||||
ICAL: {
|
||||
issueType: 'ICAL',
|
||||
fields: [],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ export interface IssueServiceInterface {
|
|||
allExistingIssueIds: number[] | string[],
|
||||
): Promise<IssueDataReduced[]>;
|
||||
|
||||
getSubTasks?(
|
||||
issueId: string | number,
|
||||
issueProviderId: string,
|
||||
issue: IssueDataReduced,
|
||||
): Promise<IssueDataReduced[]>;
|
||||
|
||||
// TODO could be called when task is updated from issue, whenever task is updated
|
||||
updateIssueFromTask?(task: Task): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ import {
|
|||
DEFAULT_LINEAR_CFG,
|
||||
LINEAR_CONFIG_FORM_SECTION,
|
||||
} from './providers/linear/linear.const';
|
||||
import {
|
||||
DEFAULT_CLICKUP_CFG,
|
||||
CLICKUP_CONFIG_FORM_SECTION,
|
||||
} from './providers/clickup/clickup.const';
|
||||
|
||||
export const DELAY_BEFORE_ISSUE_POLLING = 8000;
|
||||
|
||||
|
|
@ -50,6 +54,7 @@ export const REDMINE_TYPE: IssueProviderKey = 'REDMINE';
|
|||
export const ICAL_TYPE: IssueProviderKey = 'ICAL';
|
||||
export const TRELLO_TYPE: IssueProviderKey = 'TRELLO';
|
||||
export const LINEAR_TYPE: IssueProviderKey = 'LINEAR';
|
||||
export const CLICKUP_TYPE: IssueProviderKey = 'CLICKUP';
|
||||
|
||||
export const ISSUE_PROVIDER_TYPES: IssueProviderKey[] = [
|
||||
GITLAB_TYPE,
|
||||
|
|
@ -62,6 +67,7 @@ export const ISSUE_PROVIDER_TYPES: IssueProviderKey[] = [
|
|||
TRELLO_TYPE,
|
||||
REDMINE_TYPE,
|
||||
LINEAR_TYPE,
|
||||
CLICKUP_TYPE,
|
||||
] as const;
|
||||
|
||||
export const ISSUE_PROVIDER_ICON_MAP = {
|
||||
|
|
@ -75,6 +81,7 @@ export const ISSUE_PROVIDER_ICON_MAP = {
|
|||
[TRELLO_TYPE]: 'trello',
|
||||
[REDMINE_TYPE]: 'redmine',
|
||||
[LINEAR_TYPE]: 'linear',
|
||||
[CLICKUP_TYPE]: 'clickup',
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_HUMANIZED = {
|
||||
|
|
@ -88,6 +95,7 @@ export const ISSUE_PROVIDER_HUMANIZED = {
|
|||
[TRELLO_TYPE]: 'Trello',
|
||||
[REDMINE_TYPE]: 'Redmine',
|
||||
[LINEAR_TYPE]: 'Linear',
|
||||
[CLICKUP_TYPE]: 'ClickUp',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ISSUE_PROVIDER_CFGS = {
|
||||
|
|
@ -101,6 +109,7 @@ export const DEFAULT_ISSUE_PROVIDER_CFGS = {
|
|||
[TRELLO_TYPE]: DEFAULT_TRELLO_CFG,
|
||||
[REDMINE_TYPE]: DEFAULT_REDMINE_CFG,
|
||||
[LINEAR_TYPE]: DEFAULT_LINEAR_CFG,
|
||||
[CLICKUP_TYPE]: DEFAULT_CLICKUP_CFG,
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
|
||||
|
|
@ -114,6 +123,7 @@ export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
|
|||
[TRELLO_TYPE]: TRELLO_CONFIG_FORM_SECTION,
|
||||
[REDMINE_TYPE]: REDMINE_CONFIG_FORM_SECTION,
|
||||
[LINEAR_TYPE]: LINEAR_CONFIG_FORM_SECTION,
|
||||
[CLICKUP_TYPE]: CLICKUP_CONFIG_FORM_SECTION,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_ISSUE_STRS: { ISSUE_STR: string; ISSUES_STR: string } = {
|
||||
|
|
@ -139,6 +149,7 @@ export const ISSUE_STR_MAP: { [key: string]: { ISSUE_STR: string; ISSUES_STR: st
|
|||
[TRELLO_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[REDMINE_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[LINEAR_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[CLICKUP_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit<
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { TrelloCfg } from './providers/trello/trello.model';
|
|||
import { TrelloIssue, TrelloIssueReduced } from './providers/trello/trello-issue.model';
|
||||
import { LinearCfg } from './providers/linear/linear.model';
|
||||
import { LinearIssue, LinearIssueReduced } from './providers/linear/linear-issue.model';
|
||||
import { ClickUpCfg } from './providers/clickup/clickup.model';
|
||||
import { ClickUpTask, ClickUpTaskReduced } from './providers/clickup/clickup-issue.model';
|
||||
import { EntityState } from '@ngrx/entity';
|
||||
import {
|
||||
CalendarProviderCfg,
|
||||
|
|
@ -41,7 +43,8 @@ export type IssueProviderKey =
|
|||
| 'GITEA'
|
||||
| 'TRELLO'
|
||||
| 'REDMINE'
|
||||
| 'LINEAR';
|
||||
| 'LINEAR'
|
||||
| 'CLICKUP';
|
||||
|
||||
export type IssueIntegrationCfg =
|
||||
| JiraCfg
|
||||
|
|
@ -53,7 +56,8 @@ export type IssueIntegrationCfg =
|
|||
| GiteaCfg
|
||||
| TrelloCfg
|
||||
| RedmineCfg
|
||||
| LinearCfg;
|
||||
| LinearCfg
|
||||
| ClickUpCfg;
|
||||
|
||||
export enum IssueLocalState {
|
||||
OPEN = 'OPEN',
|
||||
|
|
@ -73,6 +77,7 @@ export interface IssueIntegrationCfgs {
|
|||
GITEA?: GiteaCfg;
|
||||
REDMINE?: RedmineCfg;
|
||||
LINEAR?: LinearCfg;
|
||||
CLICKUP?: ClickUpCfg;
|
||||
}
|
||||
|
||||
export type IssueData =
|
||||
|
|
@ -85,7 +90,8 @@ export type IssueData =
|
|||
| GiteaIssue
|
||||
| RedmineIssue
|
||||
| TrelloIssue
|
||||
| LinearIssue;
|
||||
| LinearIssue
|
||||
| ClickUpTask;
|
||||
|
||||
export type IssueDataReduced =
|
||||
| GithubIssueReduced
|
||||
|
|
@ -97,7 +103,8 @@ export type IssueDataReduced =
|
|||
| GiteaIssue
|
||||
| RedmineIssue
|
||||
| TrelloIssueReduced
|
||||
| LinearIssueReduced;
|
||||
| LinearIssueReduced
|
||||
| ClickUpTaskReduced;
|
||||
|
||||
export type IssueDataReducedMap = {
|
||||
[K in IssueProviderKey]: K extends 'JIRA'
|
||||
|
|
@ -120,7 +127,9 @@ export type IssueDataReducedMap = {
|
|||
? RedmineIssue
|
||||
: K extends 'LINEAR'
|
||||
? LinearIssueReduced
|
||||
: never;
|
||||
: K extends 'CLICKUP'
|
||||
? ClickUpTaskReduced
|
||||
: never;
|
||||
};
|
||||
|
||||
// TODO: add issue model to the IssueDataReducedMap
|
||||
|
|
@ -201,6 +210,10 @@ export interface IssueProviderLinear extends IssueProviderBase, LinearCfg {
|
|||
issueProviderKey: 'LINEAR';
|
||||
}
|
||||
|
||||
export interface IssueProviderClickUp extends IssueProviderBase, ClickUpCfg {
|
||||
issueProviderKey: 'CLICKUP';
|
||||
}
|
||||
|
||||
export type IssueProvider =
|
||||
| IssueProviderJira
|
||||
| IssueProviderGithub
|
||||
|
|
@ -211,7 +224,8 @@ export type IssueProvider =
|
|||
| IssueProviderGitea
|
||||
| IssueProviderRedmine
|
||||
| IssueProviderTrello
|
||||
| IssueProviderLinear;
|
||||
| IssueProviderLinear
|
||||
| IssueProviderClickUp;
|
||||
|
||||
export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
|
||||
? IssueProviderJira
|
||||
|
|
@ -233,4 +247,6 @@ export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
|
|||
? IssueProviderTrello
|
||||
: T extends 'LINEAR'
|
||||
? IssueProviderLinear
|
||||
: never;
|
||||
: T extends 'CLICKUP'
|
||||
? IssueProviderClickUp
|
||||
: never;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
TRELLO_TYPE,
|
||||
REDMINE_TYPE,
|
||||
LINEAR_TYPE,
|
||||
CLICKUP_TYPE,
|
||||
} from './issue.const';
|
||||
import { TaskService } from '../tasks/task.service';
|
||||
import { IssueTask, Task, TaskCopy } from '../tasks/task.model';
|
||||
|
|
@ -38,6 +39,7 @@ import { OpenProjectCommonInterfacesService } from './providers/open-project/ope
|
|||
import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service';
|
||||
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
|
||||
import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
|
||||
import { ClickUpCommonInterfacesService } from './providers/clickup/clickup-common-interfaces.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
|
@ -71,6 +73,7 @@ export class IssueService {
|
|||
private _giteaInterfaceService = inject(GiteaCommonInterfacesService);
|
||||
private _redmineInterfaceService = inject(RedmineCommonInterfacesService);
|
||||
private _linearCommonInterfaceService = inject(LinearCommonInterfacesService);
|
||||
private _clickUpCommonInterfaceService = inject(ClickUpCommonInterfacesService);
|
||||
private _calendarCommonInterfaceService = inject(CalendarCommonInterfacesService);
|
||||
private _issueProviderService = inject(IssueProviderService);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
|
|
@ -91,6 +94,7 @@ export class IssueService {
|
|||
[REDMINE_TYPE]: this._redmineInterfaceService,
|
||||
[ICAL_TYPE]: this._calendarCommonInterfaceService,
|
||||
[LINEAR_TYPE]: this._linearCommonInterfaceService,
|
||||
[CLICKUP_TYPE]: this._clickUpCommonInterfaceService,
|
||||
|
||||
// trello
|
||||
[TRELLO_TYPE]: this._trelloCommonInterfacesService,
|
||||
|
|
@ -524,11 +528,61 @@ export class IssueService {
|
|||
issueDataReduced as ICalIssueReduced,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle subtasks if provider supports it
|
||||
if (this.ISSUE_SERVICE_MAP[issueProviderKey].getSubTasks && taskId) {
|
||||
await this._addSubTasks(
|
||||
issueDataReduced,
|
||||
taskId,
|
||||
issueProviderId,
|
||||
issueProviderKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private async _addSubTasks(
|
||||
issueDataReduced: IssueDataReduced,
|
||||
parentTaskId: string,
|
||||
issueProviderId: string,
|
||||
issueProviderKey: IssueProviderKey,
|
||||
): Promise<void> {
|
||||
const provider = this.ISSUE_SERVICE_MAP[issueProviderKey];
|
||||
if (!provider.getSubTasks) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const subtasks = await provider.getSubTasks(
|
||||
issueDataReduced.id,
|
||||
issueProviderId,
|
||||
issueDataReduced,
|
||||
);
|
||||
|
||||
if (!subtasks || subtasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const subtask of subtasks) {
|
||||
const subTaskData = this._getAddTaskData(issueProviderKey, subtask);
|
||||
const { title: subTaskTitle, ...subTaskAdditional } = subTaskData;
|
||||
|
||||
await this._taskService.addSubTaskTo(parentTaskId, {
|
||||
title: subTaskTitle,
|
||||
issueType: issueProviderKey,
|
||||
issueProviderId: issueProviderId,
|
||||
issueId: subtask.id.toString(),
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: Date.now(),
|
||||
...subTaskAdditional,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
IssueLog.warn('Failed to add subtasks for ' + issueProviderKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
private async _tryAddSubTask({
|
||||
title,
|
||||
taskData,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing';
|
||||
import { ClickUpApiService } from './clickup-api.service';
|
||||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
import { ClickUpCfg } from './clickup.model';
|
||||
import {
|
||||
ClickUpTaskReduced,
|
||||
ClickUpTask,
|
||||
ClickUpTeamsResponse,
|
||||
ClickUpUserResponse,
|
||||
ClickUpTaskSearchResponse,
|
||||
} from './clickup-issue.model';
|
||||
import typia from 'typia';
|
||||
import { CLICKUP_HEADER_RATE_LIMIT_RESET } from './clickup.const';
|
||||
|
||||
const CLICKUP_API_URL = 'https://api.clickup.com/api/v2';
|
||||
|
||||
describe('ClickUpApiService', () => {
|
||||
let service: ClickUpApiService;
|
||||
let httpMock: HttpTestingController;
|
||||
let snackServiceMock: Partial<SnackService>;
|
||||
|
||||
const mockCfg: ClickUpCfg = {
|
||||
apiKey: 'TEST_API_KEY',
|
||||
isEnabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
snackServiceMock = {
|
||||
open: jasmine.createSpy('open'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
ClickUpApiService,
|
||||
{ provide: SnackService, useValue: snackServiceMock },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ClickUpApiService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getById$', () => {
|
||||
it('should fetch a task by ID', () => {
|
||||
const mockTask: ClickUpTask = {
|
||||
...typia.random<ClickUpTask>(),
|
||||
id: 'TASK_ID',
|
||||
name: 'Task Name',
|
||||
};
|
||||
|
||||
service.getById$('TASK_ID', mockCfg).subscribe((task) => {
|
||||
expect(task).toEqual(mockTask);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/task/TASK_ID`,
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.params.get('include_markdown_description')).toBe('true');
|
||||
expect(req.request.params.get('include_subtasks')).toBe('true');
|
||||
expect(req.request.headers.get('Authorization')).toBe(mockCfg.apiKey!);
|
||||
req.flush(mockTask);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthorizedTeams$', () => {
|
||||
it('should fetch and map authorized teams', () => {
|
||||
const mockResponse: ClickUpTeamsResponse = {
|
||||
...typia.random<ClickUpTeamsResponse>(),
|
||||
teams: [
|
||||
{
|
||||
...typia.random<ClickUpTeamsResponse['teams'][0]>(),
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
},
|
||||
{
|
||||
...typia.random<ClickUpTeamsResponse['teams'][0]>(),
|
||||
id: '2',
|
||||
name: 'Team 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expectedTeams = mockResponse.teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
service.getAuthorizedTeams$(mockCfg).subscribe((teams) => {
|
||||
expect(teams).toEqual(expectedTeams);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team`,
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Authorization')).toBe(mockCfg.apiKey!);
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser$', () => {
|
||||
it('should fetch current user', () => {
|
||||
const mockUserResponse: ClickUpUserResponse = {
|
||||
user: {
|
||||
...typia.random<ClickUpUserResponse['user']>(),
|
||||
id: 123,
|
||||
username: 'Test User',
|
||||
},
|
||||
};
|
||||
|
||||
service.getCurrentUser$(mockCfg).subscribe((user) => {
|
||||
expect(user).toEqual(mockUserResponse);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/user`,
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Authorization')).toBe(mockCfg.apiKey!);
|
||||
req.flush(mockUserResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTasks$', () => {
|
||||
const mockTasksResponse: ClickUpTaskSearchResponse = {
|
||||
...typia.random<ClickUpTaskSearchResponse>(),
|
||||
tasks: [
|
||||
{
|
||||
...typia.random<ClickUpTask>(),
|
||||
id: '1',
|
||||
name: 'Task 1',
|
||||
},
|
||||
{
|
||||
...typia.random<ClickUpTask>(),
|
||||
id: '2',
|
||||
name: 'Task 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should search tasks in specific teams when teamIds are provided', () => {
|
||||
const cfgWithTeams: ClickUpCfg = { ...mockCfg, teamIds: ['T1', 'T2'] };
|
||||
const expectedTasks: ClickUpTaskReduced[] = mockTasksResponse.tasks.map((task) => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
status: task.status,
|
||||
date_updated: task.date_updated,
|
||||
url: task.url,
|
||||
custom_id: task.custom_id,
|
||||
}));
|
||||
|
||||
service.searchTasks$('Task', cfgWithTeams).subscribe((tasks) => {
|
||||
// We expect flattened results from 2 teams, each returning same mock data for this test
|
||||
expect(tasks.length).toBe(4);
|
||||
expect(tasks[0].id).toBe(expectedTasks[0].id);
|
||||
expect(tasks[0].name).toBe(expectedTasks[0].name);
|
||||
expect(tasks[0].status).toEqual(expectedTasks[0].status);
|
||||
expect(tasks[0].custom_id).toBe(expectedTasks[0].custom_id);
|
||||
});
|
||||
|
||||
const req1 = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team/T1/task`,
|
||||
);
|
||||
expect(req1.request.method).toBe('GET');
|
||||
expect(req1.request.params.get('page')).toBe('0');
|
||||
expect(req1.request.params.get('subtasks')).toBe('true');
|
||||
req1.flush(mockTasksResponse);
|
||||
|
||||
const req2 = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team/T2/task`,
|
||||
);
|
||||
expect(req2.request.method).toBe('GET');
|
||||
expect(req2.request.params.get('page')).toBe('0');
|
||||
expect(req2.request.params.get('subtasks')).toBe('true');
|
||||
req2.flush(mockTasksResponse);
|
||||
});
|
||||
|
||||
it('should fetch authorized teams and then search in all of them when teamIds are NOT provided', () => {
|
||||
const cfgNoTeams: ClickUpCfg = { ...mockCfg, teamIds: [] };
|
||||
const teamResponse: ClickUpTeamsResponse = {
|
||||
...typia.random<ClickUpTeamsResponse>(),
|
||||
teams: [
|
||||
{
|
||||
...typia.random<ClickUpTeamsResponse['teams'][0]>(),
|
||||
id: 'T1',
|
||||
name: 'Team 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
service.searchTasks$('Task', cfgNoTeams).subscribe((tasks) => {
|
||||
expect(tasks.length).toBe(2);
|
||||
});
|
||||
|
||||
// 1. Fetch teams
|
||||
const reqTeams = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team`,
|
||||
);
|
||||
expect(reqTeams.request.method).toBe('GET');
|
||||
reqTeams.flush(teamResponse);
|
||||
|
||||
// 2. Search in T1
|
||||
const reqSearch = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team/T1/task`,
|
||||
);
|
||||
expect(reqSearch.request.method).toBe('GET');
|
||||
expect(reqSearch.request.params.get('page')).toBe('0');
|
||||
expect(reqSearch.request.params.get('subtasks')).toBe('true');
|
||||
reqSearch.flush(mockTasksResponse);
|
||||
});
|
||||
|
||||
it('should return empty array if no authorized teams found', () => {
|
||||
const cfgNoTeams: ClickUpCfg = { ...mockCfg, teamIds: [] };
|
||||
const teamResponse = { teams: [] };
|
||||
|
||||
service.searchTasks$('Task', cfgNoTeams).subscribe((tasks) => {
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
|
||||
const reqTeams = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team`,
|
||||
);
|
||||
reqTeams.flush(teamResponse);
|
||||
});
|
||||
|
||||
it('should filter tasks by search term locally', () => {
|
||||
const cfgWithTeams: ClickUpCfg = { ...mockCfg, teamIds: ['T1'] };
|
||||
|
||||
service.searchTasks$('Task 1', cfgWithTeams).subscribe((tasks) => {
|
||||
expect(tasks.length).toBe(1);
|
||||
expect(tasks[0].name).toBe('Task 1');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(request) => request.url === `${CLICKUP_API_URL}/team/T1/task`,
|
||||
);
|
||||
expect(req.request.params.get('page')).toBe('0');
|
||||
expect(req.request.params.get('subtasks')).toBe('true');
|
||||
req.flush(mockTasksResponse);
|
||||
});
|
||||
|
||||
it('should handle error in one of the team searches gracefully', () => {
|
||||
const cfgWithTeams: ClickUpCfg = { ...mockCfg, teamIds: ['T1', 'T2'] };
|
||||
|
||||
service.searchTasks$('Task', cfgWithTeams).subscribe((tasks) => {
|
||||
// T1 failed, T2 succeeded with 2 tasks
|
||||
expect(tasks.length).toBe(2);
|
||||
});
|
||||
|
||||
const req1 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/team/T1/task?page=0&subtasks=true`,
|
||||
);
|
||||
req1.flush(null, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
const req2 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/team/T2/task?page=0&subtasks=true`,
|
||||
);
|
||||
req2.flush(mockTasksResponse);
|
||||
});
|
||||
it('should retry with backoff on 429 using X-RateLimit-Reset', fakeAsync(() => {
|
||||
// Current time is X. Reset time is X + 1s.
|
||||
const resetDelay = 1000;
|
||||
const resetTime = Math.floor(Date.now() / 1000) + 1;
|
||||
|
||||
let errorResponse: any;
|
||||
service.getById$('TASK_ID', mockCfg).subscribe({
|
||||
error: (err) => (errorResponse = err),
|
||||
});
|
||||
|
||||
// Request 1: Fail with 429
|
||||
const req1 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/task/TASK_ID?include_markdown_description=true&include_subtasks=true`,
|
||||
);
|
||||
req1.flush('Rate Limit Exceeded', {
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
headers: { [CLICKUP_HEADER_RATE_LIMIT_RESET]: resetTime.toString() },
|
||||
});
|
||||
|
||||
// Should be waiting now. No new request yet.
|
||||
httpMock.expectNone(
|
||||
`${CLICKUP_API_URL}/task/TASK_ID?include_markdown_description=true&include_subtasks=true`,
|
||||
);
|
||||
|
||||
// Advance time by slightly more than resetDelay
|
||||
tick(resetDelay + 100);
|
||||
|
||||
// Request 2: Should happen now. Fail again.
|
||||
const req2 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/task/TASK_ID?include_markdown_description=true&include_subtasks=true`,
|
||||
);
|
||||
req2.flush('Rate Limit Exceeded', {
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
headers: { [CLICKUP_HEADER_RATE_LIMIT_RESET]: (resetTime + 2).toString() },
|
||||
});
|
||||
|
||||
tick(2100); // Wait for next retry
|
||||
|
||||
// Request 3: Fail again
|
||||
const req3 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/task/TASK_ID?include_markdown_description=true&include_subtasks=true`,
|
||||
);
|
||||
req3.flush('Rate Limit Exceeded', {
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
});
|
||||
|
||||
tick(5000); // Backoff wait
|
||||
|
||||
// Request 4: Final attempt (since count is 3, original + 3 retries? No, standard retry(3) is 3 retries (total 4 requests) or 3 attempts?
|
||||
// RxJS retry(3) resubscribes 3 times. Total 4 attempts.
|
||||
|
||||
const req4 = httpMock.expectOne(
|
||||
`${CLICKUP_API_URL}/task/TASK_ID?include_markdown_description=true&include_subtasks=true`,
|
||||
);
|
||||
req4.flush('Rate Limit Exceeded', { status: 429, statusText: 'Too Many Requests' });
|
||||
|
||||
// Should error out now
|
||||
expect(errorResponse).toBeDefined();
|
||||
}));
|
||||
});
|
||||
});
|
||||
223
src/app/features/issue/providers/clickup/clickup-api.service.ts
Normal file
223
src/app/features/issue/providers/clickup/clickup-api.service.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import {
|
||||
HttpClient,
|
||||
HttpHeaders,
|
||||
HttpParams,
|
||||
HttpErrorResponse,
|
||||
HttpResponse,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, forkJoin, of, throwError, timer } from 'rxjs';
|
||||
import { catchError, map, switchMap, retry } from 'rxjs/operators';
|
||||
import typia from 'typia';
|
||||
import { IssueLog } from '../../../../core/log';
|
||||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
import { handleIssueProviderHttpError$ } from '../../handle-issue-provider-http-error';
|
||||
import { CLICKUP_TYPE } from '../../issue.const';
|
||||
import { CLICKUP_HEADER_RATE_LIMIT_RESET } from './clickup.const';
|
||||
import {
|
||||
ClickUpTask,
|
||||
ClickUpTaskReduced,
|
||||
ClickUpTaskSearchResponse,
|
||||
ClickUpTeamsResponse,
|
||||
ClickUpUserResponse,
|
||||
} from './clickup-issue.model';
|
||||
import { ClickUpCfg } from './clickup.model';
|
||||
|
||||
const CLICKUP_API_URL = 'https://api.clickup.com/api/v2';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ClickUpApiService {
|
||||
private _snackService = inject(SnackService);
|
||||
private _http = inject(HttpClient);
|
||||
|
||||
getById$(taskId: string, cfg: ClickUpCfg): Observable<ClickUpTask> {
|
||||
const params = new HttpParams()
|
||||
.set('include_markdown_description', 'true')
|
||||
.set('include_subtasks', 'true');
|
||||
|
||||
return this._sendRequest$({
|
||||
url: `${CLICKUP_API_URL}/task/${taskId}`,
|
||||
params,
|
||||
cfg,
|
||||
validator: (res) => typia.assert<ClickUpTask>(res),
|
||||
});
|
||||
}
|
||||
|
||||
getAuthorizedTeams$(cfg: ClickUpCfg): Observable<Array<{ id: string; name: string }>> {
|
||||
return this._sendRequest$({
|
||||
url: `${CLICKUP_API_URL}/team`,
|
||||
cfg,
|
||||
validator: (res) =>
|
||||
typia.assert<ClickUpTeamsResponse>(res).teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
searchTasks$(searchTerm: string, cfg: ClickUpCfg): Observable<ClickUpTaskReduced[]> {
|
||||
// If teamIds are configured, search only in those teams
|
||||
if (cfg.teamIds && cfg.teamIds.length > 0) {
|
||||
return this._searchTasksInTeams$(searchTerm, cfg.teamIds, cfg);
|
||||
}
|
||||
|
||||
// If no teamIds are configured, fetch all authorized teams and search across all of them
|
||||
return this.getAuthorizedTeams$(cfg).pipe(
|
||||
switchMap((teams) => {
|
||||
if (!teams || teams.length === 0) {
|
||||
IssueLog.warn('No authorized teams found');
|
||||
return of([]);
|
||||
}
|
||||
|
||||
const teamIds = teams.map((team) => team.id);
|
||||
return this._searchTasksInTeams$(searchTerm, teamIds, cfg);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _searchTasksInTeams$(
|
||||
searchTerm: string,
|
||||
teamIds: string[],
|
||||
cfg: ClickUpCfg,
|
||||
): Observable<ClickUpTaskReduced[]> {
|
||||
// Search tasks in all teams in parallel
|
||||
const searchObservables = teamIds.map((teamId) =>
|
||||
this._searchTasksInTeam$(searchTerm, teamId, cfg).pipe(
|
||||
catchError((err) => {
|
||||
IssueLog.warn(`Failed to search tasks in team ${teamId}:`, err);
|
||||
return of([]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return forkJoin(searchObservables).pipe(
|
||||
map((results) => {
|
||||
// Flatten all results from all teams
|
||||
return results.flat();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _searchTasksInTeam$(
|
||||
searchTerm: string,
|
||||
teamId: string,
|
||||
cfg: ClickUpCfg,
|
||||
): Observable<ClickUpTaskReduced[]> {
|
||||
let params = new HttpParams().set('page', '0').set('subtasks', 'true');
|
||||
|
||||
// Add assignee filter if userId is available
|
||||
if (cfg.userId) {
|
||||
params = params.set('assignees[]', cfg.userId.toString());
|
||||
}
|
||||
|
||||
return this._sendRequest$({
|
||||
url: `${CLICKUP_API_URL}/team/${teamId}/task`,
|
||||
params,
|
||||
cfg,
|
||||
validator: (res) => {
|
||||
const body = typia.assert<ClickUpTaskSearchResponse>(res);
|
||||
let tasks = body.tasks;
|
||||
if (searchTerm.trim()) {
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
tasks = tasks.filter(
|
||||
(task) =>
|
||||
task.name.toLowerCase().includes(lowerSearchTerm) ||
|
||||
(task.custom_id &&
|
||||
task.custom_id.toLowerCase().includes(lowerSearchTerm)) ||
|
||||
task.id.includes(lowerSearchTerm),
|
||||
);
|
||||
}
|
||||
return tasks.map(this._mapToReduced);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentUser$(cfg: ClickUpCfg): Observable<ClickUpUserResponse> {
|
||||
return this._sendRequest$({
|
||||
url: `${CLICKUP_API_URL}/user`,
|
||||
cfg,
|
||||
validator: (res) => typia.assert<ClickUpUserResponse>(res),
|
||||
});
|
||||
}
|
||||
|
||||
private _getHeaders(cfg: ClickUpCfg): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: cfg.apiKey || '',
|
||||
});
|
||||
}
|
||||
|
||||
private _sendRequest$<T>({
|
||||
url,
|
||||
params,
|
||||
cfg,
|
||||
validator,
|
||||
}: {
|
||||
url: string;
|
||||
params?: HttpParams;
|
||||
cfg: ClickUpCfg;
|
||||
validator?: (res: unknown) => T;
|
||||
}): Observable<T> {
|
||||
const headers = this._getHeaders(cfg);
|
||||
|
||||
return this._http
|
||||
.get<T>(url, {
|
||||
headers,
|
||||
params,
|
||||
reportProgress: false,
|
||||
observe: 'response',
|
||||
})
|
||||
.pipe(
|
||||
// Retry with backoff on 429
|
||||
retry({
|
||||
count: 3,
|
||||
delay: (error: HttpErrorResponse, retryCount) => {
|
||||
if (error.status === 429) {
|
||||
const resetHeader = error.headers.get(CLICKUP_HEADER_RATE_LIMIT_RESET);
|
||||
if (resetHeader) {
|
||||
const resetTime = parseInt(resetHeader, 10) * 1000;
|
||||
const waitTime = resetTime - Date.now();
|
||||
if (waitTime > 0) {
|
||||
IssueLog.warn(
|
||||
`ClickUp: Rate limit exceeded. Waiting for ${waitTime}ms before retry ${retryCount}.`,
|
||||
);
|
||||
return timer(waitTime);
|
||||
}
|
||||
}
|
||||
// Fallback to exponential backoff if no header or invalid
|
||||
const delay = 1000 * Math.pow(2, retryCount - 1);
|
||||
IssueLog.warn(
|
||||
`ClickUp: Rate limit exceeded. Waiting for ${delay}ms (backoff) before retry ${retryCount}.`,
|
||||
);
|
||||
return timer(delay);
|
||||
}
|
||||
return throwError(() => error);
|
||||
},
|
||||
}),
|
||||
map((res: HttpResponse<T>) => (res && res.body ? res.body : res)),
|
||||
map((res) => {
|
||||
if (validator) {
|
||||
return validator(res);
|
||||
}
|
||||
return res as T;
|
||||
}),
|
||||
catchError((err) =>
|
||||
handleIssueProviderHttpError$<T>(CLICKUP_TYPE, this._snackService, err),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _mapToReduced(task: ClickUpTask): ClickUpTaskReduced {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
status: task.status,
|
||||
date_updated: task.date_updated,
|
||||
url: task.url,
|
||||
custom_id: task.custom_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of, firstValueFrom } from 'rxjs';
|
||||
import { concatMap, first, map, switchMap } from 'rxjs/operators';
|
||||
import { IssueServiceInterface } from '../../issue-service-interface';
|
||||
import { IssueProviderService } from '../../issue-provider.service';
|
||||
import { ClickUpApiService } from './clickup-api.service';
|
||||
import { ClickUpCfg } from './clickup.model';
|
||||
import { SearchResultItem } from '../../issue.model';
|
||||
import { ClickUpTask, ClickUpTaskReduced } from './clickup-issue.model';
|
||||
import {
|
||||
mapClickUpAttachmentToTaskAttachment,
|
||||
mapClickUpTaskToTask,
|
||||
isClickUpTaskDone,
|
||||
} from './clickup-issue-map.util';
|
||||
import { Task } from '../../../tasks/task.model';
|
||||
import { TaskAttachment } from '../../../tasks/task-attachment/task-attachment.model';
|
||||
import { truncate } from '../../../../util/truncate';
|
||||
import { IssueLog } from '../../../../core/log';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ClickUpCommonInterfacesService implements IssueServiceInterface {
|
||||
private _clickUpApiService = inject(ClickUpApiService);
|
||||
private _issueProviderService = inject(IssueProviderService);
|
||||
|
||||
isEnabled(cfg: ClickUpCfg): boolean {
|
||||
return !!cfg && cfg.isEnabled && !!cfg.apiKey;
|
||||
}
|
||||
|
||||
testConnection(cfg: ClickUpCfg): Promise<boolean> {
|
||||
return firstValueFrom(
|
||||
this._clickUpApiService.getCurrentUser$(cfg).pipe(
|
||||
map(() => true),
|
||||
first(),
|
||||
),
|
||||
)
|
||||
.then((result) => result ?? false)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
issueLink(issueId: string, issueProviderId: string): Promise<string> {
|
||||
return firstValueFrom(
|
||||
this._getCfgOnce$(issueProviderId).pipe(
|
||||
concatMap((cfg) =>
|
||||
this._clickUpApiService.getById$(issueId, cfg).pipe(map((issue) => issue.url)),
|
||||
),
|
||||
first(),
|
||||
),
|
||||
).then((res) => res ?? '');
|
||||
}
|
||||
|
||||
getById(issueId: string, issueProviderId: string): Promise<ClickUpTask> {
|
||||
return firstValueFrom(
|
||||
this._getCfgOnce$(issueProviderId).pipe(
|
||||
concatMap((cfg) => this._clickUpApiService.getById$(issueId, cfg)),
|
||||
first(),
|
||||
),
|
||||
).then((res) => {
|
||||
if (!res) throw new Error('Failed to get ClickUp task');
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
searchIssues(searchTerm: string, issueProviderId: string): Promise<SearchResultItem[]> {
|
||||
return firstValueFrom(
|
||||
this._getCfgOnce$(issueProviderId).pipe(
|
||||
switchMap((cfg) =>
|
||||
this.isEnabled(cfg)
|
||||
? this._clickUpApiService.searchTasks$(searchTerm, cfg).pipe(
|
||||
map((tasks) =>
|
||||
tasks.map((task) => ({
|
||||
title: task.name,
|
||||
issueType: 'CLICKUP' as const,
|
||||
issueData: task,
|
||||
})),
|
||||
),
|
||||
)
|
||||
: of([]),
|
||||
),
|
||||
first(),
|
||||
),
|
||||
).then((res) => res ?? []);
|
||||
}
|
||||
|
||||
async getFreshDataForIssueTask(task: Task): Promise<{
|
||||
taskChanges: Partial<Task>;
|
||||
issue: ClickUpTask;
|
||||
issueTitle: string;
|
||||
} | null> {
|
||||
if (!task.issueProviderId || !task.issueId) {
|
||||
throw new Error('No issueProviderId or issueId');
|
||||
}
|
||||
|
||||
const cfg = await firstValueFrom(this._getCfgOnce$(task.issueProviderId)).then(
|
||||
(res) => {
|
||||
if (!res) throw new Error('No config found');
|
||||
return res;
|
||||
},
|
||||
);
|
||||
|
||||
const issue = await firstValueFrom(
|
||||
this._clickUpApiService.getById$(task.issueId, cfg),
|
||||
).then((res) => {
|
||||
if (!res) throw new Error('Issue not found');
|
||||
return res;
|
||||
});
|
||||
|
||||
const issueLastUpdated = parseInt(issue.date_updated, 10);
|
||||
const wasUpdated = issueLastUpdated > (task.issueLastUpdated || 0);
|
||||
|
||||
if (wasUpdated) {
|
||||
return {
|
||||
taskChanges: {
|
||||
...mapClickUpTaskToTask(issue),
|
||||
issueWasUpdated: true,
|
||||
},
|
||||
issue,
|
||||
issueTitle: truncate(issue.name),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getFreshDataForIssueTasks(
|
||||
tasks: Task[],
|
||||
): Promise<{ task: Task; taskChanges: Partial<Task>; issue: ClickUpTask }[]> {
|
||||
// Parallel requests might be too much for API limits? ClickUp limits are generous mostly.
|
||||
return Promise.all(
|
||||
tasks.map(
|
||||
(task) =>
|
||||
this.getFreshDataForIssueTask(task)
|
||||
.then((refreshData) => ({
|
||||
task,
|
||||
refreshData,
|
||||
}))
|
||||
.catch((e) => {
|
||||
IssueLog.error('ClickUp getFreshDataForIssueTasks error', e);
|
||||
return { task, refreshData: null };
|
||||
}), // suppress error to not fail all?
|
||||
),
|
||||
).then((items) => {
|
||||
return items
|
||||
.filter(({ refreshData }) => !!refreshData)
|
||||
.map(({ refreshData, task }) => ({
|
||||
task,
|
||||
taskChanges: refreshData!.taskChanges,
|
||||
issue: refreshData!.issue,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
getMappedAttachments(issue: ClickUpTask): TaskAttachment[] {
|
||||
return (issue.attachments || []).map(mapClickUpAttachmentToTaskAttachment);
|
||||
}
|
||||
|
||||
async getNewIssuesToAddToBacklog?(
|
||||
issueProviderId: string,
|
||||
allExistingIssueIds: (string | number)[],
|
||||
): Promise<ClickUpTaskReduced[]> {
|
||||
const cfg = await firstValueFrom(this._getCfgOnce$(issueProviderId)).then(
|
||||
(result) => {
|
||||
if (!result) {
|
||||
throw new Error('No config found');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const tasks = await firstValueFrom(
|
||||
this._clickUpApiService.searchTasks$('', cfg).pipe(first()),
|
||||
).then((res) => res ?? []);
|
||||
|
||||
return tasks.filter((task) => !allExistingIssueIds.includes(task.id));
|
||||
}
|
||||
|
||||
pollInterval = 60000;
|
||||
|
||||
getAddTaskData(issue: ClickUpTaskReduced): Partial<Task> & { title: string } {
|
||||
return {
|
||||
title: issue.name,
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: parseInt(issue.date_updated, 10),
|
||||
isDone: isClickUpTaskDone(issue),
|
||||
};
|
||||
}
|
||||
|
||||
getSubTasksForIssue(
|
||||
issue: ClickUpTask,
|
||||
): Array<Partial<Task> & { title: string; related_to: string }> {
|
||||
if (!issue.subtasks || issue.subtasks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return issue.subtasks.map((subtask) => ({
|
||||
title: subtask.name,
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: parseInt(subtask.date_updated, 10),
|
||||
isDone: isClickUpTaskDone(subtask),
|
||||
related_to: issue.id, // Link to parent task
|
||||
issueId: subtask.id,
|
||||
issueType: 'CLICKUP' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async getSubTasks(
|
||||
issueId: string | number,
|
||||
issueProviderId: string,
|
||||
issue: ClickUpTaskReduced,
|
||||
): Promise<ClickUpTaskReduced[]> {
|
||||
let subtasks = (issue as ClickUpTask).subtasks;
|
||||
|
||||
if (!subtasks) {
|
||||
const fullIssue = await this.getById(issueId.toString(), issueProviderId);
|
||||
subtasks = fullIssue.subtasks;
|
||||
}
|
||||
|
||||
if (subtasks) {
|
||||
return subtasks.filter((subtask) => subtask.status?.type !== 'closed');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private _getCfgOnce$(issueProviderId: string): Observable<ClickUpCfg> {
|
||||
return this._issueProviderService.getCfgOnce$(issueProviderId, 'CLICKUP');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { T } from '../../../../t.const';
|
||||
import {
|
||||
IssueContentConfig,
|
||||
IssueFieldType,
|
||||
} from '../../issue-content/issue-content.model';
|
||||
import { ClickUpTask } from './clickup-issue.model';
|
||||
|
||||
export const CLICKUP_ISSUE_CONTENT_CONFIG: IssueContentConfig<ClickUpTask> = {
|
||||
issueType: 'CLICKUP' as const,
|
||||
fields: [
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
|
||||
type: IssueFieldType.LINK,
|
||||
value: (issue: ClickUpTask) => issue.name,
|
||||
getLink: (issue: ClickUpTask) => issue.url,
|
||||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.STATUS,
|
||||
value: (issue: ClickUpTask) => issue.status.status,
|
||||
type: IssueFieldType.TEXT,
|
||||
isVisible: (issue: ClickUpTask) => !!issue.status,
|
||||
},
|
||||
{
|
||||
label: 'Priority',
|
||||
value: (issue: ClickUpTask) => issue.priority?.priority,
|
||||
type: IssueFieldType.TEXT,
|
||||
isVisible: (issue: ClickUpTask) => !!issue.priority,
|
||||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.ASSIGNEE,
|
||||
type: IssueFieldType.TEXT,
|
||||
value: (issue: ClickUpTask) => issue.assignees?.map((a) => a.username).join(', '),
|
||||
isVisible: (issue: ClickUpTask) => (issue.assignees?.length ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.LABELS,
|
||||
value: (issue: ClickUpTask) => issue.tags?.map((t) => t.name).join(', '),
|
||||
type: IssueFieldType.TEXT,
|
||||
isVisible: (issue: ClickUpTask) => (issue.tags?.length ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.DESCRIPTION,
|
||||
value: (issue: ClickUpTask) => issue.markdown_description,
|
||||
type: IssueFieldType.MARKDOWN,
|
||||
isVisible: (issue: ClickUpTask) => !!issue.markdown_description,
|
||||
},
|
||||
// TODO: Add comments (activity)
|
||||
],
|
||||
getIssueUrl: (issue) => issue.url,
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { ClickUpTaskReduced, ClickUpTask } from './clickup-issue.model';
|
||||
import { mapClickUpTaskToTask, isClickUpTaskDone } from './clickup-issue-map.util';
|
||||
import typia from 'typia';
|
||||
|
||||
const mockIssueReduced: ClickUpTaskReduced = {
|
||||
...typia.random<ClickUpTaskReduced>(),
|
||||
id: 'abc',
|
||||
name: 'Test Task',
|
||||
custom_id: '123',
|
||||
status: {
|
||||
...typia.random<ClickUpTaskReduced['status']>(),
|
||||
status: 'open',
|
||||
type: 'open',
|
||||
color: '#000',
|
||||
},
|
||||
date_updated: '1600000000000',
|
||||
};
|
||||
|
||||
const mockIssue: ClickUpTask = {
|
||||
...typia.random<ClickUpTask>(),
|
||||
...mockIssueReduced,
|
||||
date_created: '1500000000000',
|
||||
assignees: [],
|
||||
tags: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
describe('mapClickUpTaskToTask', () => {
|
||||
it('should map correctly', () => {
|
||||
const result = mapClickUpTaskToTask(mockIssue);
|
||||
expect(result.title).toBe('Test Task');
|
||||
expect(result.issueLastUpdated).toBe(1600000000000);
|
||||
expect(result.isDone).toBe(false);
|
||||
});
|
||||
|
||||
it('should map correctly without custom_id', () => {
|
||||
const issueWithoutId = { ...mockIssue, custom_id: undefined };
|
||||
const result = mapClickUpTaskToTask(issueWithoutId);
|
||||
expect(result.title).toBe('Test Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClickUpTaskDone', () => {
|
||||
it('should be open for open status', () => {
|
||||
expect(isClickUpTaskDone(mockIssueReduced)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be done for closed status type', () => {
|
||||
const closedIssue = {
|
||||
...mockIssueReduced,
|
||||
status: { ...mockIssueReduced.status, type: 'closed' },
|
||||
};
|
||||
expect(isClickUpTaskDone(closedIssue)).toBe(true);
|
||||
});
|
||||
|
||||
it('should be done for closed status name', () => {
|
||||
const closedIssue = {
|
||||
...mockIssueReduced,
|
||||
status: { ...mockIssueReduced.status, status: 'closed' },
|
||||
};
|
||||
expect(isClickUpTaskDone(closedIssue)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
ClickUpTask,
|
||||
ClickUpTaskReduced,
|
||||
ClickUpAttachment,
|
||||
} from './clickup-issue.model';
|
||||
import { Task } from '../../../tasks/task.model';
|
||||
import { TaskAttachment } from '../../../tasks/task-attachment/task-attachment.model';
|
||||
|
||||
export const mapClickUpAttachmentToTaskAttachment = (
|
||||
attachment: ClickUpAttachment,
|
||||
): TaskAttachment => {
|
||||
return {
|
||||
id: attachment.id,
|
||||
title: attachment.title,
|
||||
path: attachment.url,
|
||||
type: 'LINK',
|
||||
// ClickUp attachments also have 'type': 'image/png' etc.
|
||||
// But for app integration, we treat them as links to the file for now.
|
||||
};
|
||||
};
|
||||
|
||||
export const mapClickUpTaskToTask = (
|
||||
issue: ClickUpTask,
|
||||
): Partial<Task> & { title: string } => {
|
||||
return {
|
||||
title: issue.name,
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: parseInt(issue.date_updated, 10),
|
||||
isDone: isClickUpTaskDone(issue),
|
||||
// timeSpent is in total?
|
||||
// If we want to import time spent, we might need to be careful not to overwrite local time if not desired.
|
||||
// For now, let's not auto-update timeSpent from server to local unless we work on full sync.
|
||||
// Just mapping basics.
|
||||
};
|
||||
};
|
||||
|
||||
export const isClickUpTaskDone = (issue: ClickUpTask | ClickUpTaskReduced): boolean => {
|
||||
return issue.status.type === 'closed' || issue.status.status === 'closed'; // 'closed' type is safer
|
||||
};
|
||||
|
||||
export const mapClickUpTaskWithSubTasks = (
|
||||
issue: ClickUpTask,
|
||||
): {
|
||||
mainTask: Partial<Task> & { title: string };
|
||||
subTasks: Array<Partial<Task> & { title: string }>;
|
||||
} => {
|
||||
const mainTask = mapClickUpTaskToTask(issue);
|
||||
const subTasks: Array<Partial<Task> & { title: string }> = [];
|
||||
|
||||
if (issue.subtasks && issue.subtasks.length > 0) {
|
||||
issue.subtasks.forEach((subtask) => {
|
||||
subTasks.push({
|
||||
title: subtask.name,
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: parseInt(subtask.date_updated, 10),
|
||||
isDone: isClickUpTaskDone(subtask),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { mainTask, subTasks };
|
||||
};
|
||||
147
src/app/features/issue/providers/clickup/clickup-issue.model.ts
Normal file
147
src/app/features/issue/providers/clickup/clickup-issue.model.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// NOTE: This file is a consolidation of previous models and API responses
|
||||
// All inline types have been extracted for better reusability and type safety.
|
||||
|
||||
export type ClickUpStatus = Readonly<{
|
||||
id?: string;
|
||||
status: string;
|
||||
type: string;
|
||||
orderindex: number;
|
||||
color: string;
|
||||
}>;
|
||||
|
||||
export type ClickUpUser = Readonly<{
|
||||
id: number;
|
||||
username: string;
|
||||
color: string;
|
||||
email: string;
|
||||
profilePicture: string | null;
|
||||
initials?: string; // Present in assignees but not always creator
|
||||
}>;
|
||||
|
||||
export type ClickUpTag = Readonly<{
|
||||
name: string;
|
||||
tag_fg: string;
|
||||
tag_bg: string;
|
||||
creator: number;
|
||||
}>;
|
||||
|
||||
export type ClickUpPriority = Readonly<{
|
||||
id: string;
|
||||
priority: string;
|
||||
color: string;
|
||||
orderindex: string;
|
||||
}>;
|
||||
|
||||
export type ClickUpAttachment = Readonly<{
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
type: number;
|
||||
source: number;
|
||||
version: number;
|
||||
extension: string;
|
||||
thumbnail_small: string;
|
||||
thumbnail_large: string;
|
||||
mimetype: string;
|
||||
hidden: boolean;
|
||||
parent_id: string;
|
||||
size: number;
|
||||
total_comments: number;
|
||||
url: string;
|
||||
url_w_query: string;
|
||||
url_w_host: string;
|
||||
}>;
|
||||
|
||||
export type ClickUpTeam = Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
avatar: string | null;
|
||||
members: Array<{
|
||||
user: ClickUpUser;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClickUpTeamsResponse = Readonly<{
|
||||
teams: ClickUpTeam[];
|
||||
}>;
|
||||
|
||||
export type ClickUpUserResponse = Readonly<{
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
color: string;
|
||||
profilePicture: string | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ClickUpTaskReduced = Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: ClickUpStatus;
|
||||
date_updated: string; // unix timestamp in ms as string
|
||||
url: string;
|
||||
// Shared properties that might be useful in reduced view
|
||||
custom_id?: string | null;
|
||||
}>;
|
||||
|
||||
export type ClickUpTask = ClickUpTaskReduced &
|
||||
Readonly<{
|
||||
custom_item_id?: number | null; // observed as 0 in sample
|
||||
text_content?: string | null;
|
||||
description?: string | null; // html
|
||||
markdown_description?: string | null;
|
||||
orderindex: string;
|
||||
date_created: string; // unix timestamp in ms as string
|
||||
date_closed: string | null; // unix timestamp in ms as string
|
||||
date_done: string | null;
|
||||
archived?: boolean;
|
||||
creator: ClickUpUser;
|
||||
assignees: ClickUpUser[];
|
||||
watchers: ClickUpUser[];
|
||||
tags: ClickUpTag[];
|
||||
parent: string | null;
|
||||
top_level_parent?: string | null;
|
||||
priority?: ClickUpPriority | null;
|
||||
due_date: string | null;
|
||||
start_date: string | null;
|
||||
points: number | null;
|
||||
time_estimate: number | null;
|
||||
time_spent?: number;
|
||||
team_id?: string;
|
||||
sharing?: {
|
||||
public: boolean;
|
||||
public_share_expires_on: string | null;
|
||||
public_fields: string[];
|
||||
token: string | null;
|
||||
seo_optimized: boolean;
|
||||
};
|
||||
permission_level?: string;
|
||||
list?: {
|
||||
id: string;
|
||||
name: string;
|
||||
access: boolean;
|
||||
};
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden: boolean;
|
||||
access: boolean;
|
||||
};
|
||||
folder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden: boolean;
|
||||
access: boolean;
|
||||
};
|
||||
space?: {
|
||||
id: string;
|
||||
};
|
||||
attachments?: ClickUpAttachment[];
|
||||
subtasks?: ClickUpTaskReduced[];
|
||||
}>;
|
||||
|
||||
export type ClickUpTaskSearchResponse = Readonly<{
|
||||
tasks: ClickUpTask[];
|
||||
last_page?: boolean;
|
||||
}>;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<form [formGroup]="form">
|
||||
<collapsible
|
||||
[title]="'Workspace Selection'"
|
||||
[isExpanded]="isWorkspaceSelectionExpanded"
|
||||
(isExpandedChange)="isWorkspaceSelectionExpanded = $event"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
(click)="loadAvailableTeams()"
|
||||
mat-raised-button
|
||||
type="button"
|
||||
[disabled]="isLoadingTeams$ | async"
|
||||
>
|
||||
@if (isLoadingTeams$ | async) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
style="display: inline-block; margin-right: 8px"
|
||||
></mat-spinner>
|
||||
}
|
||||
Load Available Workspaces
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@if ((availableTeams$ | async)?.length) {
|
||||
<div @expand>
|
||||
<p><strong>Available Workspaces:</strong></p>
|
||||
@for (team of availableTeams$ | async; track trackByTeamId($index, team)) {
|
||||
<div style="margin-bottom: 8px">
|
||||
<mat-checkbox
|
||||
[checked]="isTeamSelected(team.id)"
|
||||
(change)="toggleTeam(team.id, $event.checked)"
|
||||
>{{ team.name }}</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</collapsible>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sub-section-heading {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mat-checkbox {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { ConfigFormSection } from '../../../../../config/global-config.model';
|
||||
import { FormlyFormOptions } from '@ngx-formly/core';
|
||||
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
|
||||
import { expandAnimation } from '../../../../../../ui/animations/expand.ani';
|
||||
import { BehaviorSubject, Subscription, forkJoin } from 'rxjs';
|
||||
import { IssueProviderClickUp } from '../../../../issue.model';
|
||||
import { first, tap } from 'rxjs/operators';
|
||||
import { ClickUpApiService } from '../../clickup-api.service';
|
||||
import { DEFAULT_CLICKUP_CFG } from '../../clickup.const';
|
||||
import { SnackService } from '../../../../../../core/snack/snack.service';
|
||||
import { T } from '../../../../../../t.const';
|
||||
import { HelperClasses } from '../../../../../../app.constants';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ClickUpTeam } from '../../clickup.model';
|
||||
import { CollapsibleComponent } from '../../../../../../ui/collapsible/collapsible.component';
|
||||
|
||||
@Component({
|
||||
selector: 'clickup-additional-cfg',
|
||||
templateUrl: './clickup-additional-cfg.component.html',
|
||||
styleUrls: ['./clickup-additional-cfg.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatSlideToggle,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatProgressSpinner,
|
||||
MatButton,
|
||||
MatCheckbox,
|
||||
AsyncPipe,
|
||||
TranslatePipe,
|
||||
CollapsibleComponent,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
export class ClickUpAdditionalCfgComponent implements OnInit, OnDestroy {
|
||||
private _clickUpApiService = inject(ClickUpApiService);
|
||||
private _snackService = inject(SnackService);
|
||||
|
||||
readonly section = input<ConfigFormSection<IssueProviderClickUp>>();
|
||||
readonly modelChange = output<IssueProviderClickUp>();
|
||||
|
||||
T: typeof T = T;
|
||||
HelperClasses: typeof HelperClasses = HelperClasses;
|
||||
form: UntypedFormGroup = new UntypedFormGroup({});
|
||||
options: FormlyFormOptions = {};
|
||||
|
||||
availableTeams$: BehaviorSubject<ClickUpTeam[]> = new BehaviorSubject<ClickUpTeam[]>(
|
||||
[],
|
||||
);
|
||||
isLoadingTeams$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
isWorkspaceSelectionExpanded = false;
|
||||
|
||||
private _subs: Subscription = new Subscription();
|
||||
private _cfg?: IssueProviderClickUp;
|
||||
private _boundCfg?: IssueProviderClickUp;
|
||||
|
||||
get cfg(): IssueProviderClickUp {
|
||||
return this._boundCfg as IssueProviderClickUp;
|
||||
}
|
||||
|
||||
@Input() set cfg(cfg: IssueProviderClickUp) {
|
||||
this._boundCfg = cfg;
|
||||
|
||||
const newCfg: IssueProviderClickUp = { ...cfg };
|
||||
const isEqual = JSON.stringify(newCfg) === JSON.stringify(this._cfg);
|
||||
if (isEqual) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newCfg.teamIds) {
|
||||
newCfg.teamIds = DEFAULT_CLICKUP_CFG.teamIds;
|
||||
}
|
||||
|
||||
this._cfg = newCfg;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Auto-load teams if API key is present
|
||||
if (this.cfg.apiKey) {
|
||||
this.loadAvailableTeams();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._subs.unsubscribe();
|
||||
}
|
||||
|
||||
partialModelChange(cfg: Partial<IssueProviderClickUp>): void {
|
||||
this._cfg = { ...this.cfg, ...cfg } as IssueProviderClickUp;
|
||||
this.notifyModelChange();
|
||||
}
|
||||
|
||||
notifyModelChange(): void {
|
||||
this.modelChange.emit(this._cfg as IssueProviderClickUp);
|
||||
}
|
||||
|
||||
loadAvailableTeams(): void {
|
||||
if (!this.cfg?.apiKey) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Please enter your API key first',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingTeams$.next(true);
|
||||
this._subs.add(
|
||||
forkJoin({
|
||||
teams: this._clickUpApiService.getAuthorizedTeams$(this.cfg),
|
||||
user: this._clickUpApiService.getCurrentUser$(this.cfg),
|
||||
})
|
||||
.pipe(
|
||||
first(),
|
||||
tap(() => this.isLoadingTeams$.next(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ teams, user }) => {
|
||||
this.availableTeams$.next(teams);
|
||||
|
||||
// Save user ID to configuration
|
||||
if (user && user.user && user.user.id) {
|
||||
this.partialModelChange({ userId: user.user.id });
|
||||
}
|
||||
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: `Loaded ${teams.length} workspace(s)`,
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoadingTeams$.next(false);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to load workspaces. Please check your API key.',
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
isTeamSelected(teamId: string): boolean {
|
||||
return this.cfg.teamIds?.includes(teamId) || false;
|
||||
}
|
||||
|
||||
toggleTeam(teamId: string, isChecked: boolean): void {
|
||||
const currentTeamIds = this.cfg.teamIds || [];
|
||||
let newTeamIds: string[];
|
||||
|
||||
if (isChecked) {
|
||||
// Add team if not already present
|
||||
newTeamIds = currentTeamIds.includes(teamId)
|
||||
? currentTeamIds
|
||||
: [...currentTeamIds, teamId];
|
||||
} else {
|
||||
// Remove team
|
||||
newTeamIds = currentTeamIds.filter((id) => id !== teamId);
|
||||
}
|
||||
|
||||
this.partialModelChange({ teamIds: newTeamIds });
|
||||
}
|
||||
|
||||
trackByTeamId(index: number, team: ClickUpTeam): string {
|
||||
return team.id;
|
||||
}
|
||||
}
|
||||
49
src/app/features/issue/providers/clickup/clickup.const.ts
Normal file
49
src/app/features/issue/providers/clickup/clickup.const.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
ConfigFormSection,
|
||||
LimitedFormlyFieldConfig,
|
||||
} from '../../../config/global-config.model';
|
||||
import { IssueProviderClickUp } from '../../issue.model';
|
||||
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
|
||||
import { ClickUpCfg } from './clickup.model';
|
||||
|
||||
export const DEFAULT_CLICKUP_CFG: ClickUpCfg = {
|
||||
isEnabled: false,
|
||||
apiKey: null,
|
||||
teamIds: [],
|
||||
userId: null,
|
||||
};
|
||||
|
||||
export const CLICKUP_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderClickUp>[] = [
|
||||
{
|
||||
key: 'apiKey',
|
||||
type: 'input',
|
||||
props: {
|
||||
label: 'API Key',
|
||||
required: true,
|
||||
type: 'password',
|
||||
placeholder: 'pk_...',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
props: {
|
||||
url: 'https://app.clickup.com/settings/apps',
|
||||
txt: 'Get your Personal API key',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'collapsible',
|
||||
props: { label: 'Advanced Config' },
|
||||
fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
|
||||
},
|
||||
];
|
||||
|
||||
export const CLICKUP_CONFIG_FORM_SECTION: ConfigFormSection<IssueProviderClickUp> = {
|
||||
title: 'ClickUp',
|
||||
key: 'CLICKUP',
|
||||
items: CLICKUP_CONFIG_FORM,
|
||||
help: 'Configure ClickUp integration to sync tasks.',
|
||||
customSection: 'CLICKUP_CFG',
|
||||
};
|
||||
|
||||
export const CLICKUP_HEADER_RATE_LIMIT_RESET = 'X-RateLimit-Reset';
|
||||
12
src/app/features/issue/providers/clickup/clickup.model.ts
Normal file
12
src/app/features/issue/providers/clickup/clickup.model.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { BaseIssueProviderCfg } from '../../issue.model';
|
||||
|
||||
export interface ClickUpCfg extends BaseIssueProviderCfg {
|
||||
apiKey: string | null;
|
||||
teamIds?: string[];
|
||||
userId?: number | null;
|
||||
}
|
||||
|
||||
export interface ClickUpTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -16,9 +16,9 @@ export const LINEAR_ISSUE_CONTENT_CONFIG: IssueContentConfig<LinearIssue> = {
|
|||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.STATUS,
|
||||
value: 'state',
|
||||
value: (issue: LinearIssue) => issue.state.name,
|
||||
type: IssueFieldType.TEXT,
|
||||
isVisible: (issue: LinearIssue) => !!issue.state,
|
||||
isVisible: (issue: LinearIssue) => !!issue.state.name,
|
||||
},
|
||||
{
|
||||
label: 'Priority',
|
||||
|
|
@ -45,12 +45,6 @@ export const LINEAR_ISSUE_CONTENT_CONFIG: IssueContentConfig<LinearIssue> = {
|
|||
type: IssueFieldType.MARKDOWN,
|
||||
isVisible: (issue: LinearIssue) => !!issue.description,
|
||||
},
|
||||
{
|
||||
label: T.F.ISSUE.ISSUE_CONTENT.ATTACHMENTS,
|
||||
value: 'attachments',
|
||||
type: IssueFieldType.LINK,
|
||||
isVisible: (issue: LinearIssue) => Boolean(issue.attachments?.length),
|
||||
},
|
||||
],
|
||||
comments: {
|
||||
field: 'comments',
|
||||
|
|
|
|||
|
|
@ -30,62 +30,64 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<mat-calendar
|
||||
(keydown)="onKeyDownOnCalendar($event)"
|
||||
[selected]="selectedDate"
|
||||
[minDate]="minDate"
|
||||
(selectedChange)="dateSelected($event)"
|
||||
></mat-calendar>
|
||||
<mat-dialog-content>
|
||||
<mat-calendar
|
||||
(keydown)="onKeyDownOnCalendar($event)"
|
||||
[selected]="selectedDate"
|
||||
[minDate]="minDate"
|
||||
(selectedChange)="dateSelected($event)"
|
||||
></mat-calendar>
|
||||
|
||||
@if (isShowEnterMsg) {
|
||||
<div
|
||||
class="press-enter-msg"
|
||||
@fade
|
||||
>
|
||||
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-ctrl-wrapper">
|
||||
<mat-form-field class="example-full-width">
|
||||
<mat-label>Time</mat-label>
|
||||
<mat-icon matPrefix>schedule</mat-icon>
|
||||
<input
|
||||
type="time"
|
||||
(focus)="onTimeFocus()"
|
||||
[(ngModel)]="selectedTime"
|
||||
step="60"
|
||||
matInput
|
||||
(keydown)="onTimeKeyDown($event)"
|
||||
/>
|
||||
@if (selectedTime) {
|
||||
<mat-icon
|
||||
style="cursor: pointer"
|
||||
matSuffix
|
||||
(click)="onTimeClear($event)"
|
||||
>close
|
||||
</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedTime) {
|
||||
<mat-form-field [@expandFade]>
|
||||
<mat-icon matPrefix>alarm</mat-icon>
|
||||
<mat-label>{{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }}</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="selectedReminderCfgId"
|
||||
name="type"
|
||||
required="true"
|
||||
>
|
||||
@for (remindOption of remindAvailableOptions; track remindOption.value) {
|
||||
<mat-option [value]="remindOption.value">
|
||||
{{ remindOption.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (isShowEnterMsg) {
|
||||
<div
|
||||
class="press-enter-msg"
|
||||
@fade
|
||||
>
|
||||
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-ctrl-wrapper">
|
||||
<mat-form-field class="example-full-width">
|
||||
<mat-label>Time</mat-label>
|
||||
<mat-icon matPrefix>schedule</mat-icon>
|
||||
<input
|
||||
type="time"
|
||||
(focus)="onTimeFocus()"
|
||||
[(ngModel)]="selectedTime"
|
||||
step="60"
|
||||
matInput
|
||||
(keydown)="onTimeKeyDown($event)"
|
||||
/>
|
||||
@if (selectedTime) {
|
||||
<mat-icon
|
||||
style="cursor: pointer"
|
||||
matSuffix
|
||||
(click)="onTimeClear($event)"
|
||||
>close
|
||||
</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedTime) {
|
||||
<mat-form-field [@expandFade]>
|
||||
<mat-icon matPrefix>alarm</mat-icon>
|
||||
<mat-label>{{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }}</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="selectedReminderCfgId"
|
||||
name="type"
|
||||
required="true"
|
||||
>
|
||||
@for (remindOption of remindAvailableOptions; track remindOption.value) {
|
||||
<mat-option [value]="remindOption.value">
|
||||
{{ remindOption.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -59,9 +59,12 @@
|
|||
}
|
||||
|
||||
.form-ctrl-wrapper {
|
||||
margin: 0 16px;
|
||||
margin-top: 16px;
|
||||
//border-top: 1px solid var(--separator-color);
|
||||
margin: 16px 16px 0;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
position: relative;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.press-enter-msg {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import {
|
||||
|
|
@ -70,6 +71,7 @@ const DEFAULT_TIME = '09:00';
|
|||
TranslatePipe,
|
||||
MatButton,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatCalendar,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||
import { ReminderService } from './reminder.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import {
|
||||
concatMap,
|
||||
delay,
|
||||
|
|
@ -123,6 +124,11 @@ export class ReminderModule {
|
|||
|
||||
@throttle(60000)
|
||||
private _showNotification(reminders: TaskWithReminderData[]): void {
|
||||
// Skip on Android - we use native notifications with snooze button instead
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMultiple = reminders.length > 1;
|
||||
const title = isMultiple
|
||||
? '"' +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,674 @@
|
|||
import { getFirstRepeatOccurrence } from './get-first-repeat-occurrence.util';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
|
||||
const createMockRepeatCfg = (overrides: Partial<TaskRepeatCfg> = {}): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'test-id',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getFirstRepeatOccurrence', () => {
|
||||
describe('DAILY', () => {
|
||||
it('should return today for daily repeat', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(0); // January
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should return startDate if in future for daily repeat', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-20',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEKLY', () => {
|
||||
it('should return today if today matches the weekly pattern', () => {
|
||||
// Wednesday, January 15, 2025
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
tuesday: false,
|
||||
thursday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(15); // Wednesday
|
||||
});
|
||||
|
||||
it('should return next matching day if today does not match pattern', () => {
|
||||
// Saturday, January 18, 2025
|
||||
const today = new Date('2025-01-18T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
tuesday: false,
|
||||
thursday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next matching day is Monday, January 20
|
||||
expect(result!.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
it('should handle Sunday as first day of week', () => {
|
||||
// Friday, January 17, 2025
|
||||
const today = new Date('2025-01-17T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
sunday: true,
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next Sunday is January 19
|
||||
expect(result!.getDate()).toBe(19);
|
||||
});
|
||||
|
||||
it('should return null if no days are selected', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MONTHLY', () => {
|
||||
it('should return this month if repeat day has not passed', () => {
|
||||
// January 10, 2025
|
||||
const today = new Date('2025-01-10T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-15', // 15th of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(0); // January
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should return today if today is the repeat day', () => {
|
||||
// January 15, 2025
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-15', // 15th of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(0);
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should return next month if repeat day has passed', () => {
|
||||
// January 20, 2025
|
||||
const today = new Date('2025-01-20T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-15', // 15th of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle month-end dates (31st)', () => {
|
||||
// February 1, 2025
|
||||
const today = new Date('2025-02-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-31', // 31st of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// February only has 28 days in 2025
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YEARLY', () => {
|
||||
it('should return this year if date has not passed', () => {
|
||||
// March 1, 2025
|
||||
const today = new Date('2025-03-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-15', // June 15
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(5); // June
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should return next year if date has passed', () => {
|
||||
// August 1, 2025
|
||||
const today = new Date('2025-08-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-15', // June 15
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2026);
|
||||
expect(result!.getMonth()).toBe(5); // June
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle Feb 29 in non-leap year', () => {
|
||||
// January 1, 2025 (not a leap year)
|
||||
const today = new Date('2025-01-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-02-29', // Feb 29 (2024 is leap year)
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(28); // Falls back to 28
|
||||
});
|
||||
|
||||
it('should handle Feb 29 in leap year', () => {
|
||||
// January 1, 2028 (leap year)
|
||||
const today = new Date('2028-01-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-02-29', // Feb 29
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2028);
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(29); // Feb 29 exists
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return null for invalid repeatEvery', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 0,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for negative repeatEvery', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: -1,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing startDate', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: undefined,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should return null for unknown repeat cycle', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'UNKNOWN' as any,
|
||||
repeatEvery: 1,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world bug scenarios (#5594)', () => {
|
||||
it('should return Monday for Mon/Wed/Fri repeat created on Saturday', () => {
|
||||
// This is the exact scenario from the bug report
|
||||
// Saturday, December 14, 2024
|
||||
const saturday = new Date('2024-12-14T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-14', // Start date is Saturday
|
||||
monday: true,
|
||||
tuesday: false,
|
||||
wednesday: true,
|
||||
thursday: false,
|
||||
friday: true,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, saturday);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next Monday is December 16
|
||||
expect(result!.getDate()).toBe(16);
|
||||
expect(result!.getDay()).toBe(1); // Monday
|
||||
});
|
||||
|
||||
it('should return Sunday for Sunday-only repeat created on Saturday', () => {
|
||||
// Saturday, December 14, 2024
|
||||
const saturday = new Date('2024-12-14T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-14',
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: true,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, saturday);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next Sunday is December 15
|
||||
expect(result!.getDate()).toBe(15);
|
||||
expect(result!.getDay()).toBe(0); // Sunday
|
||||
});
|
||||
|
||||
it('should return today for daily repeat when today matches start date', () => {
|
||||
const today = new Date('2024-12-14T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-14',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(14);
|
||||
});
|
||||
|
||||
it('should return future date for daily repeat with future start date', () => {
|
||||
// Today is December 14, start date is December 20
|
||||
const today = new Date('2024-12-14T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-20',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEKLY additional scenarios', () => {
|
||||
it('should return today for weekday repeat when today is a weekday', () => {
|
||||
// Monday, January 13, 2025
|
||||
const monday = new Date('2025-01-13T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: true,
|
||||
tuesday: true,
|
||||
wednesday: true,
|
||||
thursday: true,
|
||||
friday: true,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, monday);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(13); // Today (Monday)
|
||||
});
|
||||
|
||||
it('should return Saturday for weekend-only repeat created on Friday', () => {
|
||||
// Friday, January 17, 2025
|
||||
const friday = new Date('2025-01-17T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: true,
|
||||
sunday: true,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, friday);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next Saturday is January 18
|
||||
expect(result!.getDate()).toBe(18);
|
||||
expect(result!.getDay()).toBe(6); // Saturday
|
||||
});
|
||||
|
||||
it('should return today for all-days repeat', () => {
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: true,
|
||||
tuesday: true,
|
||||
wednesday: true,
|
||||
thursday: true,
|
||||
friday: true,
|
||||
saturday: true,
|
||||
sunday: true,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(15); // Today
|
||||
});
|
||||
|
||||
it('should handle Thursday-only repeat on Monday', () => {
|
||||
// Monday, January 13, 2025
|
||||
const monday = new Date('2025-01-13T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: true,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, monday);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Next Thursday is January 16
|
||||
expect(result!.getDate()).toBe(16);
|
||||
expect(result!.getDay()).toBe(4); // Thursday
|
||||
});
|
||||
});
|
||||
|
||||
describe('MONTHLY additional scenarios', () => {
|
||||
it('should handle 30th in months with 30 days', () => {
|
||||
// April 1, 2025 (April has 30 days)
|
||||
const today = new Date('2025-04-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-30', // 30th of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(3); // April
|
||||
expect(result!.getDate()).toBe(30);
|
||||
});
|
||||
|
||||
it('should handle 31st in February (clamped to 28)', () => {
|
||||
// February 1, 2025 (not leap year)
|
||||
const today = new Date('2025-02-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-31',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(28); // Clamped
|
||||
});
|
||||
|
||||
it('should return 1st of next month for 1st-of-month repeat after the 1st', () => {
|
||||
// January 15, 2025
|
||||
const today = new Date('2025-01-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01', // 1st of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(1); // February
|
||||
expect(result!.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should return today for last-day-of-month repeat on last day', () => {
|
||||
// January 31, 2025
|
||||
const today = new Date('2025-01-31T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-31', // 31st of month
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getMonth()).toBe(0); // January
|
||||
expect(result!.getDate()).toBe(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YEARLY additional scenarios', () => {
|
||||
it('should handle Dec 31 repeat', () => {
|
||||
// October 1, 2025
|
||||
const today = new Date('2025-10-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-12-31', // Dec 31
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(11); // December
|
||||
expect(result!.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it('should handle Jan 1 repeat when today is Jan 1', () => {
|
||||
// January 1, 2025
|
||||
const today = new Date('2025-01-01T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-01-01', // Jan 1
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(0); // January
|
||||
expect(result!.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should return next year for Jan 1 repeat when today is Jan 2', () => {
|
||||
// January 2, 2025
|
||||
const today = new Date('2025-01-02T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-01-01', // Jan 1
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2026);
|
||||
expect(result!.getMonth()).toBe(0); // January
|
||||
expect(result!.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle today being the exact yearly repeat date', () => {
|
||||
// June 15, 2025
|
||||
const today = new Date('2025-06-15T12:00:00');
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-15',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(5); // June
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time consistency', () => {
|
||||
it('should always return date at noon to avoid DST issues', () => {
|
||||
const today = new Date('2025-03-15T03:00:00'); // Around DST change
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-03-01',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getHours()).toBe(12); // Noon
|
||||
});
|
||||
|
||||
it('should handle early morning times correctly', () => {
|
||||
const today = new Date('2025-01-15T01:00:00'); // 1 AM
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle late night times correctly', () => {
|
||||
const today = new Date('2025-01-15T23:59:00'); // 11:59 PM
|
||||
const cfg = createMockRepeatCfg({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2025-01-01',
|
||||
});
|
||||
|
||||
const result = getFirstRepeatOccurrence(cfg, today);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { TASK_REPEAT_WEEKDAY_MAP, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
|
||||
/**
|
||||
* Gets the first valid repeat occurrence date based on the repeat configuration.
|
||||
* This is used when initially creating a repeat config to determine when the first
|
||||
* task instance should be scheduled.
|
||||
*
|
||||
* Unlike getNextRepeatOccurrence which finds the next occurrence AFTER lastTaskCreationDay,
|
||||
* this function finds the first occurrence ON OR AFTER the startDate.
|
||||
*
|
||||
* @param taskRepeatCfg The repeat configuration
|
||||
* @param today The current date to check against
|
||||
* @returns The first valid occurrence date, or null if none found
|
||||
*/
|
||||
export const getFirstRepeatOccurrence = (
|
||||
taskRepeatCfg: TaskRepeatCfg,
|
||||
today: Date = new Date(),
|
||||
): Date | null => {
|
||||
if (!Number.isInteger(taskRepeatCfg.repeatEvery) || taskRepeatCfg.repeatEvery < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDateStr = taskRepeatCfg.startDate || '1970-01-01';
|
||||
const startDateDate = dateStrToUtcDate(startDateStr);
|
||||
|
||||
// Use noon (12:00) to avoid DST issues
|
||||
const checkDate = new Date(today);
|
||||
checkDate.setHours(12, 0, 0, 0);
|
||||
startDateDate.setHours(12, 0, 0, 0);
|
||||
|
||||
// If start date is in the future, start checking from start date
|
||||
if (startDateDate > checkDate) {
|
||||
checkDate.setTime(startDateDate.getTime());
|
||||
}
|
||||
|
||||
switch (taskRepeatCfg.repeatCycle) {
|
||||
case 'DAILY': {
|
||||
// For daily, the first occurrence is today (or startDate if in future)
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
case 'WEEKLY': {
|
||||
const maxDaysToCheck = 7; // Only need to check one week
|
||||
|
||||
for (let i = 0; i < maxDaysToCheck; i++) {
|
||||
const dayOfWeek = checkDate.getDay();
|
||||
const dayStr = TASK_REPEAT_WEEKDAY_MAP[
|
||||
dayOfWeek
|
||||
] as keyof typeof TASK_REPEAT_WEEKDAY_MAP;
|
||||
|
||||
if (dayStr && taskRepeatCfg[dayStr as keyof TaskRepeatCfg] === true) {
|
||||
return checkDate;
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'MONTHLY': {
|
||||
const dayOfMonthRepeat = startDateDate.getDate();
|
||||
const currentDayOfMonth = checkDate.getDate();
|
||||
|
||||
// If we haven't passed the repeat day this month, set to that day
|
||||
if (currentDayOfMonth <= dayOfMonthRepeat) {
|
||||
const lastDayOfMonth = new Date(
|
||||
checkDate.getFullYear(),
|
||||
checkDate.getMonth() + 1,
|
||||
0,
|
||||
).getDate();
|
||||
checkDate.setDate(Math.min(dayOfMonthRepeat, lastDayOfMonth));
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
// Otherwise, move to next month
|
||||
checkDate.setMonth(checkDate.getMonth() + 1);
|
||||
checkDate.setDate(1);
|
||||
const lastDayOfMonth = new Date(
|
||||
checkDate.getFullYear(),
|
||||
checkDate.getMonth() + 1,
|
||||
0,
|
||||
).getDate();
|
||||
checkDate.setDate(Math.min(dayOfMonthRepeat, lastDayOfMonth));
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
case 'YEARLY': {
|
||||
const dayOfMonthRepeat = startDateDate.getDate();
|
||||
const monthOfRepeat = startDateDate.getMonth();
|
||||
|
||||
// Check if we can still hit this year's occurrence
|
||||
const thisYearOccurrence = new Date(checkDate.getFullYear(), monthOfRepeat, 1);
|
||||
thisYearOccurrence.setHours(12, 0, 0, 0);
|
||||
|
||||
// Handle Feb 29 for non-leap years
|
||||
const isLeapYear = (year: number): boolean => {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
};
|
||||
|
||||
const setYearlyDate = (date: Date): void => {
|
||||
date.setMonth(monthOfRepeat);
|
||||
if (monthOfRepeat === 1 && dayOfMonthRepeat === 29) {
|
||||
date.setDate(isLeapYear(date.getFullYear()) ? 29 : 28);
|
||||
} else {
|
||||
date.setDate(dayOfMonthRepeat);
|
||||
}
|
||||
};
|
||||
|
||||
setYearlyDate(thisYearOccurrence);
|
||||
|
||||
if (thisYearOccurrence >= checkDate) {
|
||||
return thisYearOccurrence;
|
||||
}
|
||||
|
||||
// Otherwise next year
|
||||
const nextYearOccurrence = new Date(checkDate.getFullYear() + 1, monthOfRepeat, 1);
|
||||
nextYearOccurrence.setHours(12, 0, 0, 0);
|
||||
setYearlyDate(nextYearOccurrence);
|
||||
return nextYearOccurrence;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
|||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
|
||||
describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
||||
let actions$: Observable<Action>;
|
||||
|
|
@ -82,6 +83,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
'getTasksByRepeatCfgId$',
|
||||
'getByIdsLive$',
|
||||
'getArchiveTasksForRepeatCfgId',
|
||||
'update',
|
||||
]);
|
||||
|
||||
const taskRepeatCfgServiceSpy = jasmine.createSpyObj('TaskRepeatCfgService', [
|
||||
|
|
@ -167,7 +169,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
|
||||
describe('updateTaskAfterMakingItRepeatable$', () => {
|
||||
it('should capture subtasks as templates when adding repeat config', () => {
|
||||
it('should capture subtasks as templates and set lastTaskCreationDay when adding repeat config', () => {
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: mockRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
|
|
@ -181,19 +183,22 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Should update config with subtask templates AND lastTaskCreationDay (#5594)
|
||||
expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith(
|
||||
'repeat-cfg-id',
|
||||
{
|
||||
jasmine.objectContaining({
|
||||
subTaskTemplates: [
|
||||
{ title: 'SubTask 1', notes: 'Notes 1', timeEstimate: 3600000 },
|
||||
{ title: 'SubTask 2', notes: 'Notes 2', timeEstimate: 7200000 },
|
||||
],
|
||||
},
|
||||
lastTaskCreationDay: jasmine.any(String),
|
||||
lastTaskCreation: jasmine.any(Number),
|
||||
}),
|
||||
);
|
||||
expect((effects as any)._updateRegularTaskInstance).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle task with no subtasks', () => {
|
||||
it('should handle task with no subtasks and still set lastTaskCreationDay', () => {
|
||||
const taskWithoutSubs: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
|
|
@ -211,13 +216,241 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Should update config with empty subtasks AND lastTaskCreationDay (#5594)
|
||||
expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith(
|
||||
'repeat-cfg-id',
|
||||
{
|
||||
jasmine.objectContaining({
|
||||
subTaskTemplates: [],
|
||||
},
|
||||
lastTaskCreationDay: jasmine.any(String),
|
||||
lastTaskCreation: jasmine.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update task dueDay when first occurrence differs from current (#5594)', () => {
|
||||
// Scenario: Task is created today, but repeat config only matches future days
|
||||
// Calculate next Monday from today
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
const daysUntilMonday = (8 - today.getDay()) % 7 || 7; // Next Monday (not today even if today is Monday)
|
||||
const nextMonday = new Date(today);
|
||||
nextMonday.setDate(today.getDate() + daysUntilMonday);
|
||||
const mondayStr = getDbDateStr(nextMonday);
|
||||
|
||||
const taskCreatedToday: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
dueDay: todayStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const weeklyRepeatCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: todayStr,
|
||||
monday: true,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: weeklyRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskCreatedToday));
|
||||
|
||||
spyOn(effects as any, '_updateRegularTaskInstance');
|
||||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Verify that update was called with next Monday
|
||||
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
|
||||
dueDay: mondayStr,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT update task dueDay when first occurrence matches current', () => {
|
||||
// Scenario: Task dueDay already matches first occurrence
|
||||
// Use daily repeat starting today - first occurrence is today
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
||||
const taskCreatedToday: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
dueDay: todayStr, // Matches first occurrence
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const dailyRepeatCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: todayStr,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: dailyRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskCreatedToday));
|
||||
|
||||
spyOn(effects as any, '_updateRegularTaskInstance');
|
||||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Verify that update was NOT called (same date)
|
||||
expect(taskService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update dueDay for daily repeat with future start date', () => {
|
||||
// Scenario: Task created today, but start date is 7 days in the future
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
const futureDate = new Date(today);
|
||||
futureDate.setDate(today.getDate() + 7);
|
||||
const futureStartStr = getDbDateStr(futureDate);
|
||||
|
||||
const taskCreatedToday: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
dueDay: todayStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const dailyRepeatCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: futureStartStr,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: dailyRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskCreatedToday));
|
||||
|
||||
spyOn(effects as any, '_updateRegularTaskInstance');
|
||||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Verify that update was called with future start date
|
||||
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
|
||||
dueDay: futureStartStr,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update dueDay for monthly repeat when today is after repeat day', () => {
|
||||
// Scenario: Monthly repeat on the 1st, but we're past the 1st
|
||||
// First occurrence should be next month's 1st
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
||||
// Find next 1st of month
|
||||
const nextFirst = new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
||||
const nextFirstStr = getDbDateStr(nextFirst);
|
||||
|
||||
// Start date is the 1st of current month (already passed)
|
||||
const startDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const startDateStr = getDbDateStr(startDate);
|
||||
|
||||
const taskCreatedToday: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
dueDay: todayStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const monthlyRepeatCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: startDateStr, // 1st of month
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: monthlyRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskCreatedToday));
|
||||
|
||||
spyOn(effects as any, '_updateRegularTaskInstance');
|
||||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Should update if today is not the 1st
|
||||
if (today.getDate() !== 1) {
|
||||
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
|
||||
dueDay: nextFirstStr,
|
||||
});
|
||||
} else {
|
||||
// If today IS the 1st, no update needed
|
||||
expect(taskService.update).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use task created date as fallback when dueDay is missing', () => {
|
||||
// Scenario: Task has no dueDay, should use created date for comparison
|
||||
// Calculate next Monday from today
|
||||
const today = new Date();
|
||||
const daysUntilMonday = (8 - today.getDay()) % 7 || 7; // Next Monday (not today even if today is Monday)
|
||||
const nextMonday = new Date(today);
|
||||
nextMonday.setDate(today.getDate() + daysUntilMonday);
|
||||
const mondayStr = getDbDateStr(nextMonday);
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
||||
const taskWithoutDueDay: TaskWithSubTasks = {
|
||||
...mockTask,
|
||||
subTasks: [],
|
||||
dueDay: undefined, // No dueDay
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const weeklyRepeatCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: todayStr,
|
||||
monday: true,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: weeklyRepeatCfg,
|
||||
taskId: 'parent-task-id',
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskWithoutDueDay));
|
||||
|
||||
spyOn(effects as any, '_updateRegularTaskInstance');
|
||||
|
||||
effects.updateTaskAfterMakingItRepeatable$.subscribe().unsubscribe();
|
||||
|
||||
// Verify that update was called with next Monday
|
||||
expect(taskService.update).toHaveBeenCalledWith('parent-task-id', {
|
||||
dueDay: mondayStr,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStartDateOnComplete$', () => {
|
||||
|
|
@ -537,7 +770,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(mockTask));
|
||||
|
||||
// For DAILY repeat, getNewestPossibleDueDate returns today
|
||||
// For DAILY repeat, getFirstRepeatOccurrence returns today
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(todayStr).getTime(),
|
||||
|
|
@ -551,7 +784,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -615,7 +848,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -624,19 +857,39 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should fallback to task.dueDay when startDate is undefined (issue #5594)', () => {
|
||||
it('should calculate next occurrence when startDate is undefined (issue #5594)', () => {
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const dueDayStr = '2025-02-01';
|
||||
const startTimeStr = '10:00';
|
||||
const today = new Date();
|
||||
const todayDayOfWeek = today.getDay();
|
||||
|
||||
// Create a repeat config without startDate - getFirstRepeatOccurrence
|
||||
// will find the next matching weekday from today
|
||||
// Set the repeat to today's weekday so we get today's date
|
||||
const weekdayKeys = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
const todayWeekdayKey = weekdayKeys[todayDayOfWeek];
|
||||
|
||||
// Create a repeat config without startDate - should gracefully fallback
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: {
|
||||
...mockRepeatCfg,
|
||||
startDate: undefined,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: true,
|
||||
monday: todayWeekdayKey === 'monday',
|
||||
tuesday: todayWeekdayKey === 'tuesday',
|
||||
wednesday: todayWeekdayKey === 'wednesday',
|
||||
thursday: todayWeekdayKey === 'thursday',
|
||||
friday: todayWeekdayKey === 'friday',
|
||||
saturday: todayWeekdayKey === 'saturday',
|
||||
sunday: todayWeekdayKey === 'sunday',
|
||||
},
|
||||
taskId: 'parent-task-id',
|
||||
startTime: startTimeStr,
|
||||
|
|
@ -644,29 +897,25 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(mockTask));
|
||||
|
||||
const taskWithDueDay: Task = {
|
||||
...mockTask,
|
||||
dueDay: dueDayStr,
|
||||
};
|
||||
|
||||
taskService.getByIdOnce$.and.returnValue(of(taskWithDueDay));
|
||||
|
||||
// When startDate is undefined, skip getNewestPossibleDueDate and use task.dueDay
|
||||
// When startDate is undefined, getFirstRepeatOccurrence still calculates
|
||||
// the next valid occurrence. For WEEKLY on today's day, it returns today.
|
||||
const todayStr = getDbDateStr();
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(dueDayStr).getTime(),
|
||||
dateStrToUtcDate(todayStr).getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: taskWithDueDay,
|
||||
task: mockTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -675,9 +924,8 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should fallback to task.dueWithTime when startDate is undefined and no dueDay', () => {
|
||||
it('should calculate today for DAILY when startDate is undefined', () => {
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const dueWithTime = new Date(2025, 1, 15, 14, 30).getTime(); // Feb 15, 2025 14:30
|
||||
const startTimeStr = '10:00';
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
|
|
@ -693,27 +941,24 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(mockTask));
|
||||
|
||||
const taskWithDueWithTime: Task = {
|
||||
...mockTask,
|
||||
dueDay: undefined,
|
||||
dueWithTime,
|
||||
};
|
||||
|
||||
taskService.getByIdOnce$.and.returnValue(of(taskWithDueWithTime));
|
||||
|
||||
// Fallback to task.dueWithTime when no startDate and no dueDay
|
||||
const expectedDateTime = getDateTimeFromClockString(startTimeStr, dueWithTime);
|
||||
// For DAILY with undefined startDate, getFirstRepeatOccurrence returns today
|
||||
const todayStr = getDbDateStr();
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(todayStr).getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: taskWithDueWithTime,
|
||||
task: mockTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -762,7 +1007,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -806,7 +1051,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -815,17 +1060,15 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should fallback to task.dueDay when WEEKLY pattern does not match today (issue #5594)', () => {
|
||||
it('should schedule for next matching weekday when WEEKLY pattern does not match today (issue #5594)', () => {
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const todayStr = getDbDateStr();
|
||||
const dueDayStr = '2025-03-15';
|
||||
const startTimeStr = '10:00';
|
||||
const today = new Date();
|
||||
const todayDayOfWeek = today.getDay();
|
||||
|
||||
// Create a repeat config for a day that is NOT today
|
||||
// Find a weekday that is NOT today
|
||||
const notTodayDayOfWeek = (todayDayOfWeek + 3) % 7; // 3 days from today
|
||||
// Create a repeat config for a day that is 3 days from today
|
||||
const targetDayOfWeek = (todayDayOfWeek + 3) % 7;
|
||||
const weekdayKeys = [
|
||||
'sunday',
|
||||
'monday',
|
||||
|
|
@ -835,7 +1078,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
const notTodayWeekdayKey = weekdayKeys[notTodayDayOfWeek];
|
||||
const targetWeekdayKey = weekdayKeys[targetDayOfWeek];
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: {
|
||||
|
|
@ -843,13 +1086,13 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
startDate: todayStr,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: notTodayWeekdayKey === 'monday',
|
||||
tuesday: notTodayWeekdayKey === 'tuesday',
|
||||
wednesday: notTodayWeekdayKey === 'wednesday',
|
||||
thursday: notTodayWeekdayKey === 'thursday',
|
||||
friday: notTodayWeekdayKey === 'friday',
|
||||
saturday: notTodayWeekdayKey === 'saturday',
|
||||
sunday: notTodayWeekdayKey === 'sunday',
|
||||
monday: targetWeekdayKey === 'monday',
|
||||
tuesday: targetWeekdayKey === 'tuesday',
|
||||
wednesday: targetWeekdayKey === 'wednesday',
|
||||
thursday: targetWeekdayKey === 'thursday',
|
||||
friday: targetWeekdayKey === 'friday',
|
||||
saturday: targetWeekdayKey === 'saturday',
|
||||
sunday: targetWeekdayKey === 'sunday',
|
||||
},
|
||||
taskId: 'parent-task-id',
|
||||
startTime: startTimeStr,
|
||||
|
|
@ -857,30 +1100,29 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(mockTask));
|
||||
|
||||
const taskWithDueDay: Task = {
|
||||
...mockTask,
|
||||
dueDay: dueDayStr,
|
||||
};
|
||||
// When WEEKLY pattern doesn't match today, getFirstRepeatOccurrence
|
||||
// returns the NEXT matching weekday (3 days from today)
|
||||
const nextMatchingDate = new Date(today);
|
||||
nextMatchingDate.setDate(nextMatchingDate.getDate() + 3);
|
||||
nextMatchingDate.setHours(12, 0, 0, 0);
|
||||
const nextMatchingDateStr = getDbDateStr(nextMatchingDate);
|
||||
|
||||
taskService.getByIdOnce$.and.returnValue(of(taskWithDueDay));
|
||||
|
||||
// When WEEKLY pattern doesn't match today, getNewestPossibleDueDate returns null
|
||||
// So we should fall back to task.dueDay
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(dueDayStr).getTime(),
|
||||
dateStrToUtcDate(nextMatchingDateStr).getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: taskWithDueDay,
|
||||
task: mockTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -917,7 +1159,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
|
||||
taskService.getByIdOnce$.and.returnValue(of(taskWithDueDay));
|
||||
|
||||
// Should catch the error from getNewestPossibleDueDate and fall back
|
||||
// Should handle gracefully and fall back to task.dueDay
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(dueDayStr).getTime(),
|
||||
|
|
@ -931,7 +1173,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -975,7 +1217,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1030,7 +1272,7 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1120,27 +1362,27 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
|
||||
describe('Scenario: Wednesday creating task for different weekdays', () => {
|
||||
// Today is Wednesday (Jan 15, 2025)
|
||||
// getFirstRepeatOccurrence finds the NEXT matching weekday from today
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'WEEKLY on Wednesday (today) - should schedule for today',
|
||||
weekday: 'wednesday',
|
||||
expectedDateStr: '2025-01-15',
|
||||
expectedDateStr: '2025-01-15', // Today
|
||||
shouldMatchToday: true,
|
||||
},
|
||||
{
|
||||
// Friday (Jan 10) is in the previous ISO week, so diffInWeeks < 0 causes early break
|
||||
// Falls back to task.dueDay (Jan 20)
|
||||
name: 'WEEKLY on Friday - previous week, falls back to task.dueDay',
|
||||
// Friday is 2 days ahead - getFirstRepeatOccurrence finds next Friday
|
||||
name: 'WEEKLY on Friday - schedules for next Friday (Jan 17)',
|
||||
weekday: 'friday',
|
||||
expectedDateStr: '2025-01-20', // Fallback to task.dueDay
|
||||
shouldMatchToday: false, // Uses fallback, not calculated date
|
||||
expectedDateStr: '2025-01-17', // Next Friday (2 days from today)
|
||||
shouldMatchToday: true,
|
||||
},
|
||||
{
|
||||
// Monday is 2 days behind, getNewestPossibleDueDate returns last Monday (Jan 13)
|
||||
name: 'WEEKLY on Monday - returns last Monday (Jan 13)',
|
||||
// Monday is 5 days ahead - getFirstRepeatOccurrence finds next Monday
|
||||
name: 'WEEKLY on Monday - schedules for next Monday (Jan 20)',
|
||||
weekday: 'monday',
|
||||
expectedDateStr: '2025-01-13', // Last Monday before Jan 15
|
||||
shouldMatchToday: true, // Uses calculated date, not fallback
|
||||
expectedDateStr: '2025-01-20', // Next Monday (5 days from today)
|
||||
shouldMatchToday: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -1195,7 +1437,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1243,7 +1485,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1252,13 +1494,13 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should return last month occurrence when MONTHLY day has not occurred yet this month', () => {
|
||||
it('should schedule for future date when MONTHLY day has not occurred yet this month', () => {
|
||||
// Today is Jan 15, repeat is for the 20th
|
||||
// getNewestPossibleDueDate returns Dec 20 (last occurrence of the 20th)
|
||||
// getFirstRepeatOccurrence returns Jan 20 (first future occurrence of the 20th)
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
// Dec 20, 2024 is the most recent 20th before Jan 15, 2025
|
||||
const expectedDateStr = '2024-12-20';
|
||||
// Jan 20, 2025 is the first 20th ON OR AFTER Jan 15, 2025
|
||||
const expectedDateStr = '2025-01-20';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
|
|
@ -1278,7 +1520,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// getNewestPossibleDueDate returns Dec 20, 2024 (the last valid occurrence)
|
||||
// getFirstRepeatOccurrence returns Jan 20, 2025 (the next valid occurrence)
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(expectedDateStr).getTime(),
|
||||
|
|
@ -1292,7 +1534,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1338,7 +1580,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1384,7 +1626,7 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
@ -1395,15 +1637,15 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
});
|
||||
|
||||
describe('Scenario: Edge cases and error handling', () => {
|
||||
it('should handle future startDate gracefully (fallback to task.dueDay)', () => {
|
||||
it('should schedule for future startDate correctly (not fallback)', () => {
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '10:00';
|
||||
const fallbackDueDay = '2025-01-15';
|
||||
const futureStartDate = '2025-02-01';
|
||||
|
||||
// startDate is in the future
|
||||
// startDate is in the future - getFirstRepeatOccurrence handles this correctly
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2025-02-01', // Future date
|
||||
startDate: futureStartDate, // Future date
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
};
|
||||
|
|
@ -1417,28 +1659,270 @@ describe('TaskRepeatCfgEffects - Deterministic Date Scenarios', () => {
|
|||
|
||||
actions$ = hot('-a', { a: action });
|
||||
|
||||
const taskWithFallback: Task = {
|
||||
...baseTask,
|
||||
dueDay: fallbackDueDay,
|
||||
};
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
taskService.getByIdOnce$.and.returnValue(of(taskWithFallback));
|
||||
|
||||
// Future startDate means getNewestPossibleDueDate returns null
|
||||
// Future startDate is handled by getFirstRepeatOccurrence - returns the startDate
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate(fallbackDueDay).getTime(),
|
||||
dateStrToUtcDate(futureStartDate).getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: taskWithFallback,
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: isToday(expectedDateTime),
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
a: expectedAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Critical tests for isSkipAutoRemoveFromToday behavior (#5594 fix)
|
||||
* These tests explicitly verify that:
|
||||
* - Tasks scheduled for TODAY have isSkipAutoRemoveFromToday = true (stay in today list)
|
||||
* - Tasks scheduled for FUTURE have isSkipAutoRemoveFromToday = false (removed from today list)
|
||||
*/
|
||||
describe('Scenario: isSkipAutoRemoveFromToday behavior', () => {
|
||||
it('should set isSkipAutoRemoveFromToday=TRUE when task is scheduled for TODAY', () => {
|
||||
// Today is Jan 15, 2025 (Wednesday) - DAILY pattern means scheduled for today
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2025-01-15', // Today
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: repeatCfg,
|
||||
taskId: 'test-task-id',
|
||||
startTime: startTimeStr,
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// Scheduled for today (Jan 15) - should stay in today list
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate('2025-01-15').getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true, // MUST be true for today
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
a: expectedAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set isSkipAutoRemoveFromToday=FALSE when task is scheduled for FUTURE (WEEKLY)', () => {
|
||||
// Today is Jan 15, 2025 (Wednesday) - WEEKLY on Friday means scheduled for Jan 17
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2025-01-15',
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: true, // Friday is 2 days from Wednesday
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: repeatCfg,
|
||||
taskId: 'test-task-id',
|
||||
startTime: startTimeStr,
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// Scheduled for Jan 17 (Friday) - should be removed from today list
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate('2025-01-17').getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: false, // MUST be false for future dates
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
a: expectedAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set isSkipAutoRemoveFromToday=FALSE when task is scheduled for FUTURE (MONTHLY)', () => {
|
||||
// Today is Jan 15, 2025 - MONTHLY on 20th means scheduled for Jan 20
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2024-12-20', // 20th of month
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: repeatCfg,
|
||||
taskId: 'test-task-id',
|
||||
startTime: startTimeStr,
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// Scheduled for Jan 20 - should be removed from today list
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate('2025-01-20').getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: false, // MUST be false for future dates
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
a: expectedAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set isSkipAutoRemoveFromToday=FALSE when startDate is in the FUTURE', () => {
|
||||
// Today is Jan 15, 2025 - startDate Feb 1 means scheduled for Feb 1
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2025-02-01', // Future start date
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: repeatCfg,
|
||||
taskId: 'test-task-id',
|
||||
startTime: startTimeStr,
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// Scheduled for Feb 1 (future start date) - should be removed from today list
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate('2025-02-01').getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: false, // MUST be false for future start dates
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
a: expectedAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set isSkipAutoRemoveFromToday=TRUE when WEEKLY matches TODAY', () => {
|
||||
// Today is Jan 15, 2025 (Wednesday) - WEEKLY on Wednesday means today
|
||||
testScheduler.run(({ hot, expectObservable }) => {
|
||||
const startTimeStr = '09:00';
|
||||
|
||||
const repeatCfg: TaskRepeatCfgCopy = {
|
||||
...baseRepeatCfg,
|
||||
startDate: '2025-01-15',
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: true, // Wednesday is today
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: repeatCfg,
|
||||
taskId: 'test-task-id',
|
||||
startTime: startTimeStr,
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
});
|
||||
|
||||
actions$ = hot('-a', { a: action });
|
||||
taskService.getByIdOnce$.and.returnValue(of(baseTask));
|
||||
|
||||
// Scheduled for today (Wednesday) - should stay in today list
|
||||
const expectedDateTime = getDateTimeFromClockString(
|
||||
startTimeStr,
|
||||
dateStrToUtcDate('2025-01-15').getTime(),
|
||||
);
|
||||
|
||||
const expectedAction = TaskSharedActions.scheduleTaskWithTime({
|
||||
task: baseTask,
|
||||
dueWithTime: expectedDateTime,
|
||||
remindAt: remindOptionToMilliseconds(
|
||||
expectedDateTime,
|
||||
TaskReminderOptionId.AtStart,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true, // MUST be true for today
|
||||
});
|
||||
|
||||
expectObservable(effects.addRepeatCfgToTaskUpdateTask$).toBe('-a', {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { EMPTY, forkJoin, from, Observable, of as rxOf } from 'rxjs';
|
|||
import { getEffectiveLastTaskCreationDay } from './get-effective-last-task-creation-day.util';
|
||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { devError } from '../../../util/dev-error';
|
||||
import { getNewestPossibleDueDate } from './get-newest-possible-due-date.util';
|
||||
import { getFirstRepeatOccurrence } from './get-first-repeat-occurrence.util';
|
||||
|
||||
@Injectable()
|
||||
export class TaskRepeatCfgEffects {
|
||||
|
|
@ -62,19 +62,11 @@ export class TaskRepeatCfgEffects {
|
|||
return null; // Return null instead of EMPTY
|
||||
}
|
||||
// Calculate the correct target day based on the repeat pattern (fixes #5594)
|
||||
// instead of using startDate which always defaults to today
|
||||
let calculatedTargetDate: Date | null = null;
|
||||
try {
|
||||
// getNewestPossibleDueDate throws if startDate is undefined or repeatEvery is invalid
|
||||
if (taskRepeatCfg.startDate) {
|
||||
calculatedTargetDate = getNewestPossibleDueDate(
|
||||
taskRepeatCfg as TaskRepeatCfg,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to existing logic if calculation fails
|
||||
}
|
||||
// Use getFirstRepeatOccurrence which handles future start dates correctly
|
||||
const calculatedTargetDate = getFirstRepeatOccurrence(
|
||||
taskRepeatCfg as TaskRepeatCfg,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
// Use calculated date if available, otherwise fall back to existing logic
|
||||
const targetDayTimestamp = calculatedTargetDate
|
||||
|
|
@ -86,6 +78,10 @@ export class TaskRepeatCfgEffects {
|
|||
startTime as string,
|
||||
targetDayTimestamp,
|
||||
);
|
||||
|
||||
// Only skip auto-removal from today if the task is scheduled for today
|
||||
const scheduledForToday = isToday(dateTime);
|
||||
|
||||
return TaskSharedActions.scheduleTaskWithTime({
|
||||
task,
|
||||
dueWithTime: dateTime,
|
||||
|
|
@ -94,7 +90,7 @@ export class TaskRepeatCfgEffects {
|
|||
remindAt as TaskReminderOptionId,
|
||||
),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
isSkipAutoRemoveFromToday: scheduledForToday,
|
||||
});
|
||||
}),
|
||||
filter(
|
||||
|
|
@ -112,47 +108,81 @@ export class TaskRepeatCfgEffects {
|
|||
// ArchiveOperationHandler._handleDeleteTaskRepeatCfg, which is the single
|
||||
// source of truth for archive operations.
|
||||
|
||||
updateTaskAfterMakingItRepeatable$ = createEffect(
|
||||
() =>
|
||||
this._localActions$.pipe(
|
||||
ofType(addTaskRepeatCfgToTask),
|
||||
switchMap(({ taskRepeatCfg, taskId }) => {
|
||||
return this._taskService.getByIdWithSubTaskData$(taskId).pipe(
|
||||
first(),
|
||||
map((taskWithSubTasks) => {
|
||||
// Extract subtasks safely, ensuring we handle the type properly
|
||||
const subTasks = Array.isArray(taskWithSubTasks.subTasks)
|
||||
? taskWithSubTasks.subTasks
|
||||
: [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { subTasks: _ignored, ...taskWithoutSubs } = taskWithSubTasks;
|
||||
|
||||
if (subTasks.length === 0) {
|
||||
return {
|
||||
task: taskWithoutSubs,
|
||||
taskRepeatCfg,
|
||||
subTaskTemplates: [],
|
||||
};
|
||||
}
|
||||
|
||||
const subTaskTemplates = this._toSubTaskTemplates(subTasks);
|
||||
updateTaskAfterMakingItRepeatable$ = createEffect(() =>
|
||||
this._localActions$.pipe(
|
||||
ofType(addTaskRepeatCfgToTask),
|
||||
switchMap(({ taskRepeatCfg, taskId }) => {
|
||||
return this._taskService.getByIdWithSubTaskData$(taskId).pipe(
|
||||
first(),
|
||||
map((taskWithSubTasks) => {
|
||||
// Extract subtasks safely, ensuring we handle the type properly
|
||||
const subTasks = Array.isArray(taskWithSubTasks.subTasks)
|
||||
? taskWithSubTasks.subTasks
|
||||
: [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { subTasks: _ignored, ...taskWithoutSubs } = taskWithSubTasks;
|
||||
|
||||
if (subTasks.length === 0) {
|
||||
return {
|
||||
task: taskWithoutSubs,
|
||||
taskRepeatCfg,
|
||||
subTaskTemplates,
|
||||
subTaskTemplates: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
tap(({ task, taskRepeatCfg, subTaskTemplates }) => {
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(taskRepeatCfg.id, {
|
||||
subTaskTemplates,
|
||||
}
|
||||
|
||||
const subTaskTemplates = this._toSubTaskTemplates(subTasks);
|
||||
|
||||
return {
|
||||
task: taskWithoutSubs,
|
||||
taskRepeatCfg,
|
||||
subTaskTemplates,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
map(({ task, taskRepeatCfg, subTaskTemplates }) => {
|
||||
// Calculate the correct first occurrence date (#5594)
|
||||
// and update both the task's dueDay and the repeat config's lastTaskCreationDay
|
||||
const firstOccurrence = getFirstRepeatOccurrence(
|
||||
taskRepeatCfg as TaskRepeatCfg,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const firstOccurrenceStr = firstOccurrence
|
||||
? getDbDateStr(firstOccurrence)
|
||||
: getDbDateStr(new Date());
|
||||
|
||||
// Update repeat config with subtask templates AND the correct lastTaskCreationDay
|
||||
// Setting lastTaskCreationDay to first occurrence indicates a task exists for that day
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(taskRepeatCfg.id, {
|
||||
subTaskTemplates,
|
||||
lastTaskCreationDay: firstOccurrenceStr,
|
||||
lastTaskCreation: firstOccurrence?.getTime() || Date.now(),
|
||||
});
|
||||
|
||||
// Update task's dueDay if it differs from first occurrence
|
||||
const currentDueDay = task.dueDay || getDbDateStr(task.created);
|
||||
if (currentDueDay !== firstOccurrenceStr) {
|
||||
this._taskService.update(task.id, {
|
||||
dueDay: firstOccurrenceStr,
|
||||
});
|
||||
this._updateRegularTaskInstance(task, taskRepeatCfg, taskRepeatCfg);
|
||||
}
|
||||
|
||||
this._updateRegularTaskInstance(task, taskRepeatCfg, taskRepeatCfg);
|
||||
|
||||
// Return action to remove from Today if first occurrence is not today (#5594)
|
||||
// This handles the case where startTime/remindAt are not set, so the
|
||||
// addRepeatCfgToTaskUpdateTask$ effect doesn't fire
|
||||
const isFirstOccurrenceToday = firstOccurrence ? isToday(firstOccurrence) : true;
|
||||
return { task, isFirstOccurrenceToday };
|
||||
}),
|
||||
filter(({ isFirstOccurrenceToday }) => !isFirstOccurrenceToday),
|
||||
map(({ task }) =>
|
||||
TaskSharedActions.removeTasksFromTodayTag({
|
||||
taskIds: [task.id],
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import {
|
|||
selectAllUnprocessedTaskRepeatCfgs,
|
||||
selectTaskRepeatCfgsForExactDay,
|
||||
} from './store/task-repeat-cfg.selectors';
|
||||
import { DEFAULT_TASK, Task, TaskWithSubTasks } from '../tasks/task.model';
|
||||
import {
|
||||
DEFAULT_TASK,
|
||||
Task,
|
||||
TaskWithSubTasks,
|
||||
TaskReminderOptionId,
|
||||
} from '../tasks/task.model';
|
||||
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
|
|
@ -1154,4 +1159,82 @@ describe('TaskRepeatCfgService', () => {
|
|||
expect(subTaskAction1.task.projectId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTaskRepeatCfgToTask dispatch (#5594)', () => {
|
||||
// Note: First occurrence calculation and lastTaskCreationDay updates
|
||||
// are handled by the updateTaskAfterMakingItRepeatable$ effect.
|
||||
// These tests verify the service correctly dispatches the action.
|
||||
|
||||
it('should include startTime and remindAt in dispatched action', () => {
|
||||
const taskId = 'task-123';
|
||||
const projectId = 'project-123';
|
||||
const today = new Date();
|
||||
|
||||
const taskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
title: 'Task with Time',
|
||||
repeatCycle: 'DAILY' as const,
|
||||
repeatEvery: 1,
|
||||
startDate: formatIsoDate(today),
|
||||
startTime: '09:00',
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
};
|
||||
|
||||
service.addTaskRepeatCfgToTask(taskId, projectId, taskRepeatCfg);
|
||||
|
||||
const dispatchedAction = dispatchSpy.calls.mostRecent().args[0];
|
||||
|
||||
expect(dispatchedAction.startTime).toBe('09:00');
|
||||
expect(dispatchedAction.remindAt).toBe(TaskReminderOptionId.AtStart);
|
||||
});
|
||||
|
||||
it('should preserve all taskRepeatCfg properties', () => {
|
||||
const taskId = 'task-123';
|
||||
const projectId = 'project-123';
|
||||
const today = new Date();
|
||||
|
||||
const taskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
title: 'Task with Properties',
|
||||
repeatCycle: 'DAILY' as const,
|
||||
repeatEvery: 2,
|
||||
startDate: formatIsoDate(today),
|
||||
notes: 'Some notes',
|
||||
defaultEstimate: 3600000,
|
||||
tagIds: ['tag1', 'tag2'],
|
||||
};
|
||||
|
||||
service.addTaskRepeatCfgToTask(taskId, projectId, taskRepeatCfg);
|
||||
|
||||
const dispatchedAction = dispatchSpy.calls.mostRecent().args[0];
|
||||
const cfg = dispatchedAction.taskRepeatCfg;
|
||||
|
||||
expect(cfg.title).toBe('Task with Properties');
|
||||
expect(cfg.repeatEvery).toBe(2);
|
||||
expect(cfg.notes).toBe('Some notes');
|
||||
expect(cfg.defaultEstimate).toBe(3600000);
|
||||
expect(cfg.tagIds).toEqual(['tag1', 'tag2']);
|
||||
expect(cfg.projectId).toBe(projectId);
|
||||
expect(cfg.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should dispatch action with taskId', () => {
|
||||
const taskId = 'task-123';
|
||||
const projectId = 'project-123';
|
||||
|
||||
const taskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
title: 'Test Task',
|
||||
repeatCycle: 'DAILY' as const,
|
||||
repeatEvery: 1,
|
||||
};
|
||||
|
||||
service.addTaskRepeatCfgToTask(taskId, projectId, taskRepeatCfg);
|
||||
|
||||
const dispatchedAction = dispatchSpy.calls.mostRecent().args[0];
|
||||
|
||||
expect(dispatchedAction.type).toBe(addTaskRepeatCfgToTask.type);
|
||||
expect(dispatchedAction.taskId).toBe(taskId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { getDateTimeFromClockString } from '../../util/get-date-time-from-clock-
|
|||
import { remindOptionToMilliseconds } from '../tasks/util/remind-option-to-milliseconds';
|
||||
import { getNewestPossibleDueDate } from './store/get-newest-possible-due-date.util';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { isToday } from '../../util/is-today.util';
|
||||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
import {
|
||||
selectAllTaskRepeatCfgs,
|
||||
|
|
@ -80,6 +81,8 @@ export class TaskRepeatCfgService {
|
|||
projectId: string | null,
|
||||
taskRepeatCfg: Omit<TaskRepeatCfgCopy, 'id'>,
|
||||
): void {
|
||||
// Note: First occurrence calculation and lastTaskCreationDay update
|
||||
// is handled by the updateTaskAfterMakingItRepeatable$ effect (#5594)
|
||||
this._store$.dispatch(
|
||||
addTaskRepeatCfgToTask({
|
||||
taskRepeatCfg: {
|
||||
|
|
@ -279,7 +282,8 @@ export class TaskRepeatCfgService {
|
|||
dueWithTime: dateTime,
|
||||
remindAt: remindOptionToMilliseconds(dateTime, taskRepeatCfg.remindAt),
|
||||
isMoveToBacklog: false,
|
||||
isSkipAutoRemoveFromToday: true,
|
||||
// Only keep in today list if scheduled for today (#5594)
|
||||
isSkipAutoRemoveFromToday: isToday(dateTime),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,6 +300,13 @@ export class SyncWrapperService {
|
|||
// Silently ignore concurrent sync attempts (using proper error class)
|
||||
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (this._isPermissionError(error)) {
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.ERROR_PERMISSION,
|
||||
type: 'ERROR',
|
||||
config: { duration: 12000 },
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else {
|
||||
const errStr = getSyncErrorStr(error);
|
||||
this._snackService.open({
|
||||
|
|
@ -503,6 +510,11 @@ export class SyncWrapperService {
|
|||
return confirm(this._translateService.instant(str));
|
||||
}
|
||||
|
||||
private _isPermissionError(error: unknown): boolean {
|
||||
const errStr = String(error);
|
||||
return /EROFS|EACCES|EPERM|read-only file system|permission denied/i.test(errStr);
|
||||
}
|
||||
|
||||
private lastConflictDialog?: MatDialogRef<any, any>;
|
||||
|
||||
private _openConflictDialog$(
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ import { workContextReducer } from '../features/work-context/store/work-context.
|
|||
import { WorkContextEffects } from '../features/work-context/store/work-context.effects';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../util/is-android-web-view';
|
||||
import { AndroidEffects } from '../features/android/store/android.effects';
|
||||
import { AndroidFocusModeEffects } from '../features/android/store/android-focus-mode.effects';
|
||||
import { AndroidForegroundTrackingEffects } from '../features/android/store/android-foreground-tracking.effects';
|
||||
import { CaldavIssueEffects } from '../features/issue/providers/caldav/caldav-issue.effects';
|
||||
import { CalendarIntegrationEffects } from '../features/calendar-integration/store/calendar-integration.effects';
|
||||
import { ElectronEffects } from '../core/electron/electron.effects';
|
||||
|
|
@ -176,7 +178,11 @@ import {
|
|||
StoreModule.forFeature(ARCHIVE_OLD_FEATURE_NAME, archiveOldReducer),
|
||||
|
||||
// EFFECTS ONLY
|
||||
EffectsModule.forFeature([...(IS_ANDROID_WEB_VIEW ? [AndroidEffects] : [])]),
|
||||
EffectsModule.forFeature([
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [AndroidEffects, AndroidFocusModeEffects, AndroidForegroundTrackingEffects]
|
||||
: []),
|
||||
]),
|
||||
EffectsModule.forFeature([CaldavIssueEffects]),
|
||||
EffectsModule.forFeature([CalendarIntegrationEffects]),
|
||||
EffectsModule.forFeature([ElectronEffects]),
|
||||
|
|
|
|||
|
|
@ -219,9 +219,14 @@ const T = {
|
|||
ADD_TIME_MINUTE: 'F.FOCUS_MODE.ADD_TIME_MINUTE',
|
||||
B: {
|
||||
BREAK_RUNNING: 'F.FOCUS_MODE.B.BREAK_RUNNING',
|
||||
END_BREAK: 'F.FOCUS_MODE.B.END_BREAK',
|
||||
END_SESSION: 'F.FOCUS_MODE.B.END_SESSION',
|
||||
PAUSE: 'F.FOCUS_MODE.B.PAUSE',
|
||||
POMODORO_BREAK_RUNNING: 'F.FOCUS_MODE.B.POMODORO_BREAK_RUNNING',
|
||||
POMODORO_SESSION_RUNNING: 'F.FOCUS_MODE.B.POMODORO_SESSION_RUNNING',
|
||||
RESUME: 'F.FOCUS_MODE.B.RESUME',
|
||||
SESSION_RUNNING: 'F.FOCUS_MODE.B.SESSION_RUNNING',
|
||||
START: 'F.FOCUS_MODE.B.START',
|
||||
TO_FOCUS_OVERLAY: 'F.FOCUS_MODE.B.TO_FOCUS_OVERLAY',
|
||||
},
|
||||
BACK_TO_PLANNING: 'F.FOCUS_MODE.BACK_TO_PLANNING',
|
||||
|
|
@ -1215,6 +1220,7 @@ const T = {
|
|||
ERROR_CORS: 'F.SYNC.S.ERROR_CORS',
|
||||
ERROR_DATA_IS_CURRENTLY_WRITTEN: 'F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN',
|
||||
ERROR_FALLBACK_TO_BACKUP: 'F.SYNC.S.ERROR_FALLBACK_TO_BACKUP',
|
||||
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
|
||||
ERROR_INVALID_DATA: 'F.SYNC.S.ERROR_INVALID_DATA',
|
||||
ERROR_NO_REV: 'F.SYNC.S.ERROR_NO_REV',
|
||||
ERROR_UNABLE_TO_READ_REMOTE_DATA: 'F.SYNC.S.ERROR_UNABLE_TO_READ_REMOTE_DATA',
|
||||
|
|
@ -1889,7 +1895,11 @@ const T = {
|
|||
FOCUS_MODE: {
|
||||
HELP: 'GCF.FOCUS_MODE.HELP',
|
||||
L_ALWAYS_OPEN_FOCUS_MODE: 'GCF.FOCUS_MODE.L_ALWAYS_OPEN_FOCUS_MODE',
|
||||
L_SYNC_SESSION_WITH_TRACKING: 'GCF.FOCUS_MODE.L_SYNC_SESSION_WITH_TRACKING',
|
||||
L_IS_PLAY_TICK: 'GCF.FOCUS_MODE.L_IS_PLAY_TICK',
|
||||
L_PAUSE_TRACKING_DURING_BREAK: 'GCF.FOCUS_MODE.L_PAUSE_TRACKING_DURING_BREAK',
|
||||
L_SKIP_PREPARATION_SCREEN: 'GCF.FOCUS_MODE.L_SKIP_PREPARATION_SCREEN',
|
||||
L_START_IN_BACKGROUND: 'GCF.FOCUS_MODE.L_START_IN_BACKGROUND',
|
||||
TITLE: 'GCF.FOCUS_MODE.TITLE',
|
||||
},
|
||||
IDLE: {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,10 @@
|
|||
"name": "Date Range Reporter Plugin",
|
||||
"shortDescription": "Generates reports of completed tasks and tasks with work logs within a specified date range.",
|
||||
"url": "https://github.com/dougcooper/sp-reporter"
|
||||
},
|
||||
{
|
||||
"name": "Archived Tasks Viewer",
|
||||
"shortDescription": "Read-only viewer to browse archived tasks with grouping, filtering, subtasks preview, and theme toggle.",
|
||||
"url": "https://github.com/baiyina/Archived-Tasks-Viewer"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "المهمة المجدولة \"{{title}}\"",
|
||||
"REMINDER_DELETED": "تذكير محذوف للمهمة",
|
||||
"REMINDER_UPDATED": "تذكير محدث للمهمة \"{{title}}\"",
|
||||
"TASK_CREATED": "المهمة التي تم تكوينها \"{{title}}\""
|
||||
"TASK_CREATED": "المهمة التي تم تكوينها \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "حدد أو أنشئ المهمة",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -913,7 +913,7 @@
|
|||
"REMINDER_ADDED": "Naplánován úkol „{{title}}“",
|
||||
"REMINDER_DELETED": "Smazáno připomenutí k úkolu",
|
||||
"REMINDER_UPDATED": "Aktualizováno připomenutí k úkolu „{{title}}“",
|
||||
"TASK_CREATED": "Vytvořen úkol „{{title}}“"
|
||||
"TASK_CREATED": "Vytvořen úkol „{{taskTitle}}“"
|
||||
},
|
||||
"SELECT_OR_CREATE": "Vyberte nebo vytvořte úkol",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1373,7 +1373,7 @@
|
|||
"REMINDER_ADDED": "Geplante Aufgabe \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Erinnerung für Aufgabe gelöscht",
|
||||
"REMINDER_UPDATED": "Erinnerung für Aufgabe \"{{title}}\" aktualisiert",
|
||||
"TASK_CREATED": "Erstellte Aufgabe \"{{title}}\""
|
||||
"TASK_CREATED": "Erstellte Aufgabe \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Aufgabe auswählen oder erstellen",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -217,9 +217,14 @@
|
|||
"ADD_TIME_MINUTE": "Add 1 minute",
|
||||
"B": {
|
||||
"BREAK_RUNNING": "Break is running",
|
||||
"END_BREAK": "End break",
|
||||
"END_SESSION": "End session",
|
||||
"PAUSE": "Pause",
|
||||
"POMODORO_BREAK_RUNNING": "Break #{{cycleNr}} is running",
|
||||
"POMODORO_SESSION_RUNNING": "Pomodoro Session #{{cycleNr}} is running",
|
||||
"RESUME": "Resume",
|
||||
"SESSION_RUNNING": "Focus Session is running",
|
||||
"START": "Start",
|
||||
"TO_FOCUS_OVERLAY": "To Focus Overlay"
|
||||
},
|
||||
"BACK_TO_PLANNING": "Back to Planning",
|
||||
|
|
@ -1189,6 +1194,7 @@
|
|||
"DATA_REPAIRED": "Data automatically repaired ({{count}} issues fixed)",
|
||||
"ERROR_CORS": "WebDAV Sync Error: Network request failed.\n\nThis might be a CORS issue. Please ensure:\n• Your WebDAV server allows Cross-Origin requests\n• The server URL is correct and accessible\n• You have a working internet connection",
|
||||
"ERROR_DATA_IS_CURRENTLY_WRITTEN": "Remote Data is currently being written",
|
||||
"ERROR_PERMISSION": "File access denied. If using Flatpak/Snap, grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
|
||||
"ERROR_FALLBACK_TO_BACKUP": "Something went wrong while importing the data. Falling back to local backup.",
|
||||
"ERROR_INVALID_DATA": "Error while syncing. Invalid data",
|
||||
"ERROR_NO_REV": "No valid rev for remote file",
|
||||
|
|
@ -1850,7 +1856,11 @@
|
|||
"FOCUS_MODE": {
|
||||
"HELP": "Focus Mode opens up a distraction free screen to help you focus on your current task.",
|
||||
"L_ALWAYS_OPEN_FOCUS_MODE": "Always open focus mode, when tracking",
|
||||
"L_SKIP_PREPARATION_SCREEN": "Skip preparation screen (stretching etc.)",
|
||||
"L_SYNC_SESSION_WITH_TRACKING": "Sync focus session with time tracking",
|
||||
"L_IS_PLAY_TICK": "Play ticking sound during focus sessions",
|
||||
"L_PAUSE_TRACKING_DURING_BREAK": "Pause task tracking during breaks",
|
||||
"L_SKIP_PREPARATION_SCREEN": "Skip preparation screen (rocket animation)",
|
||||
"L_START_IN_BACKGROUND": "Start focus sessions with banner only (no overlay)",
|
||||
"TITLE": "Focus Mode"
|
||||
},
|
||||
"IDLE": {
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,41 @@
|
|||
"UPLOAD_ERROR": "Error de Subida Desconocido (¿Configuración correcta?): {{err}}"
|
||||
}
|
||||
},
|
||||
"SAFETY_BACKUP": {
|
||||
"BACKUP_NOT_FOUND": "No se encontró la copia de seguridad con ID {{backupId}}",
|
||||
"BTN_CLEAR_ALL": "Borrar todo",
|
||||
"BTN_CREATE_MANUAL": "Crear copia manual",
|
||||
"BTN_DELETE": "Eliminar",
|
||||
"BTN_REFRESH": "Actualizar",
|
||||
"BTN_RESTORE": "Restaurar",
|
||||
"CLEAR_FAILED": "Error al borrar las copias de seguridad",
|
||||
"CLEARED_SUCCESS": "Todas las copias de seguridad borradas con éxito",
|
||||
"CREATE_FAILED": "Error al crear la copia de seguridad",
|
||||
"CREATED_SUCCESS": "Copia de seguridad manual creada con éxito",
|
||||
"DELETE_FAILED": "Error al eliminar la copia de seguridad",
|
||||
"DELETED_SUCCESS": "Copia de seguridad eliminada con éxito",
|
||||
"DESCRIPTION": "Las copias de seguridad automáticas se crean antes de descargar datos remotos durante las operaciones de sincronización. Las copias se organizan en 4 espacios inteligentes: 2 más recientes, 1 primera de hoy y 1 primera de un día anterior a hoy.",
|
||||
"INVALID_ID_ERROR": "ID de copia de seguridad generado inválido",
|
||||
"LAST_CHANGE_PREFIX": "Último cambio:",
|
||||
"LOADING": "Cargando...",
|
||||
"NO_BACKUPS": "Aún no hay copias de seguridad disponibles. Las copias se crean automáticamente antes de las operaciones de sincronización.",
|
||||
"REASON_BEFORE_UPDATE": "Copia automática antes de sincronizar",
|
||||
"REASON_MANUAL": "Copia manual",
|
||||
"RESTORE_CONFIRM_MSG": "¿Estás seguro de que quieres restaurar la copia de seguridad de {{timestamp}}?\n\n¡Esto REEMPLAZARÁ COMPLETAMENTE todos tus datos actuales!\n\nRazón: {{reason}}\n\nHaz clic en Aceptar para proceder o Cancelar para abortar.",
|
||||
"RESTORE_CONFIRM_TITLE": "Restaurar copia de seguridad",
|
||||
"RESTORE_FAILED": "Error al restaurar la copia de seguridad: {{error}}",
|
||||
"RESTORED_SUCCESS": "Copia de seguridad restaurada con éxito",
|
||||
"SLOT_BEFORE_TODAY": "Primera copia anterior a hoy",
|
||||
"SLOT_RECENT": "Copia reciente",
|
||||
"SLOT_TODAY": "Primera copia de hoy",
|
||||
"TITLE": "Copias de seguridad de sincronización",
|
||||
"TOOLTIP_CLEAR_ALL": "Eliminar todas las copias de seguridad",
|
||||
"TOOLTIP_CREATE_MANUAL": "Crear una copia de seguridad manual de todos tus datos",
|
||||
"TOOLTIP_DELETE": "Eliminar esta copia de seguridad",
|
||||
"TOOLTIP_REFRESH": "Actualizar la lista de copias de seguridad",
|
||||
"TOOLTIP_RESTORE": "Restaurar esta copia (reemplazará todos los datos actuales)"
|
||||
}
|
||||
},
|
||||
"TAG": {
|
||||
"D_CREATE": {
|
||||
"CREATE": "Crear Etiqueta",
|
||||
|
|
@ -1164,7 +1199,7 @@
|
|||
"REMINDER_ADDED": "Tarea programada \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Recordatorio eliminado para tarea",
|
||||
"REMINDER_UPDATED": "Recordatorio actualizado para tarea \"{{title}}\"",
|
||||
"TASK_CREATED": "Tarea creada \"{{title}}\""
|
||||
"TASK_CREATED": "Tarea creada \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Seleccionar o crear tarea",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
@ -1609,6 +1644,23 @@
|
|||
"SNOOZE_TIME": "Tiempo de posponer cuando se pide hacer un descanso",
|
||||
"TITLE": "Recordatorio de Descanso"
|
||||
},
|
||||
"APP_FEATURES": {
|
||||
"HELP": "Habilita o deshabilita funciones específicas de la aplicación en la interfaz.",
|
||||
"TITLE": "Funciones de la aplicación",
|
||||
"TIME_TRACKING": "Seguimiento de tiempo (Cronómetro)",
|
||||
"FOCUS_MODE": "Modo concentración",
|
||||
"SCHEDULE": "Horario",
|
||||
"PLANNER": "Planificador",
|
||||
"BOARDS": "Tableros",
|
||||
"SCHEDULE_DAY_PANEL": "Panel de horario diario",
|
||||
"ISSUES_PANEL": "Panel de incidencias",
|
||||
"PROJECT_NOTES": "Notas del proyecto",
|
||||
"SYNC_BUTTON": "Botón de sincronización",
|
||||
"DONATE_PAGE": "Página de donaciones",
|
||||
"USER_PROFILES": "Perfiles de usuario (Experimental)",
|
||||
"USER_PROFILES_HINT": "Permite crear y alternar entre diferentes perfiles de usuario, cada uno con configuraciones, tareas y datos de sincronización independientes. El botón de gestión de perfiles aparecerá en la esquina superior derecha cuando esté habilitado. Nota: Deshabilitar esta función ocultará la interfaz pero conservará los datos de tu perfil (Función experimental, sin garantías. Asegúrate de tener una copia de seguridad)."
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"CAL_PROVIDERS": "Proveedores de calendario (experimental y opcional)",
|
||||
"CAL_PROVIDERS_ADD": "Añadir fuente ical",
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Scheduled task \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Deleted reminder for task",
|
||||
"REMINDER_UPDATED": "Updated reminder for task \"{{title}}\"",
|
||||
"TASK_CREATED": "Created task \"{{title}}\""
|
||||
"TASK_CREATED": "Created task \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "کاری انتخاب و یا ایجاد کنید",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1347,7 +1347,7 @@
|
|||
"REMINDER_ADDED": "Aikataulutettu <strong>{{title}}</strong> klo <strong>{{date}}</strong>",
|
||||
"REMINDER_DELETED": "Poistettiin muistutus tehtävälle",
|
||||
"REMINDER_UPDATED": "Päivitettiin muistutus tehtävälle \"{{title}}\"",
|
||||
"TASK_CREATED": "Luotiin tehtävä \"{{title}}\""
|
||||
"TASK_CREATED": "Luotiin tehtävä \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Valitse tai luo tehtävä",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Tâche planifiée \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Rappel supprimé pour la tâche",
|
||||
"REMINDER_UPDATED": "Rappel mis à jour pour la tâche \"{{title}}\"",
|
||||
"TASK_CREATED": "Tâche créée \"{{title}}\""
|
||||
"TASK_CREATED": "Tâche créée \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Sélectionner ou créer une tâche",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1182,7 +1182,7 @@
|
|||
"REMINDER_ADDED": "Terminirani zadatak „{{title}}”",
|
||||
"REMINDER_DELETED": "Izbrisan je podjsetnik za zadatak",
|
||||
"REMINDER_UPDATED": "Aktualiziran je podjsetnik za zadatak „{{title}}”",
|
||||
"TASK_CREATED": "Stvoren je zadatak „{{title}}”"
|
||||
"TASK_CREATED": "Stvoren je zadatak „{{taskTitle}}”"
|
||||
},
|
||||
"SELECT_OR_CREATE": "Odaberi ili stvori zadatak",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Tugas \"{{title}}\" terjadwal",
|
||||
"REMINDER_DELETED": "Pengingat yang dihapus untuk tugas",
|
||||
"REMINDER_UPDATED": "Pengingat yang diperbarui untuk tugas \"{{title}}\"",
|
||||
"TASK_CREATED": "tugas yang dibuat \"{{title}}\""
|
||||
"TASK_CREATED": "tugas yang dibuat \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Pilih atau buat tugas",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,7 @@
|
|||
"REMINDER_ADDED": "Pianificata attività <strong>{{title}}</strong> alle <strong>{{date}}</strong>{{extra}}",
|
||||
"REMINDER_DELETED": "Cancellato promemoria per attività",
|
||||
"REMINDER_UPDATED": "Aggiornato promemoria per attività \"{{title}}\"",
|
||||
"TASK_CREATED": "Creata attività \"{{title}}\""
|
||||
"TASK_CREATED": "Creata attività \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Seleziona o crea attività",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "スケジュール済みタスク \"{{title}}\"",
|
||||
"REMINDER_DELETED": "タスクの通知を削除しました",
|
||||
"REMINDER_UPDATED": "タスク \"{{title}}\"の通知を更新しました",
|
||||
"TASK_CREATED": "タスク「{{title}}」を作成しました"
|
||||
"TASK_CREATED": "タスク「{{taskTitle}}」を作成しました"
|
||||
},
|
||||
"SELECT_OR_CREATE": "タスクを選択または作成する",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1203,7 +1203,7 @@
|
|||
"REMINDER_ADDED": "예약 된 작업 \"{{title}}\"",
|
||||
"REMINDER_DELETED": "할 일 목록 삭제",
|
||||
"REMINDER_UPDATED": "\"{{title}}\"작업에 대한 알림이 업데이트되었습니다.",
|
||||
"TASK_CREATED": "\"{{title}}\"작업 생성"
|
||||
"TASK_CREATED": "\"{{taskTitle}}\"작업 생성"
|
||||
},
|
||||
"SELECT_OR_CREATE": "작업 선택 또는 생성",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Planlagt oppgave \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Slettet påminnelse for oppgaven",
|
||||
"REMINDER_UPDATED": "Oppdatert påminnelse for oppgaven \"{{title}}\"",
|
||||
"TASK_CREATED": "Opprettet oppgaven \"{{title}}\""
|
||||
"TASK_CREATED": "Opprettet oppgaven \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Velg eller opprett oppgave",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1374,7 +1374,7 @@
|
|||
"REMINDER_ADDED": "Geplande taak \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Herinnering voor taak verwijderd",
|
||||
"REMINDER_UPDATED": "Herinnering voor taak \"{{title}}\" bijgewerkt",
|
||||
"TASK_CREATED": "Taak \"{{title}}\" gemaakt"
|
||||
"TASK_CREATED": "Taak \"{{taskTitle}}\" gemaakt"
|
||||
},
|
||||
"SELECT_OR_CREATE": "Selecteer of maak een taak",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Tarefa agendada \" {{title}}\"",
|
||||
"REMINDER_DELETED": "Lembrete excluído para tarefa",
|
||||
"REMINDER_UPDATED": "Lembrete atualizado para a tarefa \" {{title}}\"",
|
||||
"TASK_CREATED": "Tarefa criada \" {{title}}\""
|
||||
"TASK_CREATED": "Tarefa criada \" {{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Selecione ou crie uma tarefa",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
|
|
@ -1426,7 +1426,7 @@
|
|||
"REMINDER_ADDED": "Tarefa agendada \"{{title}}\"",
|
||||
"REMINDER_DELETED": "Lembrete excluído da tarefa",
|
||||
"REMINDER_UPDATED": "Lembrete atualizado para a tarefa \"{{title}}\"",
|
||||
"TASK_CREATED": "Tarefa criada \"{{title}}\""
|
||||
"TASK_CREATED": "Tarefa criada \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Selecione ou crie tarefa",
|
||||
"SUMMARY_TABLE": {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue