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:
Johannes Millan 2025-12-19 21:38:29 +01:00
commit 79853eee3f
109 changed files with 7288 additions and 805 deletions

View file

@ -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: |

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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>

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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
)
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}
}

View file

@ -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) { } }

View file

@ -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)

View file

@ -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

View file

@ -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
View file

@ -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/*"

View file

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

View file

@ -31,6 +31,7 @@ export const BANNER_SORT_PRIO_MAP = {
export interface BannerAction {
label: string;
fn: () => void;
icon?: string;
}
export interface Banner {

View file

@ -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>

View file

@ -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,

View file

@ -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'],

View file

@ -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);

View 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;
}
}

View file

@ -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);
}
}
}

View file

@ -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) {

View file

@ -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');
}

View file

@ -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,

View file

@ -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,
},
},
],
};

View file

@ -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> {

View file

@ -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">

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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);
});
});

View file

@ -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 {

View file

@ -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,

View file

@ -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)

View file

@ -203,6 +203,7 @@ describe('FocusModeModel', () => {
mode: FocusModeMode.Pomodoro,
currentCycle: 0,
lastCompletedDuration: 0,
pausedTaskId: null,
};
expect(state.timer).toEqual(timer);

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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,

View file

@ -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!

View file

@ -27,6 +27,7 @@ describe('FocusModeSelectors', () => {
mode: FocusModeMode.Pomodoro,
currentCycle: 1,
lastCompletedDuration: 0,
pausedTaskId: null,
...overrides,
});

View file

@ -84,3 +84,9 @@ export const selectIsSessionCompleted = createSelector(
selectCurrentScreen,
(currentScreen) => currentScreen === 'SessionDone',
);
// Paused task during breaks
export const selectPausedTaskId = createSelector(
selectFocusModeState,
(state) => state.pausedTaskId,
);

View file

@ -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>

View file

@ -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>

View file

@ -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',

View file

@ -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: [],

View file

@ -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>;
}

View file

@ -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<

View file

@ -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;

View file

@ -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,

View file

@ -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();
}));
});
});

View 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,
};
}
}

View file

@ -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');
}
}

View file

@ -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,
};

View file

@ -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);
});
});

View file

@ -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 };
};

View 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;
}>;

View file

@ -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>

View file

@ -0,0 +1,12 @@
:host {
display: block;
}
.sub-section-heading {
margin-top: 16px;
margin-bottom: 8px;
}
mat-checkbox {
display: block;
}

View file

@ -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;
}
}

View 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';

View 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;
}

View file

@ -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',

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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
? '"' +

View file

@ -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);
});
});
});

View file

@ -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;
}
};

View file

@ -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', {

View file

@ -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 },
),
);
/**

View file

@ -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);
});
});
});

View file

@ -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),
}),
);
}

View file

@ -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$(

View file

@ -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]),

View file

@ -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: {

View file

@ -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"
}
]

View file

@ -1426,7 +1426,7 @@
"REMINDER_ADDED": "المهمة المجدولة \"{{title}}\"",
"REMINDER_DELETED": "تذكير محذوف للمهمة",
"REMINDER_UPDATED": "تذكير محدث للمهمة \"{{title}}\"",
"TASK_CREATED": "المهمة التي تم تكوينها \"{{title}}\""
"TASK_CREATED": "المهمة التي تم تكوينها \"{{taskTitle}}\""
},
"SELECT_OR_CREATE": "حدد أو أنشئ المهمة",
"SUMMARY_TABLE": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -1426,7 +1426,7 @@
"REMINDER_ADDED": "スケジュール済みタスク \"{{title}}\"",
"REMINDER_DELETED": "タスクの通知を削除しました",
"REMINDER_UPDATED": "タスク \"{{title}}\"の通知を更新しました",
"TASK_CREATED": "タスク「{{title}}」を作成しました"
"TASK_CREATED": "タスク「{{taskTitle}}」を作成しました"
},
"SELECT_OR_CREATE": "タスクを選択または作成する",
"SUMMARY_TABLE": {

View file

@ -1203,7 +1203,7 @@
"REMINDER_ADDED": "예약 된 작업 \"{{title}}\"",
"REMINDER_DELETED": "할 일 목록 삭제",
"REMINDER_UPDATED": "\"{{title}}\"작업에 대한 알림이 업데이트되었습니다.",
"TASK_CREATED": "\"{{title}}\"작업 생성"
"TASK_CREATED": "\"{{taskTitle}}\"작업 생성"
},
"SELECT_OR_CREATE": "작업 선택 또는 생성",
"SUMMARY_TABLE": {

View file

@ -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": {

View file

@ -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

View file

@ -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": {

View file

@ -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