Merge branch 'master' into feat/operation-logs

* master: (37 commits)
  16.8.0
  feat(i18n): add new translations
  fix: address code review issues from today's changes
  fix: address code review issues from today's changes
  fix(data-repair): change quickSetting to CUSTOM when startDate is missing
  fix(test): fix fetch spy setup in audio tests
  fix(android): sync time tracking from notification correctly on resume
  fix(database): prevent repeated error dialogs when disk is full
  fix(reminder): prevent dismissed reminders from reappearing
  fix(task-repeat): prevent race condition when saving repeat config
  fix(android): add error handling for native service calls
  fix(reminder): cancel native Android reminders immediately on task deletion
  fix(error-handler): use getErrorTxt to prevent [object Object] in error titles
  fix(planner): use task startDate for weekly repeat weekday calculation
  fix(focus-mode): use independent 1s timer for Pomodoro countdown
  feat(focus-mode): add Skip Break button to banner during active breaks
  feat(notes): add auto-save to fullscreen markdown editor
  fix(reflection-note): prevent trailing spaces from being deleted while typing
  fix(sync): add error handling for JSON parse failures in sync data
  fix(error-handling): prevent [object Object] from appearing in error messages
  ...

# Conflicts:
#	src/app/core/persistence/database.service.ts
#	src/app/features/android/store/android-focus-mode.effects.ts
#	src/app/features/android/store/android-foreground-tracking.effects.ts
#	src/app/features/reminder/reminder.service.spec.ts
#	src/app/features/reminder/reminder.service.ts
#	src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.ts
#	src/app/features/tasks/store/task-reminder.effects.spec.ts
#	src/app/features/tasks/store/task-reminder.effects.ts
#	src/app/features/work-context/store/work-context.effects.spec.ts
#	src/app/features/work-context/store/work-context.effects.ts
#	src/app/t.const.ts
#	src/assets/i18n/en.json
This commit is contained in:
Johannes Millan 2026-01-02 19:56:30 +01:00
commit e6ea0d74f0
105 changed files with 7302 additions and 734 deletions

View file

@ -38,7 +38,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -74,7 +74,7 @@ jobs:
# APK is now signed automatically by Gradle using signingConfig
- name: 'Upload APK files'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: sup-android-release
path: android/app/build/outputs/apk/**/*.apk

View file

@ -48,7 +48,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -82,7 +82,7 @@ jobs:
github_token: ${{ secrets.github_token }}
- name: 'Upload Artifact'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: WinStoreRelease
path: .tmp/app-builds/*.appx

View file

@ -37,7 +37,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}

View file

@ -36,7 +36,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}

View file

@ -31,7 +31,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -73,7 +73,7 @@ jobs:
- name: 'Upload E2E results on failure'
if: ${{ failure() }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: e2eResults
path: .tmp/e2e-test-results/**/*.*
@ -137,7 +137,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -230,7 +230,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}

View file

@ -29,7 +29,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -64,7 +64,7 @@ jobs:
- name: 'Upload E2E results on failure'
if: ${{ failure() }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: e2eResults
path: .tmp/e2e-test-results/**/*.*

View file

@ -32,7 +32,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -58,7 +58,7 @@ jobs:
release: false
- name: 'Upload Artifact'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: WinBuildStuff
path: .tmp/app-builds/*.exe
@ -92,7 +92,7 @@ jobs:
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -158,7 +158,7 @@ jobs:
# if: always()
# run: ls -la && cat notarization-error.log
- name: 'Upload Artifact'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: dmg
path: .tmp/app-builds/*.dmg

View file

@ -26,7 +26,7 @@ jobs:
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: npm-cache
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
@ -118,7 +118,7 @@ jobs:
fi
- name: Upload DMG artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: mac-dmg-build
path: .tmp/app-builds/*.dmg

View file

@ -1,3 +1,62 @@
# [16.8.0](https://github.com/johannesjo/super-productivity/compare/v16.7.3...v16.8.0) (2026-01-02)
### Bug Fixes
- address code review issues from today's changes ([795ec42](https://github.com/johannesjo/super-productivity/commit/795ec42f644139d03d10c660cafb2db14f845d32))
- address code review issues from today's changes ([6d57aaf](https://github.com/johannesjo/super-productivity/commit/6d57aaff13b423c2e78b7f2bca60e3dfe460b402))
- **android:** add error handling for native service calls ([a14c950](https://github.com/johannesjo/super-productivity/commit/a14c95093de3ad01e86c0e1879be866787d81874)), closes [#5819](https://github.com/johannesjo/super-productivity/issues/5819)
- **android:** resolve race condition and improve widget reliability ([00fdb29](https://github.com/johannesjo/super-productivity/commit/00fdb29db96c50515c755370afcfba9272316f85))
- **android:** skip reminder dialog on Android to fix snooze button ([ed2dbfb](https://github.com/johannesjo/super-productivity/commit/ed2dbfbc27692afe09fed1b94f05627921bfb755)), closes [#5775](https://github.com/johannesjo/super-productivity/issues/5775)
- **android:** sync notification timer when time spent is manually changed ([2c910f6](https://github.com/johannesjo/super-productivity/commit/2c910f6753cf444c90c5463d6ffc75f13803f2e8)), closes [#5772](https://github.com/johannesjo/super-productivity/issues/5772)
- **android:** sync time tracking from notification correctly on resume ([55d4fd1](https://github.com/johannesjo/super-productivity/commit/55d4fd1520ad958b2b89100a4d0e504100cdcb29)), closes [#5840](https://github.com/johannesjo/super-productivity/issues/5840) [#5842](https://github.com/johannesjo/super-productivity/issues/5842)
- **audio:** prevent app freeze during focus mode ticking sound ([3a5cddd](https://github.com/johannesjo/super-productivity/commit/3a5cddd8e86b7aee5451262ba5cc19cb05c70132)), closes [#5798](https://github.com/johannesjo/super-productivity/issues/5798)
- **backup:** correct logical operator for platform check on first launch ([80acc92](https://github.com/johannesjo/super-productivity/commit/80acc9253a9e6501d563e6aa9bfef269e26ae79d)), closes [#5796](https://github.com/johannesjo/super-productivity/issues/5796)
- **build:** ensure consistent Windows EXE metadata for installer and portable ([fff8596](https://github.com/johannesjo/super-productivity/commit/fff8596d50aad3eb12f54e5a6ad0c6ea99243c7d)), closes [#4625](https://github.com/johannesjo/super-productivity/issues/4625)
- **build:** remove deprecated win32metadata from electron-builder config ([50de4c1](https://github.com/johannesjo/super-productivity/commit/50de4c1fd71990b765a22297dc7a889e967c2663))
- **data-repair:** change quickSetting to CUSTOM when startDate is missing ([cb27b53](https://github.com/johannesjo/super-productivity/commit/cb27b53f85b547437b1f0e12948ec19406c0d06a)), closes [#5802](https://github.com/johannesjo/super-productivity/issues/5802)
- **database:** prevent repeated error dialogs when disk is full ([9f6442b](https://github.com/johannesjo/super-productivity/commit/9f6442bf6b9ff75efc06eb833f7d19b646cb4a8d)), closes [#5845](https://github.com/johannesjo/super-productivity/issues/5845)
- **electron:** delay window focus after notification to prevent accidental input ([29be592](https://github.com/johannesjo/super-productivity/commit/29be5929ead9ef8916f4d31475fd6df28c999602)), closes [#5762](https://github.com/johannesjo/super-productivity/issues/5762)
- **electron:** reduce idle detection log verbosity ([62d449a](https://github.com/johannesjo/super-productivity/commit/62d449a82fda81ed058d7786187ff41c8ea9ec79)), closes [#5794](https://github.com/johannesjo/super-productivity/issues/5794)
- **error-handler:** use getErrorTxt to prevent [object Object] in error titles ([b2d0319](https://github.com/johannesjo/super-productivity/commit/b2d0319ea236eb7166cc2bce93732718e2f7dc28)), closes [#5822](https://github.com/johannesjo/super-productivity/issues/5822)
- **error-handling:** prevent [object Object] from appearing in error messages ([e571d6e](https://github.com/johannesjo/super-productivity/commit/e571d6e3bcbe4db998678cd9c5ef039a2a5022f7)), closes [#5790](https://github.com/johannesjo/super-productivity/issues/5790)
- **focus-mode:** address critical focus mode and Android notification issues ([d114358](https://github.com/johannesjo/super-productivity/commit/d11435808eaff33b7d3fb446ffd62ba60dc9facb))
- **focus-mode:** respect isFocusModeEnabled setting in App Features ([47a9897](https://github.com/johannesjo/super-productivity/commit/47a989710da3c59db23b223d4f0687a8a7a7a121)), closes [#5776](https://github.com/johannesjo/super-productivity/issues/5776)
- **focus-mode:** use independent 1s timer for Pomodoro countdown ([ce70df4](https://github.com/johannesjo/super-productivity/commit/ce70df4a665b3ce2b4b9a04ba85c19526dbb0362)), closes [#5813](https://github.com/johannesjo/super-productivity/issues/5813)
- **ical:** prevent race condition in lazy loader ([6d82c19](https://github.com/johannesjo/super-productivity/commit/6d82c1982d06d68b9262bda6f00f220ca1e0a519))
- **localization:** respect Sunday as first day of week preference ([635083e](https://github.com/johannesjo/super-productivity/commit/635083ef76cc34188755cf6604b17ff7b732ee28)), closes [#5862](https://github.com/johannesjo/super-productivity/issues/5862)
- **offline-banner:** prevent repeated offline banner on Linux/Electron ([871ee35](https://github.com/johannesjo/super-productivity/commit/871ee354ca148c101e11021e16257f64ee059066)), closes [#5738](https://github.com/johannesjo/super-productivity/issues/5738)
- **planner:** schedule next month uses first day of month ([ece1644](https://github.com/johannesjo/super-productivity/commit/ece1644d4e366fd6ff05300b04f4c54325f13ee0))
- **planner:** use task startDate for weekly repeat weekday calculation ([4198a6b](https://github.com/johannesjo/super-productivity/commit/4198a6bbc924f5d2ccf88afef549ed2da7a68cf7)), closes [#5806](https://github.com/johannesjo/super-productivity/issues/5806)
- **plugins:** ensure setCounter creates valid SimpleCounter records ([1529920](https://github.com/johannesjo/super-productivity/commit/1529920c370162744e3a5911a1000b790d52713d)), closes [#5812](https://github.com/johannesjo/super-productivity/issues/5812)
- **reflection-note:** prevent trailing spaces from being deleted while typing ([4c27881](https://github.com/johannesjo/super-productivity/commit/4c278812d8b021ac853732bdda59b9eabbfdb526)), closes [#5800](https://github.com/johannesjo/super-productivity/issues/5800)
- **reminder:** cancel native Android reminders immediately on task deletion ([93e957e](https://github.com/johannesjo/super-productivity/commit/93e957edc1f218083867fdf470cfb63e02691573)), closes [#5831](https://github.com/johannesjo/super-productivity/issues/5831)
- **reminder:** prevent dismissed reminders from reappearing ([9c3834b](https://github.com/johannesjo/super-productivity/commit/9c3834b7ee6ca15f4eea4247dc4f67d6ef4043dc)), closes [#5826](https://github.com/johannesjo/super-productivity/issues/5826)
- **security:** address CodeQL security alerts ([c4023b4](https://github.com/johannesjo/super-productivity/commit/c4023b4f457b8f0738589a53dd9845e3e26cfd48)), closes [#50-52](https://github.com/johannesjo/super-productivity/issues/50-52) [#40](https://github.com/johannesjo/super-productivity/issues/40) [#39](https://github.com/johannesjo/super-productivity/issues/39) [#37-38](https://github.com/johannesjo/super-productivity/issues/37-38)
- **security:** update Angular packages to address CVEs ([efb164c](https://github.com/johannesjo/super-productivity/commit/efb164c1fed35a8694db022eefa6e2d1dcc22131))
- **security:** update axios and brace-expansion dependencies ([b2332c4](https://github.com/johannesjo/super-productivity/commit/b2332c4fb67e6c08c6b13235f2b84192da11f29a))
- **sync:** add error handling for JSON parse failures in sync data ([7496b2d](https://github.com/johannesjo/super-productivity/commit/7496b2dd604824886a80177def74877607f50f03)), closes [#5771](https://github.com/johannesjo/super-productivity/issues/5771)
- **sync:** redirect to TODAY when active project removed during sync ([8794194](https://github.com/johannesjo/super-productivity/commit/8794194e998c4f9aef1fb3e14266646506841245)), closes [#5859](https://github.com/johannesjo/super-productivity/issues/5859)
- **sync:** show context-aware permission error for Flatpak/Snap ([18a0e78](https://github.com/johannesjo/super-productivity/commit/18a0e78f129b65fa18d0b6c052fca1ac29bb6c44)), closes [#4078](https://github.com/johannesjo/super-productivity/issues/4078)
- **task-repeat:** prevent race condition when saving repeat config ([dc12403](https://github.com/johannesjo/super-productivity/commit/dc12403747fbf2012dfde08e33ef1c61574fabac)), closes [#5828](https://github.com/johannesjo/super-productivity/issues/5828)
- **test:** fix fetch spy setup in audio tests ([1ba7cf8](https://github.com/johannesjo/super-productivity/commit/1ba7cf89608177ef98fbbf3ceabdca8dbe9a9057))
- **test:** use dynamic date in year boundary test to avoid today collision ([6dea85c](https://github.com/johannesjo/super-productivity/commit/6dea85c083391797c9b20ae8aae4ae94f5afbd28))
### Features
- **android:** add quick add widget ([4a89c05](https://github.com/johannesjo/super-productivity/commit/4a89c05d321ea7da429dd328bd1e51a921cf4c73))
- **focus-mode:** add manual break start option for Pomodoro ([c74cebd](https://github.com/johannesjo/super-productivity/commit/c74cebddcba77ecb7cd79059063d48e09f545660)), closes [#5736](https://github.com/johannesjo/super-productivity/issues/5736)
- **focus-mode:** add Skip Break button to banner during active breaks ([b9bf655](https://github.com/johannesjo/super-productivity/commit/b9bf655e0007987041046b21cb7ad946913065e8)), closes [#5818](https://github.com/johannesjo/super-productivity/issues/5818)
- **i18n:** add new translations ([ca22c0d](https://github.com/johannesjo/super-productivity/commit/ca22c0d8f6010d7086f920749e43fbb9a49cfd43))
- **i18n:** update Turkish language ([66121a1](https://github.com/johannesjo/super-productivity/commit/66121a113106ca39e8fd86b6b3c37f2dbf4afb03))
- **notes:** add auto-save to fullscreen markdown editor ([09d0131](https://github.com/johannesjo/super-productivity/commit/09d0131760d2fb5488b1324472680a67316fd714)), closes [#5804](https://github.com/johannesjo/super-productivity/issues/5804)
- **sync:** add WebDAV Test Connection button and improve UX ([660adf7](https://github.com/johannesjo/super-productivity/commit/660adf76bc05090a3e3e1981d24bc0be2a5b9edb)), closes [#5508](https://github.com/johannesjo/super-productivity/issues/5508) [#5508](https://github.com/johannesjo/super-productivity/issues/5508)
- **task:** add Go to Task button for all newly created tasks ([b37065a](https://github.com/johannesjo/super-productivity/commit/b37065a84c899c73c7e14c2d97d019f37f137585)), closes [#5759](https://github.com/johannesjo/super-productivity/issues/5759)
### Performance Improvements
- **android:** prewarm WebView during idle time to speed up startup ([25edb4f](https://github.com/johannesjo/super-productivity/commit/25edb4ff1e0fbded63a4ce2b9de5159259974bb6))
- lazy load ical.js to reduce initial bundle size ([1cb1e1c](https://github.com/johannesjo/super-productivity/commit/1cb1e1c7424129eda9aeee8b3b660febd58de63f))
## [16.7.3](https://github.com/johannesjo/super-productivity/compare/v16.7.2...v16.7.3) (2025-12-20)
### Bug Fixes

View file

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

View file

@ -2,6 +2,7 @@ package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.content.Intent
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
@ -21,6 +22,13 @@ class JavaScriptInterface(
private val webView: WebView,
) {
private inline fun safeCall(errorMsg: String, block: () -> Unit) {
try {
block()
} catch (e: Exception) {
Log.e(TAG, errorMsg, e)
}
}
@Suppress("unused")
@JavascriptInterface
@ -80,32 +88,38 @@ 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)
safeCall("Failed to start tracking service") {
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)
}
ContextCompat.startForegroundService(activity, intent)
}
@Suppress("unused")
@JavascriptInterface
fun stopTrackingService() {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_STOP
safeCall("Failed to stop tracking service") {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_STOP
}
activity.startService(intent)
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun updateTrackingService(timeSpentMs: Long) {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_UPDATE
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
safeCall("Failed to update tracking service") {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_UPDATE
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
}
activity.startService(intent)
}
activity.startService(intent)
}
@Suppress("unused")
@ -131,39 +145,45 @@ class JavaScriptInterface(
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)
safeCall("Failed to start focus mode service") {
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)
}
ContextCompat.startForegroundService(activity, intent)
}
@Suppress("unused")
@JavascriptInterface
fun stopFocusModeService() {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_STOP
safeCall("Failed to stop focus mode service") {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_STOP
}
activity.startService(intent)
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun updateFocusModeService(title: String, remainingMs: Long, isPaused: Boolean, isBreak: Boolean, taskTitle: String?) {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_UPDATE
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
safeCall("Failed to update focus mode service") {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_UPDATE
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
}
activity.startService(intent)
}
activity.startService(intent)
}
@Suppress("unused")
@ -176,21 +196,25 @@ class JavaScriptInterface(
reminderType: String,
triggerAtMs: Long
) {
ReminderNotificationHelper.scheduleReminder(
activity,
notificationId,
reminderId,
relatedId,
title,
reminderType,
triggerAtMs
)
safeCall("Failed to schedule native reminder") {
ReminderNotificationHelper.scheduleReminder(
activity,
notificationId,
reminderId,
relatedId,
title,
reminderType,
triggerAtMs
)
}
}
@Suppress("unused")
@JavascriptInterface
fun cancelNativeReminder(notificationId: Int) {
ReminderNotificationHelper.cancelReminder(activity, notificationId)
safeCall("Failed to cancel native reminder") {
ReminderNotificationHelper.cancelReminder(activity, notificationId)
}
}
/**
@ -208,6 +232,7 @@ class JavaScriptInterface(
}
companion object {
private const val TAG = "JavaScriptInterface"
// TODO rename to WINDOW_PROPERTY
const val FN_PREFIX: String = "window.$WINDOW_INTERFACE_PROPERTY."
}

View file

@ -1,6 +1,6 @@
package com.superproductivity.superproductivity.widget
import android.app.Activity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
@ -14,7 +14,7 @@ import com.superproductivity.superproductivity.R
* A minimal floating dialog activity for quick task entry from the widget.
* Uses a dialog theme to appear as a floating window.
*/
class QuickAddActivity : Activity() {
class QuickAddActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View file

@ -22,6 +22,7 @@ object WidgetTaskQueue {
* Add a task to the queue.
* @return The generated task ID
*/
@Synchronized
fun addTask(context: Context, title: String): String {
val taskId = UUID.randomUUID().toString()
val task = JSONObject().apply {
@ -46,7 +47,7 @@ object WidgetTaskQueue {
tasks.put(task)
queue.put("tasks", tasks)
prefs.edit().putString(KEY_TASK_QUEUE, queue.toString()).commit()
prefs.edit().putString(KEY_TASK_QUEUE, queue.toString()).apply()
return taskId
}
@ -54,6 +55,7 @@ object WidgetTaskQueue {
* Get all queued tasks and clear the queue atomically.
* @return JSON string of queued tasks, or null if empty
*/
@Synchronized
fun getAndClearQueue(context: Context): String? {
val prefs = getPrefs(context)
val queueJson = prefs.getString(KEY_TASK_QUEUE, null)

View file

@ -0,0 +1,57 @@
### Bug Fixes
* address code review issues from today's changes
* address code review issues from today's changes
* **android:** add error handling for native service calls (a14c950), closes #5819
* **android:** resolve race condition and improve widget reliability
* **android:** skip reminder dialog on Android to fix snooze button (ed2dbfb), closes #5775
* **android:** sync notification timer when time spent is manually changed (2c910f6), closes #5772
* **android:** sync time tracking from notification correctly on resume (55d4fd1), closes #5840 #5842
* **audio:** prevent app freeze during focus mode ticking sound (3a5cddd), closes #5798
* **backup:** correct logical operator for platform check on first launch (80acc92), closes #5796
* **build:** ensure consistent Windows EXE metadata for installer and portable (fff8596), closes #4625
* **build:** remove deprecated win32metadata from electron-builder config
* **data-repair:** change quickSetting to CUSTOM when startDate is missing (cb27b53), closes #5802
* **database:** prevent repeated error dialogs when disk is full (9f6442b), closes #5845
* **electron:** delay window focus after notification to prevent accidental input (29be592), closes #5762
* **electron:** reduce idle detection log verbosity (62d449a), closes #5794
* **error-handler:** use getErrorTxt to prevent [object Object] in error titles (b2d0319), closes #5822
* **error-handling:** prevent [object Object] from appearing in error messages (e571d6e), closes #5790
* **focus-mode:** address critical focus mode and Android notification issues
* **focus-mode:** respect isFocusModeEnabled setting in App Features (47a9897), closes #5776
* **focus-mode:** use independent 1s timer for Pomodoro countdown (ce70df4), closes #5813
* **ical:** prevent race condition in lazy loader
* **localization:** respect Sunday as first day of week preference (635083e), closes #5862
* **offline-banner:** prevent repeated offline banner on Linux/Electron (871ee35), closes #5738
* **planner:** schedule next month uses first day of month
* **planner:** use task startDate for weekly repeat weekday calculation (4198a6b), closes #5806
* **plugins:** ensure setCounter creates valid SimpleCounter records (1529920), closes #5812
* **reflection-note:** prevent trailing spaces from being deleted while typing (4c27881), closes #5800
* **reminder:** cancel native Android reminders immediately on task deletion (93e957e), closes #5831
* **reminder:** prevent dismissed reminders from reappearing (9c3834b), closes #5826
* **security:** address CodeQL security alerts (c4023b4), closes #50-52 #40 #39 #37-38
* **security:** update Angular packages to address CVEs
* **security:** update axios and brace-expansion dependencies
* **sync:** add error handling for JSON parse failures in sync data (7496b2d), closes #5771
* **sync:** redirect to TODAY when active project removed during sync (8794194), closes #5859
* **sync:** show context-aware permission error for Flatpak/Snap (18a0e78), closes #4078
* **task-repeat:** prevent race condition when saving repeat config (dc12403), closes #5828
* **test:** fix fetch spy setup in audio tests
* **test:** use dynamic date in year boundary test to avoid today collision
### Features
* **android:** add quick add widget
* **focus-mode:** add manual break start option for Pomodoro (c74cebd), closes #5736
* **focus-mode:** add Skip Break button to banner during active breaks (b9bf655), closes #5818
* **i18n:** add new translations
* **i18n:** update Turkish language
* **notes:** add auto-save to fullscreen markdown editor (09d0131), closes #5804
* **sync:** add WebDAV Test Connection button and improve UX (660adf7), closes #5508 #5508
* **task:** add Go to Task button for all newly created tasks (b37065a), closes #5759
### Performance Improvements
* **android:** prewarm WebView during idle time to speed up startup
* lazy load ical.js to reduce initial bundle size

View file

@ -0,0 +1,303 @@
/**
* E2E test for GitHub issue #5117
* https://github.com/johannesjo/super-productivity/issues/5117
*
* Bug: Flowtime focus mode stops counting up at the value set in Countdown mode
* (e.g., 25 minutes or 5 minutes) instead of counting indefinitely.
*
* User reproduction steps:
* 1. Open focus mode
* 2. Switch to Countdown tab
* 3. Set countdown to 5 minutes (but do not start the session)
* 4. Switch to Flowtime tab
* 5. Start focus session counting up from 0
*
* Expected: Timer counts indefinitely
* Actual: Timer stops at 5 minutes (the Countdown value)
*/
import { test, expect } from '../../fixtures/test.fixture';
import { WorkViewPage } from '../../pages/work-view.page';
// Helper to parse time string "MM:SS" to seconds
const parseTime = (timeStr: string | null): number => {
if (!timeStr) return 0;
const trimmed = timeStr.trim();
const parts = trimmed.split(':');
if (parts.length !== 2) return 0;
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
const minutesInSeconds = minutes * 60;
return minutesInSeconds + seconds;
};
test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
test('Flowtime timer should count past the previously set Countdown duration', async ({
page,
testPrefix,
}) => {
const workViewPage = new WorkViewPage(page, testPrefix);
// Locators
const focusModeOverlay = page.locator('focus-mode-overlay');
const mainFocusButton = page
.getByRole('button')
.filter({ hasText: 'center_focus_strong' });
// Mode selector buttons - using the segmented button group
const flowtimeButton = page.locator('segmented-button-group button', {
hasText: 'Flowtime',
});
const countdownButton = page.locator('segmented-button-group button', {
hasText: 'Countdown',
});
// Duration slider (visible only in Countdown mode during preparation)
const durationSlider = page.locator('input-duration-slider');
// Play button to start the session (button element, not the icon inside)
const playButton = page.locator('focus-mode-main button.play-button');
// Clock display showing elapsed time
const clockTime = page.locator('focus-mode-main .clock-time');
// Wait for task list and add a task (required for focus mode)
await workViewPage.waitForTaskList();
await workViewPage.addTask('FlowTimeTestTask');
// Step 1: Open focus mode
await mainFocusButton.click();
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
// Focus mode may show task selection placeholder first - select the task
const taskSelectionPlaceholder = page.locator('.task-title-placeholder');
const isPlaceholderVisible = await taskSelectionPlaceholder
.isVisible()
.catch(() => false);
if (isPlaceholderVisible) {
console.log('Task selection placeholder visible, selecting task...');
// Click on the placeholder to open task selector
await taskSelectionPlaceholder.click();
// Wait for task selector overlay
const taskSelectorOverlay = page.locator('.task-selector-overlay');
await expect(taskSelectorOverlay).toBeVisible({ timeout: 3000 });
// Wait a bit for the autocomplete to show suggestions
await page.waitForTimeout(500);
// Click on the first suggested task (mat-option is in CDK overlay panel)
const suggestedTask = page.locator('mat-option, .mat-mdc-option').first();
await expect(suggestedTask).toBeVisible({ timeout: 5000 });
await suggestedTask.click();
await page.waitForTimeout(500);
}
// Wait for focus mode main component to be ready (after task selection)
await page.waitForTimeout(500);
// Step 2: Switch to Countdown mode
await countdownButton.click();
await expect(countdownButton).toHaveClass(/is-active/, { timeout: 2000 });
// Step 3: The slider should be visible in Countdown mode during preparation
await expect(durationSlider).toBeVisible({ timeout: 3000 });
// Get initial timer state before any changes
const initialClockText = await clockTime.textContent();
console.log('Initial clock text in Countdown mode:', initialClockText);
// Step 4: Switch to Flowtime mode
await flowtimeButton.click();
await expect(flowtimeButton).toHaveClass(/is-active/, { timeout: 2000 });
// Duration slider should NOT be visible in Flowtime mode
await expect(durationSlider).not.toBeVisible({ timeout: 2000 });
// Verify clock shows 0:00 (Flowtime starts at 0 and counts up)
await page.waitForTimeout(300);
const clockText = await clockTime.textContent();
console.log('Clock text in Flowtime mode:', clockText);
expect(clockText?.trim()).toBe('0:00');
// Step 5: Start the focus session by clicking play button
await expect(playButton).toBeVisible({ timeout: 2000 });
await playButton.click();
// Wait for the 5-4-3-2-1 countdown animation to complete
const countdownComponent = page.locator('focus-mode-countdown');
// Wait for countdown to appear and then disappear
try {
await expect(countdownComponent).toBeVisible({ timeout: 2000 });
console.log('Countdown animation started...');
// Wait for countdown to complete (5 seconds + animation buffer)
await expect(countdownComponent).not.toBeVisible({ timeout: 15000 });
console.log('Countdown animation completed');
} catch {
console.log('Countdown animation not visible (may be skipped in settings)');
}
// Wait for the timer to start running
await page.waitForTimeout(2000);
// Wait for clock-time to show a non-zero value (indicating session has started)
await expect(async () => {
const text = await clockTime.textContent();
const trimmed = text?.trim() || '';
console.log('Current clock time:', trimmed);
const seconds = parseTime(trimmed);
expect(seconds).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
// Now verify the timer continues to increase
const time1 = await clockTime.textContent();
console.log('Time at T1:', time1);
await page.waitForTimeout(3000);
const time2 = await clockTime.textContent();
console.log('Time at T2:', time2);
const seconds1 = parseTime(time1);
const seconds2 = parseTime(time2);
console.log('Seconds at T1:', seconds1);
console.log('Seconds at T2:', seconds2);
// Timer should have increased
expect(seconds2).toBeGreaterThan(seconds1);
// Verify once more
await page.waitForTimeout(2000);
const time3 = await clockTime.textContent();
const seconds3 = parseTime(time3);
console.log('Seconds at T3:', seconds3);
expect(seconds3).toBeGreaterThan(seconds2);
});
test('Exact bug scenario: Set Countdown duration, switch to Flowtime, verify timer runs', async ({
page,
testPrefix,
}) => {
// This is the exact user scenario from bug report
const workViewPage = new WorkViewPage(page, testPrefix);
const focusModeOverlay = page.locator('focus-mode-overlay');
const mainFocusButton = page
.getByRole('button')
.filter({ hasText: 'center_focus_strong' });
const flowtimeButton = page.locator('segmented-button-group button', {
hasText: 'Flowtime',
});
const countdownButton = page.locator('segmented-button-group button', {
hasText: 'Countdown',
});
const playButton = page.locator('focus-mode-main button.play-button');
const clockTime = page.locator('focus-mode-main .clock-time');
const durationSlider = page.locator('input-duration-slider');
// Setup
await workViewPage.waitForTaskList();
await workViewPage.addTask('FlowTimeTestTask2');
// Open focus mode
await mainFocusButton.click();
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
// Focus mode may show task selection placeholder first - select the task
const taskSelectionPlaceholder = page.locator('.task-title-placeholder');
const isPlaceholderVisible = await taskSelectionPlaceholder
.isVisible()
.catch(() => false);
if (isPlaceholderVisible) {
console.log('Task selection placeholder visible, selecting task...');
await taskSelectionPlaceholder.click();
const taskSelectorOverlay = page.locator('.task-selector-overlay');
await expect(taskSelectorOverlay).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(500);
const suggestedTask = page.locator('mat-option, .mat-mdc-option').first();
await expect(suggestedTask).toBeVisible({ timeout: 5000 });
await suggestedTask.click();
await page.waitForTimeout(500);
}
await page.waitForTimeout(500);
// Step 1: Switch to Countdown mode
await countdownButton.click();
await expect(countdownButton).toHaveClass(/is-active/, { timeout: 2000 });
await expect(durationSlider).toBeVisible({ timeout: 2000 });
// Step 2: Note the countdown duration displayed (default 25 min or whatever is set)
const countdownTime = await clockTime.textContent();
console.log('Countdown duration displayed:', countdownTime);
// Step 3: Switch to Flowtime (without starting the countdown session)
await flowtimeButton.click();
await expect(flowtimeButton).toHaveClass(/is-active/, { timeout: 2000 });
// Clock should show 0:00 for Flowtime
await page.waitForTimeout(300);
const flowTimeDisplay = await clockTime.textContent();
console.log('Flowtime initial display:', flowTimeDisplay);
expect(flowTimeDisplay?.trim()).toBe('0:00');
// Step 4: Start the Flowtime session
await expect(playButton).toBeVisible({ timeout: 2000 });
await playButton.click();
// Wait for countdown animation to complete
const countdownComponent = page.locator('focus-mode-countdown');
try {
await expect(countdownComponent).toBeVisible({ timeout: 2000 });
console.log('Countdown animation started...');
await expect(countdownComponent).not.toBeVisible({ timeout: 15000 });
console.log('Countdown animation completed');
} catch {
console.log('Countdown animation not visible (may be skipped)');
}
// Wait for session to start
await page.waitForTimeout(2000);
// Wait for clock-time to show a non-zero value
await expect(async () => {
const text = await clockTime.textContent();
const trimmed = text?.trim() || '';
console.log('Current clock time:', trimmed);
const seconds = parseTime(trimmed);
expect(seconds).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
// Step 5: Verify timer is counting up and doesn't stop
const time1 = await clockTime.textContent();
console.log('Flowtime time T1:', time1);
await page.waitForTimeout(3000);
const time2 = await clockTime.textContent();
console.log('Flowtime time T2:', time2);
await page.waitForTimeout(3000);
const time3 = await clockTime.textContent();
console.log('Flowtime time T3:', time3);
// Parse times
const seconds1 = parseTime(time1);
const seconds2 = parseTime(time2);
const seconds3 = parseTime(time3);
console.log('Seconds:', seconds1, '->', seconds2, '->', seconds3);
// All times should be increasing (timer is running)
expect(seconds2).toBeGreaterThan(seconds1);
expect(seconds3).toBeGreaterThan(seconds2);
// The timer should continue running indefinitely
// If bug is present, it would stop at the countdown duration
});
});

View file

@ -56,20 +56,48 @@ function _handleError(
}
}
const OBJECT_OBJECT_STR = '[object Object]';
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function _getErrorStr(e: unknown): string {
if (typeof e === 'string') {
return e;
}
if (e == null) {
return 'Unknown error';
}
// Check for message property first (standard Error and custom errors)
if (typeof (e as any).message === 'string' && (e as any).message) {
return (e as any).message;
}
if (e instanceof Error) {
return e.toString();
}
if (typeof e === 'object' && e !== null) {
// Check for name property
if (typeof (e as any).name === 'string' && (e as any).name) {
return (e as any).name;
}
if (typeof e === 'object') {
try {
return JSON.stringify(e);
} catch (err) {
return String(e);
const jsonStr = JSON.stringify(e);
if (jsonStr && jsonStr !== '{}') {
return jsonStr;
}
} catch {
// Circular reference - fall through
}
// Try toString but check for [object Object]
const str = String(e);
if (str && str !== OBJECT_OBJECT_STR) {
return str;
}
}
return String(e);
return 'Unknown error (unable to extract message)';
}

114
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "superProductivity",
"version": "16.7.3",
"version": "16.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "superProductivity",
"version": "16.7.3",
"version": "16.8.0",
"license": "MIT",
"workspaces": [
"packages/*"
@ -80,7 +80,7 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@typescript-eslint/types": "^8.17.0",
"@typescript-eslint/utils": "^8.41.0",
"@typescript-eslint/utils": "^8.51.0",
"angular-material-css-vars": "^9.1.1",
"baseline-browser-mapping": "^2.9.11",
"canvas-confetti": "^1.9.4",
@ -5205,20 +5205,6 @@
"node": ">=20.11.0"
}
},
"node_modules/@es-joy/jsdoccomment/node_modules/@typescript-eslint/types": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
"integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@es-joy/resolve.exports": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz",
@ -11258,14 +11244,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz",
"integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.41.0",
"@typescript-eslint/types": "^8.41.0",
"@typescript-eslint/tsconfig-utils": "^8.51.0",
"@typescript-eslint/types": "^8.51.0",
"debug": "^4.3.4"
},
"engines": {
@ -11308,9 +11294,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz",
"integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
"dev": true,
"license": "MIT",
"engines": {
@ -11384,9 +11370,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz",
"integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
"dev": true,
"license": "MIT",
"engines": {
@ -11437,16 +11423,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz",
"integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/typescript-estree": "8.41.0"
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -11461,14 +11447,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz",
"integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0"
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -11479,22 +11465,21 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz",
"integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.41.0",
"@typescript-eslint/tsconfig-utils": "8.41.0",
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0",
"@typescript-eslint/project-service": "8.51.0",
"@typescript-eslint/tsconfig-utils": "8.51.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -11508,13 +11493,13 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz",
"integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/types": "8.51.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -11538,10 +11523,27 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
"dev": true,
"license": "MIT",
"engines": {

View file

@ -1,6 +1,6 @@
{
"name": "superProductivity",
"version": "16.7.3",
"version": "16.8.0",
"description": "ToDo list and Time Tracking",
"keywords": [
"ToDo",
@ -200,7 +200,7 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@typescript-eslint/types": "^8.17.0",
"@typescript-eslint/utils": "^8.41.0",
"@typescript-eslint/utils": "^8.51.0",
"angular-material-css-vars": "^9.1.1",
"baseline-browser-mapping": "^2.9.11",
"canvas-confetti": "^1.9.4",

View file

@ -72,6 +72,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -1083,6 +1084,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz",
"integrity": "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -1273,6 +1275,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@ -1961,6 +1964,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2121,6 +2125,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
}
@ -2175,6 +2180,7 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz",
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==",
"dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@ -2426,6 +2432,7 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View file

@ -4767,13 +4767,6 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"optional": true
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View file

@ -96,6 +96,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -604,6 +605,7 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@ -626,6 +628,7 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2302,6 +2305,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@ -3479,6 +3483,7 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-30.0.4.tgz",
"integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==",
"dev": true,
"peer": true,
"dependencies": {
"@jest/core": "30.0.4",
"@jest/types": "30.0.1",
@ -4088,6 +4093,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -5345,6 +5351,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -81,6 +81,7 @@
[currentTask]="currentTask()"
[currentTaskId]="currentTaskId()"
[currentTaskContext]="currentTaskContext()"
[hasTrackableTasks]="hasTrackableTasks()"
></play-button>
}

View file

@ -162,6 +162,12 @@ export class MainHeaderComponent implements OnDestroy {
return this.globalConfigService.cfg()?.appFeatures.isSyncIconEnabled;
});
// Check if there are any undone tasks that can be tracked
private readonly _hasTrackableTasks$ = this.workContextService.undoneTasks$.pipe(
map((tasks) => tasks.length > 0),
);
hasTrackableTasks = toSignal(this._hasTrackableTasks$, { initialValue: true });
private readonly _userProfileService = inject(UserProfileService);
isUserProfilesEnabled = computed(() => {
return (

View file

@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ElementRef,
inject,
input,
@ -73,10 +74,11 @@ import { distinctUntilChanged, observeOn } from 'rxjs/operators';
<button
(click)="taskService.toggleStartTask()"
[color]="currentTaskId() ? 'accent' : 'primary'"
matTooltip="{{ T.MH.TOGGLE_TRACK_TIME | translate }}"
[matTooltip]="tooltipText()"
matTooltipPosition="below"
class="play-btn tour-playBtn mat-elevation-z3"
mat-mini-fab
[disabled]="isDisabled()"
>
@if (!currentTaskId()) {
<mat-icon>play_arrow</mat-icon>
@ -203,8 +205,16 @@ export class PlayButtonComponent implements OnInit, OnDestroy {
readonly currentTask = input<Task | null>();
readonly currentTaskId = input<string | null>();
readonly currentTaskContext = input<WorkContext | null>();
readonly hasTrackableTasks = input<boolean>(true);
readonly circleSvg = viewChild<ElementRef<SVGCircleElement>>('circleSvg');
readonly isDisabled = computed(
() => !this.currentTaskId() && !this.hasTrackableTasks(),
);
readonly tooltipText = computed(() =>
this.isDisabled() ? T.MH.NO_TASKS_TO_TRACK : T.MH.TOGGLE_TRACK_TIME,
);
private _subs = new Subscription();
private circumference = 10 * 2 * Math.PI; // ~62.83
protected hasTimeEstimate = false;

View file

@ -4,14 +4,50 @@ import { provideMockStore } from '@ngrx/store/testing';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { DateAdapter } from '@angular/material/core';
import { DEFAULT_FIRST_DAY_OF_WEEK } from 'src/app/core/locale.constants';
import { GlobalConfigState } from '../../features/config/global-config.model';
describe('DateTimeFormatService', () => {
let service: DateTimeFormatService;
let dateAdapter: DateAdapter<Date>;
const createServiceWithFirstDayOfWeek = (
firstDayOfWeek: number | null | undefined,
): DateTimeFormatService => {
const config: GlobalConfigState = {
...DEFAULT_GLOBAL_CONFIG,
localization: {
...DEFAULT_GLOBAL_CONFIG.localization,
firstDayOfWeek,
},
};
const mockDateAdapter = {
getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK,
setLocale: jasmine.createSpy('setLocale'),
} as unknown as DateAdapter<Date>;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
DateTimeFormatService,
provideMockStore({
initialState: {
globalConfig: config,
},
}),
{ provide: DateAdapter, useValue: mockDateAdapter },
],
});
dateAdapter = TestBed.inject(DateAdapter);
return TestBed.inject(DateTimeFormatService);
};
beforeEach(() => {
const dateAdapter = jasmine.createSpyObj<DateAdapter<Date>>('DateAdapter', [], {
const mockDateAdapter = {
getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK,
});
setLocale: jasmine.createSpy('setLocale'),
} as unknown as DateAdapter<Date>;
TestBed.configureTestingModule({
providers: [
@ -21,38 +57,85 @@ describe('DateTimeFormatService', () => {
globalConfig: DEFAULT_GLOBAL_CONFIG,
},
}),
{ provide: DateAdapter, useValue: dateAdapter },
{ provide: DateAdapter, useValue: mockDateAdapter },
],
});
dateAdapter = TestBed.inject(DateAdapter);
service = TestBed.inject(DateTimeFormatService);
});
it('should use system locale by default', () => {
// By default, timeLocale should be undefined (system default)
const testTime = new Date(2024, 0, 15, 14, 30).getTime();
const formatted = service.formatTime(testTime);
// Should produce a valid time string
expect(formatted).toBeTruthy();
expect(formatted.length).toBeGreaterThan(0);
});
it('should detect 24-hour format for appropriate locales', () => {
// When no locale is set, is24HourFormat should work with system default
const is24Hour = service.is24HourFormat();
expect(typeof is24Hour).toBe('boolean');
});
it('should maintain consistent behavior', () => {
// Test that multiple calls return the same format detection
const firstCheck = service.is24HourFormat();
const secondCheck = service.is24HourFormat();
expect(firstCheck).toBe(secondCheck);
// Test that formatTime produces consistent output
const testTime = new Date(2024, 0, 15, 14, 30).getTime();
const formatted1 = service.formatTime(testTime);
const formatted2 = service.formatTime(testTime);
expect(formatted1).toBe(formatted2);
});
describe('firstDayOfWeek configuration', () => {
it('should set Sunday (0) as first day of week when configured', () => {
createServiceWithFirstDayOfWeek(0);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(0);
});
it('should set Monday (1) as first day of week when configured', () => {
createServiceWithFirstDayOfWeek(1);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(1);
});
it('should set Tuesday (2) as first day of week when configured', () => {
createServiceWithFirstDayOfWeek(2);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(2);
});
it('should set Saturday (6) as first day of week when configured', () => {
createServiceWithFirstDayOfWeek(6);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(6);
});
it('should default to Monday when firstDayOfWeek is null', () => {
createServiceWithFirstDayOfWeek(null);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(DEFAULT_FIRST_DAY_OF_WEEK);
});
it('should default to Monday when firstDayOfWeek is undefined', () => {
createServiceWithFirstDayOfWeek(undefined);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(DEFAULT_FIRST_DAY_OF_WEEK);
});
it('should default to Monday when firstDayOfWeek is negative', () => {
createServiceWithFirstDayOfWeek(-1);
TestBed.flushEffects();
expect(dateAdapter.getFirstDayOfWeek()).toBe(DEFAULT_FIRST_DAY_OF_WEEK);
});
});
});

View file

@ -66,7 +66,8 @@ export class DateTimeFormatService {
const cfgValue = this._globalConfigService.localization()?.firstDayOfWeek;
// If not set or reset - use Monday as default (ISO 8601 standard)
if (!cfgValue) {
// Note: Must use explicit null/undefined check since 0 (Sunday) is a valid value
if (cfgValue === null || cfgValue === undefined) {
this._dateAdapter.getFirstDayOfWeek = () => DEFAULT_FIRST_DAY_OF_WEEK;
return;
}

View file

@ -0,0 +1,79 @@
import { getGithubErrorUrl, getSimpleMeta } from './global-error-handler.util';
import { getErrorTxt } from '../../util/get-error-text';
describe('global-error-handler.util', () => {
describe('getGithubErrorUrl', () => {
it('should include error title in URL', () => {
const url = getGithubErrorUrl('Test error message');
// URL encoding uses + for spaces in query strings
expect(url).toContain('Test+error+message');
});
it('should prepend error title with crash emoji', () => {
const url = getGithubErrorUrl('Test error');
// The title should be URL-encoded "💥 Test error"
expect(url).toContain('%F0%9F%92%A5'); // 💥 emoji URL-encoded
});
it('should include stacktrace in body when provided', () => {
const url = getGithubErrorUrl('Error', 'at function1\nat function2');
expect(url).toContain('function1');
});
it('should use bug report template', () => {
const url = getGithubErrorUrl('Error');
expect(url).toContain('template=in_app_bug_report.md');
});
});
describe('getSimpleMeta', () => {
it('should return meta info string', () => {
const meta = getSimpleMeta();
expect(meta).toContain('META:');
expect(meta).toContain('SP');
});
});
describe('error title extraction for GitHub URL', () => {
it('should extract meaningful title from Error object using getErrorTxt', () => {
const error = new Error('Database connection failed');
const errorTitle = getErrorTxt(error);
const url = getGithubErrorUrl(errorTitle);
// URL encoding uses + for spaces in query strings
expect(url).toContain('Database+connection+failed');
expect(url).not.toContain('object+Object');
});
it('should extract meaningful title from custom error with name', () => {
const error = { name: 'ValidationError', code: 500 };
const errorTitle = getErrorTxt(error);
const url = getGithubErrorUrl(errorTitle);
expect(url).toContain('ValidationError');
expect(url).not.toContain('object+Object');
});
it('should never produce [object Object] in GitHub URL title', () => {
// This test ensures the fix for issue #5822 works correctly
const errorCases = [
new Error('Standard error'),
new TypeError('Type error'),
{ message: 'Object with message' },
{ name: 'NamedError' },
{ error: { message: 'Nested error' } },
{ statusText: 'Not Found' },
{ code: 500, details: 'Server error' }, // Object without standard props
];
for (const error of errorCases) {
const errorTitle = getErrorTxt(error);
const url = getGithubErrorUrl(errorTitle);
// URL encoding uses + for spaces in query strings
expect(url).not.toContain('object+Object');
expect(errorTitle).not.toBe('[object Object]');
}
});
});
});

View file

@ -6,6 +6,7 @@ import { download, downloadLogs } from '../../util/download';
import { privacyExport } from '../../imex/file-imex/privacy-export';
import { getAppVersionStr } from '../../util/get-app-version-str';
import { Log } from '../log';
import { getErrorTxt } from '../../util/get-error-text';
let isWasErrorAlertCreated = false;
@ -79,7 +80,7 @@ export const logAdvancedStacktrace = (
Log.log(githubIssueLinks);
if (githubIssueLinks) {
const errEscaped = _cleanHtml(origErr as string);
const errEscaped = _cleanHtml(getErrorTxt(origErr));
Array.from(githubIssueLinks).forEach((el) =>
el.setAttribute('href', getGithubErrorUrl(errEscaped, stack, origErr)),
);

View file

@ -56,6 +56,15 @@ export class GlobalTrackingIntervalService {
this._currentTrackingStart = Date.now();
}
/**
* Reset the tracking start time to now.
* This is used after syncing time from external sources (like Android foreground service)
* to prevent double-counting the time that was already synced.
*/
resetTrackingStart(): void {
this._currentTrackingStart = Date.now();
}
private _createTodayDateStrObservable(): Observable<string> {
const timerBased$ = this.globalInterval$.pipe(
map(() => this._dateService.todayStr()),

View file

@ -0,0 +1,108 @@
import { Injectable, inject } from '@angular/core';
import { IS_ELECTRON } from '../../app.constants';
import { devError } from '../../util/dev-error';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
import { IndexedDBAdapterService } from './indexed-db-adapter.service';
import { DBAdapter } from './db-adapter.model';
import { AndroidDbAdapterService } from './android-db-adapter.service';
import { Log } from '../log';
// Flag to prevent showing multiple error dialogs when DB fails repeatedly
let isIdbErrorAlertShown = false;
@Injectable({
providedIn: 'root',
})
export class DatabaseService {
private _translateService = inject(TranslateService);
private _indexedDbAdapterService = inject(IndexedDBAdapterService);
private _androidDbAdapterService = inject(AndroidDbAdapterService);
private _lastParams?: { a: string; key?: string; data?: unknown };
// private _adapter: DBAdapter =
// IS_ANDROID_WEB_VIEW && androidInterface.saveToDb && androidInterface.loadFromDb
// ? this._androidDbAdapterService
// : this._indexedDbAdapterService;
private _adapter: DBAdapter = this._indexedDbAdapterService;
constructor() {
this._adapter = this._indexedDbAdapterService;
this._init().then();
}
async load(key: string): Promise<unknown> {
this._lastParams = { a: 'load', key };
try {
return await this._adapter.load(key);
} catch (e) {
Log.err('DB Load Error: Last Params,', this._lastParams);
return this._errorHandler(e);
}
}
async save(key: string, data: unknown): Promise<unknown> {
this._lastParams = { a: 'save', key, data };
// disable saving during testing
// return Promise.resolve();
try {
return await this._adapter.save(key, data);
} catch (e) {
Log.err('DB Save Error: Last Params,', this._lastParams);
return this._errorHandler(e);
}
}
async remove(key: string): Promise<unknown> {
this._lastParams = { a: 'remove', key };
try {
return await this._adapter.remove(key);
} catch (e) {
Log.err('DB Remove Error: Last Params,', this._lastParams);
return this._errorHandler(e);
}
}
async clearDatabase(): Promise<unknown> {
this._lastParams = { a: 'clearDatabase' };
try {
return await this._adapter.clearDatabase();
} catch (e) {
Log.err('DB Clear Error: Last Params,', this._lastParams);
return this._errorHandler(e);
}
}
private async _init(): Promise<void> {
try {
await this._adapter.init();
} catch (e) {
Log.err('Database initialization failed');
Log.err('_lastParams', this._lastParams);
Log.err(e);
alert('DB INIT Error');
throw new Error(e as any);
}
}
private async _errorHandler(e: Error | unknown): Promise<void> {
devError(e);
// Only show one error dialog to prevent spam when disk is full
// and multiple DB operations fail in rapid succession
if (!isIdbErrorAlertShown) {
isIdbErrorAlertShown = true;
alert(this._translateService.instant(T.CONFIRM.RELOAD_AFTER_IDB_ERROR));
this._restartApp();
}
// If alert already shown, silently fail (app is restarting anyway)
}
private _restartApp(): void {
if (IS_ELECTRON) {
window.ea.relaunch();
window.ea.exit(0);
} else {
window.location.reload();
}
}
}

View file

@ -18,11 +18,13 @@ import { combineLatest } from 'rxjs';
import { FocusModeMode, TimerState } from '../../focus-mode/focus-mode.model';
import { DroidLog } from '../../../core/log';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
import { SnackService } from '../../../core/snack/snack.service';
@Injectable()
export class AndroidFocusModeEffects {
private _store = inject(Store);
private _hydrationState = inject(HydrationStateService);
private _snackService = inject(SnackService);
// Start/stop focus mode notification when timer state changes
syncFocusModeToNotification$ =
@ -81,13 +83,18 @@ export class AndroidFocusModeEffects {
isBreak: isBreakActive,
isPaused: !timer.isRunning,
});
androidInterface.startFocusModeService?.(
title,
timer.duration,
remainingMs,
isBreakActive,
!timer.isRunning,
taskTitle,
this._safeNativeCall(
() =>
androidInterface.startFocusModeService?.(
title,
timer.duration,
remainingMs,
isBreakActive,
!timer.isRunning,
taskTitle,
),
'Failed to start focus mode notification',
true,
);
} else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) {
// Only update if something significant changed
@ -97,18 +104,25 @@ export class AndroidFocusModeEffects {
isPaused: !timer.isRunning,
isBreak: isBreakActive,
});
androidInterface.updateFocusModeService?.(
title,
remainingMs,
!timer.isRunning,
isBreakActive,
taskTitle,
this._safeNativeCall(
() =>
androidInterface.updateFocusModeService?.(
title,
remainingMs,
!timer.isRunning,
isBreakActive,
taskTitle,
),
'Failed to update focus mode service',
);
}
} else if (wasFocusModeActive && !isFocusModeActive) {
// Focus mode ended, stop the service
DroidLog.log('AndroidFocusModeEffects: Stopping focus mode service');
androidInterface.stopFocusModeService?.();
this._safeNativeCall(
() => androidInterface.stopFocusModeService?.(),
'Failed to stop focus mode service',
);
}
}),
),
@ -156,6 +170,17 @@ export class AndroidFocusModeEffects {
),
);
private _safeNativeCall(fn: () => void, errorMsg: string, showSnackbar = false): void {
try {
fn();
} catch (e) {
DroidLog.err(errorMsg, e);
if (showSnackbar) {
this._snackService.open({ msg: errorMsg, type: 'ERROR' });
}
}
}
private _getNotificationTitle(
mode: FocusModeMode,
isBreak: boolean,

View file

@ -157,3 +157,623 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
});
});
});
describe('AndroidForegroundTrackingEffects - safeNativeCall error handling', () => {
let logErrSpy: jasmine.Spy;
let snackOpenSpy: jasmine.Spy;
// Replicate the _safeNativeCall helper logic for testing
const safeNativeCall = (
fn: () => void,
errorMsg: string,
showSnackbar: boolean,
logErr: (msg: string, e: unknown) => void,
snackOpen: (params: { msg: string; type: string }) => void,
): void => {
try {
fn();
} catch (e) {
logErr(errorMsg, e);
if (showSnackbar) {
snackOpen({ msg: errorMsg, type: 'ERROR' });
}
}
};
beforeEach(() => {
logErrSpy = jasmine.createSpy('DroidLog.err');
snackOpenSpy = jasmine.createSpy('snackService.open');
});
it('should not log error when native call succeeds', () => {
const successFn = jasmine.createSpy('successFn');
safeNativeCall(successFn, 'Error message', false, logErrSpy, snackOpenSpy);
expect(successFn).toHaveBeenCalled();
expect(logErrSpy).not.toHaveBeenCalled();
expect(snackOpenSpy).not.toHaveBeenCalled();
});
it('should log error when native call throws', () => {
const error = new Error('Java exception was raised');
const failFn = jasmine.createSpy('failFn').and.throwError(error);
safeNativeCall(failFn, 'Failed to start service', false, logErrSpy, snackOpenSpy);
expect(failFn).toHaveBeenCalled();
expect(logErrSpy).toHaveBeenCalledWith('Failed to start service', error);
expect(snackOpenSpy).not.toHaveBeenCalled();
});
it('should show snackbar when native call throws and showSnackbar is true', () => {
const error = new Error('Java exception was raised');
const failFn = jasmine.createSpy('failFn').and.throwError(error);
safeNativeCall(failFn, 'Failed to start tracking', true, logErrSpy, snackOpenSpy);
expect(failFn).toHaveBeenCalled();
expect(logErrSpy).toHaveBeenCalledWith('Failed to start tracking', error);
expect(snackOpenSpy).toHaveBeenCalledWith({
msg: 'Failed to start tracking',
type: 'ERROR',
});
});
it('should NOT show snackbar when native call throws and showSnackbar is false', () => {
const error = new Error('Java exception was raised');
const failFn = jasmine.createSpy('failFn').and.throwError(error);
safeNativeCall(failFn, 'Failed to update service', false, logErrSpy, snackOpenSpy);
expect(failFn).toHaveBeenCalled();
expect(logErrSpy).toHaveBeenCalledWith('Failed to update service', error);
expect(snackOpenSpy).not.toHaveBeenCalled();
});
it('should handle different error types', () => {
const stringError = 'String error message';
const failFn = (): void => {
throw stringError;
};
safeNativeCall(failFn, 'Native call failed', true, logErrSpy, snackOpenSpy);
expect(logErrSpy).toHaveBeenCalledWith('Native call failed', stringError);
expect(snackOpenSpy).toHaveBeenCalledWith({
msg: 'Native call failed',
type: 'ERROR',
});
});
});
describe('AndroidForegroundTrackingEffects - saveTimeTrackingImmediately logic', () => {
/**
* Tests for the immediate save functionality added to fix issue #5842.
* When notification buttons (Pause/Done) are clicked, time tracking data
* should be saved immediately to IndexedDB, bypassing the 15-second debounce.
*/
let taskSaveSpy: jasmine.Spy;
let timeTrackingSaveSpy: jasmine.Spy;
// Replicate the _saveTimeTrackingImmediately helper logic for testing
const saveTimeTrackingImmediately = (
taskState: { entities: Record<string, unknown>; selectedTaskId: string | null },
ttState: { project: Record<string, unknown>; tag: Record<string, unknown> },
isProduction: boolean,
taskSave: (data: unknown, options: unknown) => void,
timeTrackingSave: (data: unknown, options: unknown) => void,
): void => {
// Save task state (same logic as in _saveTimeTrackingImmediately)
taskSave(
{
...taskState,
selectedTaskId: isProduction ? null : taskState.selectedTaskId,
currentTaskId: null,
},
{ isUpdateRevAndLastUpdate: true },
);
// Save time tracking state
timeTrackingSave(ttState, { isUpdateRevAndLastUpdate: true });
};
beforeEach(() => {
taskSaveSpy = jasmine.createSpy('pfapiService.m.task.save');
timeTrackingSaveSpy = jasmine.createSpy('pfapiService.m.timeTracking.save');
});
it('should save task state with currentTaskId set to null', () => {
const taskState = {
entities: { task1: { id: 'task-1', timeSpent: 60000 } },
selectedTaskId: 'task-1',
};
const ttState = { project: {}, tag: {} };
saveTimeTrackingImmediately(
taskState,
ttState,
true,
taskSaveSpy,
timeTrackingSaveSpy,
);
expect(taskSaveSpy).toHaveBeenCalledWith(
{
entities: { task1: { id: 'task-1', timeSpent: 60000 } },
selectedTaskId: null,
currentTaskId: null,
},
{ isUpdateRevAndLastUpdate: true },
);
});
it('should save time tracking state with isUpdateRevAndLastUpdate flag', () => {
const taskState = {
entities: {},
selectedTaskId: null,
};
const ttState = {
project: { proj1: { d20240101: { s: 1000, e: 2000 } } },
tag: { tag1: { d20240101: { s: 1000, e: 2000 } } },
};
saveTimeTrackingImmediately(
taskState,
ttState,
true,
taskSaveSpy,
timeTrackingSaveSpy,
);
expect(timeTrackingSaveSpy).toHaveBeenCalledWith(
{
project: { proj1: { d20240101: { s: 1000, e: 2000 } } },
tag: { tag1: { d20240101: { s: 1000, e: 2000 } } },
},
{ isUpdateRevAndLastUpdate: true },
);
});
it('should preserve selectedTaskId in non-production mode', () => {
const taskState = {
entities: {},
selectedTaskId: 'task-1',
};
const ttState = { project: {}, tag: {} };
saveTimeTrackingImmediately(
taskState,
ttState,
false, // non-production
taskSaveSpy,
timeTrackingSaveSpy,
);
expect(taskSaveSpy).toHaveBeenCalledWith(
{
entities: {},
selectedTaskId: 'task-1', // preserved in non-production
currentTaskId: null,
},
{ isUpdateRevAndLastUpdate: true },
);
});
it('should call both save methods when saving immediately', () => {
const taskState = { entities: {}, selectedTaskId: null };
const ttState = { project: {}, tag: {} };
saveTimeTrackingImmediately(
taskState,
ttState,
true,
taskSaveSpy,
timeTrackingSaveSpy,
);
expect(taskSaveSpy).toHaveBeenCalledTimes(1);
expect(timeTrackingSaveSpy).toHaveBeenCalledTimes(1);
});
});
describe('AndroidForegroundTrackingEffects - notification handler logic', () => {
/**
* Tests for notification button handlers (Pause/Done).
* These should sync elapsed time AND call immediate save before pausing.
*/
let syncElapsedTimeSpy: jasmine.Spy;
let saveImmediatelySpy: jasmine.Spy;
let pauseCurrentSpy: jasmine.Spy;
let setDoneSpy: jasmine.Spy;
// Replicate the pause handler logic
const handlePauseAction = (
currentTask: { id: string } | null,
syncElapsedTime: (taskId: string) => void,
saveImmediately: () => void,
pauseCurrent: () => void,
): void => {
if (!currentTask) return;
syncElapsedTime(currentTask.id);
saveImmediately();
pauseCurrent();
};
// Replicate the done handler logic
const handleDoneAction = (
currentTask: { id: string } | null,
syncElapsedTime: (taskId: string) => void,
setDone: (taskId: string) => void,
saveImmediately: () => void,
pauseCurrent: () => void,
): void => {
if (!currentTask) return;
syncElapsedTime(currentTask.id);
setDone(currentTask.id);
saveImmediately();
pauseCurrent();
};
beforeEach(() => {
syncElapsedTimeSpy = jasmine.createSpy('syncElapsedTimeForTask');
saveImmediatelySpy = jasmine.createSpy('saveTimeTrackingImmediately');
pauseCurrentSpy = jasmine.createSpy('pauseCurrent');
setDoneSpy = jasmine.createSpy('setDone');
});
describe('handlePauseAction', () => {
it('should sync, save immediately, then pause in correct order', () => {
const currentTask = { id: 'task-1' };
const callOrder: string[] = [];
syncElapsedTimeSpy.and.callFake(() => callOrder.push('sync'));
saveImmediatelySpy.and.callFake(() => callOrder.push('save'));
pauseCurrentSpy.and.callFake(() => callOrder.push('pause'));
handlePauseAction(
currentTask,
syncElapsedTimeSpy,
saveImmediatelySpy,
pauseCurrentSpy,
);
expect(callOrder).toEqual(['sync', 'save', 'pause']);
});
it('should call saveImmediately to bypass 15s debounce', () => {
const currentTask = { id: 'task-1' };
handlePauseAction(
currentTask,
syncElapsedTimeSpy,
saveImmediatelySpy,
pauseCurrentSpy,
);
expect(saveImmediatelySpy).toHaveBeenCalledTimes(1);
});
it('should not execute if currentTask is null', () => {
handlePauseAction(null, syncElapsedTimeSpy, saveImmediatelySpy, pauseCurrentSpy);
expect(syncElapsedTimeSpy).not.toHaveBeenCalled();
expect(saveImmediatelySpy).not.toHaveBeenCalled();
expect(pauseCurrentSpy).not.toHaveBeenCalled();
});
});
describe('handleDoneAction', () => {
it('should sync, setDone, save immediately, then pause in correct order', () => {
const currentTask = { id: 'task-1' };
const callOrder: string[] = [];
syncElapsedTimeSpy.and.callFake(() => callOrder.push('sync'));
setDoneSpy.and.callFake(() => callOrder.push('done'));
saveImmediatelySpy.and.callFake(() => callOrder.push('save'));
pauseCurrentSpy.and.callFake(() => callOrder.push('pause'));
handleDoneAction(
currentTask,
syncElapsedTimeSpy,
setDoneSpy,
saveImmediatelySpy,
pauseCurrentSpy,
);
expect(callOrder).toEqual(['sync', 'done', 'save', 'pause']);
});
it('should call saveImmediately after setDone to persist done status', () => {
const currentTask = { id: 'task-1' };
handleDoneAction(
currentTask,
syncElapsedTimeSpy,
setDoneSpy,
saveImmediatelySpy,
pauseCurrentSpy,
);
expect(setDoneSpy).toHaveBeenCalledWith('task-1');
expect(saveImmediatelySpy).toHaveBeenCalledTimes(1);
});
it('should not execute if currentTask is null', () => {
handleDoneAction(
null,
syncElapsedTimeSpy,
setDoneSpy,
saveImmediatelySpy,
pauseCurrentSpy,
);
expect(syncElapsedTimeSpy).not.toHaveBeenCalled();
expect(setDoneSpy).not.toHaveBeenCalled();
expect(saveImmediatelySpy).not.toHaveBeenCalled();
expect(pauseCurrentSpy).not.toHaveBeenCalled();
});
});
});
describe('AndroidForegroundTrackingEffects - syncElapsedTimeForTask logic', () => {
/**
* Tests for the _syncElapsedTimeForTask method that syncs time from
* the native Android foreground service to the app's task state.
* Uses firstValueFrom for reliable observable handling (fixes issue #5840).
*/
let addTimeSpentSpy: jasmine.Spy;
let resetTrackingStartSpy: jasmine.Spy;
// Replicate the sync logic for testing
const syncElapsedTimeForTask = async (
taskId: string,
elapsedJson: string | null,
getTask: (id: string) => Promise<{ id: string; timeSpent: number } | null>,
addTimeSpent: (task: unknown, duration: number, date: string) => void,
resetTrackingStart: () => void,
todayStr: string,
): Promise<void> => {
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) {
return;
}
const task = await getTask(taskId);
if (!task) {
return;
}
const currentTimeSpent = task.timeSpent || 0;
const duration = nativeData.elapsedMs - currentTimeSpent;
if (duration > 0) {
addTimeSpent(task, duration, todayStr);
resetTrackingStart();
}
} catch {
// Error handling
}
};
beforeEach(() => {
addTimeSpentSpy = jasmine.createSpy('addTimeSpent');
resetTrackingStartSpy = jasmine.createSpy('resetTrackingStart');
});
it('should add duration when native has more time than app', async () => {
const nativeElapsed = 900000; // 15 minutes
const appTimeSpent = 60000; // 1 minute
const expectedDuration = nativeElapsed - appTimeSpent; // 14 minutes
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs: nativeElapsed });
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: appTimeSpent,
});
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).toHaveBeenCalledWith(
{ id: 'task-1', timeSpent: appTimeSpent },
expectedDuration,
'2024-01-01',
);
});
it('should NOT add time when native and app times match', async () => {
const elapsedMs = 60000;
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs });
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: elapsedMs, // Same as native
});
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
});
it('should handle null elapsedJson gracefully', async () => {
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0,
});
await syncElapsedTimeForTask(
'task-1',
null,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should handle "null" string elapsedJson gracefully', async () => {
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0,
});
await syncElapsedTimeForTask(
'task-1',
'null',
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should call resetTrackingStart after successful sync', async () => {
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs: 60000 });
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0, // App has 0, native has 60s -> should sync
});
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(resetTrackingStartSpy).toHaveBeenCalledTimes(1);
});
it('should NOT call resetTrackingStart when no duration is added', async () => {
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs: 60000 });
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 60000, // Same as native - no sync needed
});
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should NOT sync if native is tracking a different task', async () => {
const elapsedJson = JSON.stringify({ taskId: 'task-2', elapsedMs: 60000 });
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0,
});
await syncElapsedTimeForTask(
'task-1', // We want to sync task-1
elapsedJson, // But native is tracking task-2
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should handle task not found gracefully', async () => {
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs: 60000 });
const getTask = async (): Promise<null> => null;
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should handle invalid JSON gracefully', async () => {
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0,
});
await syncElapsedTimeForTask(
'task-1',
'invalid json {',
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).not.toHaveBeenCalled();
expect(resetTrackingStartSpy).not.toHaveBeenCalled();
});
it('should handle task with zero timeSpent', async () => {
const elapsedJson = JSON.stringify({ taskId: 'task-1', elapsedMs: 300000 }); // 5 minutes
const getTask = async (): Promise<{ id: string; timeSpent: number }> => ({
id: 'task-1',
timeSpent: 0, // Fresh task with no time spent yet
});
await syncElapsedTimeForTask(
'task-1',
elapsedJson,
getTask,
addTimeSpentSpy,
resetTrackingStartSpy,
'2024-01-01',
);
expect(addTimeSpentSpy).toHaveBeenCalledWith(
{ id: 'task-1', timeSpent: 0 },
300000, // Full 5 minutes should be added
'2024-01-01',
);
expect(resetTrackingStartSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
import {
distinctUntilChanged,
filter,
first,
map,
pairwise,
startWith,
@ -13,13 +14,21 @@ import {
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 {
selectCurrentTask,
selectTaskFeatureState,
} 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';
import { combineLatest, firstValueFrom } from 'rxjs';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
import { SnackService } from '../../../core/snack/snack.service';
import { PfapiService } from '../../../pfapi/pfapi.service';
import { selectTimeTrackingState } from '../../time-tracking/store/time-tracking.selectors';
import { environment } from '../../../../environments/environment';
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
@Injectable()
export class AndroidForegroundTrackingEffects {
@ -27,6 +36,9 @@ export class AndroidForegroundTrackingEffects {
private _taskService = inject(TaskService);
private _dateService = inject(DateService);
private _hydrationState = inject(HydrationStateService);
private _snackService = inject(SnackService);
private _pfapiService = inject(PfapiService);
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
/**
* Start/stop the native foreground service when the current task changes.
@ -73,7 +85,10 @@ export class AndroidForegroundTrackingEffects {
DroidLog.log(
'Focus mode active, stopping tracking service to avoid duplicate notification',
);
androidInterface.stopTrackingService?.();
this._safeNativeCall(
() => androidInterface.stopTrackingService?.(),
'Failed to stop tracking service',
);
return;
}
@ -83,14 +98,22 @@ export class AndroidForegroundTrackingEffects {
title: currentTask.title,
timeSpent: currentTask.timeSpent,
});
androidInterface.startTrackingService?.(
currentTask.id,
currentTask.title,
currentTask.timeSpent || 0,
this._safeNativeCall(
() =>
androidInterface.startTrackingService?.(
currentTask.id,
currentTask.title,
currentTask.timeSpent || 0,
),
'Failed to start tracking notification',
true,
);
} else {
DroidLog.log('Stopping tracking service');
androidInterface.stopTrackingService?.();
this._safeNativeCall(
() => androidInterface.stopTrackingService?.(),
'Failed to stop tracking service',
);
}
}),
),
@ -107,8 +130,8 @@ export class AndroidForegroundTrackingEffects {
androidInterface.onResume$.pipe(
withLatestFrom(this._store.select(selectCurrentTask)),
filter(([, currentTask]) => !!currentTask),
tap(([, currentTask]) => {
this._syncElapsedTimeForTask(currentTask!.id);
tap(async ([, currentTask]) => {
await this._syncElapsedTimeForTask(currentTask!.id);
}),
),
{ dispatch: false },
@ -161,7 +184,10 @@ export class AndroidForegroundTrackingEffects {
taskId: curr.taskId,
timeSpent: curr.timeSpent,
});
androidInterface.updateTrackingService?.(curr.timeSpent);
this._safeNativeCall(
() => androidInterface.updateTrackingService?.(curr.timeSpent),
'Failed to update tracking service',
);
}),
),
{ dispatch: false },
@ -169,6 +195,7 @@ export class AndroidForegroundTrackingEffects {
/**
* Handle pause action from the notification.
* Immediately saves to DB to prevent data loss if app is closed quickly.
*/
handlePauseAction$ =
IS_ANDROID_WEB_VIEW &&
@ -177,10 +204,12 @@ export class AndroidForegroundTrackingEffects {
androidInterface.onPauseTracking$.pipe(
withLatestFrom(this._store.select(selectCurrentTask)),
filter(([, currentTask]) => !!currentTask),
tap(([, currentTask]) => {
tap(async ([, currentTask]) => {
DroidLog.log('Pause action from notification');
// Sync elapsed time first, then pause
this._syncElapsedTimeForTask(currentTask!.id);
// Sync elapsed time first and wait for completion
await this._syncElapsedTimeForTask(currentTask!.id);
// Force immediate save to prevent data loss (bypasses 15s debounce)
this._saveTimeTrackingImmediately();
this._taskService.pauseCurrent();
}),
),
@ -189,6 +218,7 @@ export class AndroidForegroundTrackingEffects {
/**
* Handle done action from the notification.
* Immediately saves to DB to prevent data loss if app is closed quickly.
*/
handleDoneAction$ =
IS_ANDROID_WEB_VIEW &&
@ -197,22 +227,76 @@ export class AndroidForegroundTrackingEffects {
androidInterface.onMarkTaskDone$.pipe(
withLatestFrom(this._store.select(selectCurrentTask)),
filter(([, currentTask]) => !!currentTask),
tap(([, currentTask]) => {
tap(async ([, currentTask]) => {
DroidLog.log('Done action from notification', { taskId: currentTask!.id });
// Sync elapsed time, mark as done, then pause
this._syncElapsedTimeForTask(currentTask!.id);
// Sync elapsed time and wait for completion
await this._syncElapsedTimeForTask(currentTask!.id);
this._taskService.setDone(currentTask!.id);
// Force immediate save to prevent data loss (bypasses 15s debounce)
this._saveTimeTrackingImmediately();
this._taskService.pauseCurrent();
}),
),
{ dispatch: false },
);
private _safeNativeCall(fn: () => void, errorMsg: string, showSnackbar = false): void {
try {
fn();
} catch (e) {
DroidLog.err(errorMsg, e);
if (showSnackbar) {
this._snackService.open({ msg: errorMsg, type: 'ERROR' });
}
}
}
/**
* Force immediate save of time tracking data to IndexedDB.
* This bypasses the normal 15-second debounce to ensure data is persisted
* before the app can be closed (e.g., after notification button clicks).
*/
private _saveTimeTrackingImmediately(): void {
// Save task state
this._store
.select(selectTaskFeatureState)
.pipe(first())
.subscribe((taskState) => {
this._pfapiService.m.task
.save(
{
...taskState,
selectedTaskId: environment.production ? null : taskState.selectedTaskId,
currentTaskId: null,
},
{ isUpdateRevAndLastUpdate: true },
)
.catch((e) => DroidLog.err('Failed to save task state immediately', e));
});
// Save time tracking state
this._store
.select(selectTimeTrackingState)
.pipe(first())
.subscribe((ttState) => {
this._pfapiService.m.timeTracking
.save(ttState, {
isUpdateRevAndLastUpdate: true,
})
.catch((e) =>
DroidLog.err('Failed to save time tracking state immediately', e),
);
});
DroidLog.log('Forced immediate save of time tracking data');
}
/**
* Sync elapsed time from native service to the task.
* Only syncs if the native service is tracking the specified task.
* Uses async/await with firstValueFrom for reliable observable handling.
*/
private _syncElapsedTimeForTask(taskId: string): void {
private async _syncElapsedTimeForTask(taskId: string): Promise<void> {
const elapsedJson = androidInterface.getTrackingElapsed?.();
DroidLog.log('Syncing elapsed time for task', { taskId, elapsedJson });
@ -236,31 +320,31 @@ export class AndroidForegroundTrackingEffects {
}
// 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 task = await firstValueFrom(this._taskService.getByIdOnce$(taskId));
if (!task) {
DroidLog.log('Task not found for sync', { taskId });
return;
}
const currentTimeSpent = task.timeSpent || 0;
const duration = nativeData.elapsedMs - currentTimeSpent;
const currentTimeSpent = task.timeSpent || 0;
const duration = nativeData.elapsedMs - currentTimeSpent;
DroidLog.log('Calculated sync duration', {
taskId,
nativeElapsed: nativeData.elapsedMs,
currentTimeSpent,
duration,
});
DroidLog.log('Calculated sync duration', {
taskId,
nativeElapsed: nativeData.elapsedMs,
currentTimeSpent,
duration,
});
if (duration > 0) {
this._taskService.addTimeSpent(task, duration, this._dateService.todayStr());
}
})
.unsubscribe();
if (duration > 0) {
this._taskService.addTimeSpent(task, duration, this._dateService.todayStr());
// Reset the tracking interval to prevent double-counting
// The native service has the authoritative time, so we reset the app's
// interval timer to avoid adding the same time again from tick$
this._globalTrackingIntervalService.resetTrackingStart();
}
} catch (e) {
DroidLog.err('Failed to parse elapsed time', e);
DroidLog.err('Failed to sync elapsed time', e);
}
}
}

View file

@ -8,7 +8,12 @@
@for (board of boards(); track board.id; let i = $index) {
<mat-tab>
<ng-template mat-tab-label>
<span #tabLabel>{{ board.title | translate }}</span>
<div
[matContextMenuTriggerFor]="boardContextMenu"
[matContextMenuTriggerData]="{ boardCfg: board }"
>
{{ board.title | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<board
@ -17,7 +22,6 @@
></board>
</ng-template>
</mat-tab>
<!-- Context menu will be handled outside the loop -->
}
<mat-tab>
@ -39,19 +43,31 @@
</mat-tab>
</mat-tab-group>
<!-- Simple context menu attached to the whole component -->
<context-menu
[rightClickTriggerEl]="componentElement"
[allowedSelectors]="'.mat-mdc-tab-label, span'"
[contextMenu]="boardContextMenuTemplate"
></context-menu>
<ng-template #boardContextMenuTemplate>
<button
mat-menu-item
(click)="duplicateBoard()"
<mat-menu #boardContextMenu>
<ng-template
matMenuContent
let-board="boardCfg"
>
Duplicate Board
</button>
<!-- <button mat-menu-item>Rename</button>-->
</ng-template>
<button
mat-menu-item
(click)="editBoard(board)"
>
<mat-icon>edit</mat-icon>
{{ T.G.EDIT | translate }}
</button>
<button
mat-menu-item
(click)="duplicateBoard(board)"
>
<mat-icon>file_copy</mat-icon>
{{ T.G.DUPLICATE | translate }}
</button>
<button
mat-menu-item
(click)="removeBoard(board)"
>
<mat-icon class="color-warn-i">delete_forever</mat-icon>
{{ T.G.DELETE | translate }}
</button>
</ng-template>
</mat-menu>

View file

@ -15,6 +15,26 @@
.mat-mdc-tab {
min-width: 40px !important;
/* The context menu is attached to the div in the tab-label, which is essentially only the title text;
To be able to open the context menu via right-click on the hole tab-label, we modify the default material
design to have zero padding and let the inner div define the tab shape. The inner spans which normally
define the text-alignment are "deactivated" by defining display: contents. */
padding: 0px;
span.mdc-tab__content {
display: contents;
span.mdc-tab__text-label {
display: contents;
div {
padding: 0px 24px;
display: flex;
align-items: center;
}
}
}
}
.mat-tab-label:last-child {

View file

@ -22,7 +22,11 @@ import { TranslatePipe } from '@ngx-translate/core';
import { BoardEditComponent } from './board-edit/board-edit.component';
import { DEFAULT_BOARD_CFG } from './boards.const';
import { BoardsActions } from './store/boards.actions';
import { ContextMenuComponent } from '../../ui/context-menu/context-menu.component';
import { BoardCfg } from './boards.model';
import { MatDialog } from '@angular/material/dialog';
import { DialogBoardEditComponent } from './dialog-board-edit/dialog-board-edit.component';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { Log } from 'src/app/core/log';
@Component({
selector: 'boards',
@ -39,13 +43,13 @@ import { ContextMenuComponent } from '../../ui/context-menu/context-menu.compone
CdkDropListGroup,
TranslatePipe,
BoardEditComponent,
ContextMenuComponent,
],
templateUrl: './boards.component.html',
styleUrl: './boards.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BoardsComponent {
private _matDialog = inject(MatDialog);
store = inject(Store);
elementRef = inject(ElementRef);
selectedTabIndex = signal(localStorage.getItem(LS.SELECTED_BOARD) || 0);
@ -83,11 +87,9 @@ export class BoardsComponent {
return this.elementRef.nativeElement;
}
duplicateBoard(): void {
const selectedTabId = this.selectedTabIndex();
const boardToDuplicate = this.boards()?.[selectedTabId];
duplicateBoard(boardToDuplicate: BoardCfg): void {
if (!boardToDuplicate) {
console.warn('No board selected to duplicate');
Log.warn('No board selected to duplicate');
return;
}
this.store.dispatch(
@ -104,4 +106,38 @@ export class BoardsComponent {
}),
);
}
editBoard(board: BoardCfg): void {
if (!board) {
Log.warn('No board selected to edit');
return;
}
this._matDialog.open(DialogBoardEditComponent, {
data: {
board: board,
},
});
}
removeBoard(board: BoardCfg): void {
if (!board) {
Log.warn('No board selected to remove');
return;
}
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
cancelTxt: T.G.CANCEL,
okTxt: T.G.DELETE,
message: T.F.BOARDS.V.CONFIRM_DELETE,
},
})
.afterClosed()
.subscribe((isConfirm: boolean) => {
if (isConfirm) {
this.store.dispatch(BoardsActions.removeBoard({ id: board.id }));
}
});
}
}

View file

@ -53,9 +53,7 @@ export class DialogBoardEditComponent {
data: {
cancelTxt: T.G.CANCEL,
okTxt: T.G.DELETE,
message:
// TODO translate
'Are you sure you want to delete this Board?',
message: T.F.BOARDS.V.CONFIRM_DELETE,
},
})
.afterClosed()

View file

@ -41,5 +41,12 @@ export const FOCUS_MODE_FORM_CFG: ConfigFormSection<FocusModeConfig> = {
label: T.GCF.FOCUS_MODE.L_PAUSE_TRACKING_DURING_BREAK,
},
},
{
key: 'isManualBreakStart',
type: 'checkbox',
templateOptions: {
label: T.GCF.FOCUS_MODE.L_MANUAL_BREAK_START,
},
},
],
};

View file

@ -199,6 +199,7 @@ export type FocusModeConfig = Readonly<{
isPauseTrackingDuringBreak?: boolean;
isSyncSessionWithTracking?: boolean;
isStartInBackground?: boolean;
isManualBreakStart?: boolean;
}>;
export type DailySummaryNote = Readonly<{

View file

@ -35,12 +35,23 @@
{{ T.F.FOCUS_MODE.BACK_TO_PLANNING | translate }}
</button>
<button
mat-raised-button
color="primary"
(click)="currentTask() ? continueWithFocusSession() : startNextFocusSession()"
>
<mat-icon>arrow_forward</mat-icon>
{{ T.F.FOCUS_MODE.START_NEXT_FOCUS_SESSION | translate }}
</button>
@if (mode() === FocusModeMode.Pomodoro && focusModeConfig()?.isManualBreakStart) {
<button
mat-raised-button
color="primary"
(click)="startBreakManually()"
>
<mat-icon>free_breakfast</mat-icon>
{{ T.F.FOCUS_MODE.START_BREAK | translate }}
</button>
} @else {
<button
mat-raised-button
color="primary"
(click)="currentTask() ? continueWithFocusSession() : startNextFocusSession()"
>
<mat-icon>arrow_forward</mat-icon>
{{ T.F.FOCUS_MODE.START_NEXT_FOCUS_SESSION | translate }}
</button>
}
</div>

View file

@ -10,13 +10,18 @@ import {
hideFocusOverlay,
selectFocusTask,
selectFocusDuration,
startBreak,
} from '../store/focus-mode.actions';
import { selectCurrentCycle } from '../store/focus-mode.selectors';
import { FocusModeMode } from '../focus-mode.model';
import { T } from '../../../t.const';
import {
selectCurrentTask,
selectLastCurrentTask,
} from '../../tasks/store/task.selectors';
import { selectFocusModeConfig } from '../../config/store/global-config.reducer';
import { FocusModeStrategyFactory, PomodoroStrategy } from '../focus-mode-strategies';
import { unsetCurrentTask } from '../../tasks/store/task.actions';
describe('FocusModeSessionDoneComponent', () => {
let component: FocusModeSessionDoneComponent;
@ -26,11 +31,30 @@ describe('FocusModeSessionDoneComponent', () => {
lastSessionTotalDurationOrTimeElapsedFallback: ReturnType<typeof signal<number>>;
};
let mockConfettiService: jasmine.SpyObj<ConfettiService>;
let mockStrategyFactory: jasmine.SpyObj<FocusModeStrategyFactory>;
let mockPomodoroStrategy: jasmine.SpyObj<PomodoroStrategy>;
let environmentInjector: EnvironmentInjector;
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select']);
mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select', 'pipe']);
mockConfettiService = jasmine.createSpyObj('ConfettiService', ['createConfetti']);
mockPomodoroStrategy = jasmine.createSpyObj(
'PomodoroStrategy',
['getBreakDuration'],
{
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: true,
shouldAutoStartNextSession: true,
},
);
mockPomodoroStrategy.getBreakDuration.and.returnValue({
duration: 5 * 60 * 1000,
isLong: false,
});
mockStrategyFactory = jasmine.createSpyObj('FocusModeStrategyFactory', [
'getStrategy',
]);
mockStrategyFactory.getStrategy.and.returnValue(mockPomodoroStrategy);
mockFocusModeService = {
mode: signal(FocusModeMode.Pomodoro),
@ -44,6 +68,12 @@ describe('FocusModeSessionDoneComponent', () => {
if (selector === selectLastCurrentTask) {
return of({ id: 'task-1', title: 'Last Task' });
}
if (selector === selectFocusModeConfig) {
return of({ isManualBreakStart: false, isPauseTrackingDuringBreak: false });
}
if (selector === selectCurrentCycle) {
return of(1);
}
return of(null);
});
@ -52,6 +82,7 @@ describe('FocusModeSessionDoneComponent', () => {
{ provide: Store, useValue: mockStore },
{ provide: FocusModeService, useValue: mockFocusModeService },
{ provide: ConfettiService, useValue: mockConfettiService },
{ provide: FocusModeStrategyFactory, useValue: mockStrategyFactory },
],
});
@ -136,4 +167,76 @@ describe('FocusModeSessionDoneComponent', () => {
expect(mockStore.dispatch).toHaveBeenCalledWith(selectFocusDuration());
});
});
describe('startBreakManually', () => {
it('should get strategy from factory', () => {
component.startBreakManually();
expect(mockStrategyFactory.getStrategy).toHaveBeenCalledWith(
FocusModeMode.Pomodoro,
);
});
it('should dispatch startBreak action with correct duration', () => {
component.startBreakManually();
expect(mockStore.dispatch).toHaveBeenCalledWith(
startBreak({
duration: 5 * 60 * 1000,
isLongBreak: false,
pausedTaskId: undefined,
}),
);
});
it('should pause tracking and include pausedTaskId when isPauseTrackingDuringBreak is enabled', () => {
mockStore.select.and.callFake((selector: any) => {
if (selector === selectCurrentTask) {
return of({ id: 'task-1', title: 'Test Task' });
}
if (selector === selectLastCurrentTask) {
return of({ id: 'task-1', title: 'Last Task' });
}
if (selector === selectFocusModeConfig) {
return of({ isManualBreakStart: true, isPauseTrackingDuringBreak: true });
}
if (selector === selectCurrentCycle) {
return of(1);
}
return of(null);
});
runInInjectionContext(environmentInjector, () => {
component = new FocusModeSessionDoneComponent();
});
component.startBreakManually();
expect(mockStore.dispatch).toHaveBeenCalledWith(unsetCurrentTask());
expect(mockStore.dispatch).toHaveBeenCalledWith(
startBreak({
duration: 5 * 60 * 1000,
isLongBreak: false,
pausedTaskId: 'task-1',
}),
);
});
it('should use long break duration when on long break cycle', () => {
mockPomodoroStrategy.getBreakDuration.and.returnValue({
duration: 15 * 60 * 1000,
isLong: true,
});
component.startBreakManually();
expect(mockStore.dispatch).toHaveBeenCalledWith(
startBreak({
duration: 15 * 60 * 1000,
isLongBreak: true,
pausedTaskId: undefined,
}),
);
});
});
});

View file

@ -22,7 +22,12 @@ import {
hideFocusOverlay,
selectFocusTask,
selectFocusDuration,
startBreak,
} from '../store/focus-mode.actions';
import { selectCurrentCycle } from '../store/focus-mode.selectors';
import { selectFocusModeConfig } from '../../config/store/global-config.reducer';
import { FocusModeStrategyFactory } from '../focus-mode-strategies';
import { unsetCurrentTask } from '../../tasks/store/task.actions';
import { MatIcon } from '@angular/material/icon';
import { TaskTrackingInfoComponent } from '../task-tracking-info/task-tracking-info.component';
@ -37,10 +42,13 @@ export class FocusModeSessionDoneComponent implements AfterViewInit {
private _store = inject(Store);
private readonly _confettiService = inject(ConfettiService);
private readonly _focusModeService = inject(FocusModeService);
private readonly _strategyFactory = inject(FocusModeStrategyFactory);
mode = this._focusModeService.mode;
FocusModeMode = FocusModeMode;
currentTask = toSignal(this._store.select(selectCurrentTask));
focusModeConfig = toSignal(this._store.select(selectFocusModeConfig));
currentCycle = toSignal(this._store.select(selectCurrentCycle));
taskTitle = toSignal(
this._store.select(selectLastCurrentTask).pipe(
switchMap((lastCurrentTask) =>
@ -84,4 +92,29 @@ export class FocusModeSessionDoneComponent implements AfterViewInit {
continueWithFocusSession(): void {
this._store.dispatch(selectFocusDuration());
}
startBreakManually(): void {
const mode = this.mode();
const cycle = this.currentCycle() ?? 1;
const currentTaskId = this.currentTask()?.id;
const config = this.focusModeConfig();
const strategy = this._strategyFactory.getStrategy(mode);
const breakInfo = strategy.getBreakDuration(cycle);
if (breakInfo) {
// Pause task tracking during break if enabled
const shouldPauseTracking = config?.isPauseTrackingDuringBreak && currentTaskId;
if (shouldPauseTracking) {
this._store.dispatch(unsetCurrentTask());
}
this._store.dispatch(
startBreak({
duration: breakInfo.duration,
isLongBreak: breakInfo.isLong,
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
}
}
}

View file

@ -1,9 +1,13 @@
import { TestBed } from '@angular/core/testing';
import {
TestBed,
fakeAsync,
tick as testTick,
discardPeriodicTasks,
} from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { BehaviorSubject, of } from 'rxjs';
import { of } from 'rxjs';
import { FocusModeService } from './focus-mode.service';
import { GlobalConfigService } from '../config/global-config.service';
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
import { FocusScreen, FocusModeMode, FocusMainUIState } from './focus-mode.model';
import * as selectors from './store/focus-mode.selectors';
import * as actions from './store/focus-mode.actions';
@ -12,9 +16,9 @@ import { selectFocusModeConfig } from '../config/store/global-config.reducer';
describe('FocusModeService', () => {
let service: FocusModeService;
let mockStore: jasmine.SpyObj<Store>;
let tickSubject: BehaviorSubject<number>;
let isRunningValue: boolean;
beforeEach(() => {
const setupTestBed = (): void => {
const storeSpy = jasmine.createSpyObj('Store', [
'select',
'dispatch',
@ -28,17 +32,8 @@ describe('FocusModeService', () => {
}),
});
tickSubject = new BehaviorSubject<number>(0);
const globalTrackingIntervalServiceSpy = jasmine.createSpyObj(
'GlobalTrackingIntervalService',
[],
{
tick$: tickSubject.asObservable(),
},
);
// Setup store selectors before TestBed configuration
storeSpy.select.and.callFake((selector) => {
storeSpy.select.and.callFake((selector: unknown) => {
if (selector === selectors.selectCurrentScreen) {
return of(FocusScreen.Main);
}
@ -55,7 +50,7 @@ describe('FocusModeService', () => {
return of(0);
}
if (selector === selectors.selectIsRunning) {
return of(false);
return of(isRunningValue);
}
if (selector === selectors.selectTimeElapsed) {
return of(0);
@ -90,7 +85,7 @@ describe('FocusModeService', () => {
return of(null);
});
storeSpy.selectSignal.and.callFake((selector) => {
storeSpy.selectSignal.and.callFake((selector: unknown) => {
if (selector === selectors.selectCurrentScreen) {
return () => FocusScreen.Main;
}
@ -107,7 +102,7 @@ describe('FocusModeService', () => {
return () => 0;
}
if (selector === selectors.selectIsRunning) {
return () => false;
return () => isRunningValue;
}
if (selector === selectors.selectTimeElapsed) {
return () => 0;
@ -147,138 +142,216 @@ describe('FocusModeService', () => {
FocusModeService,
{ provide: Store, useValue: storeSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
{
provide: GlobalTrackingIntervalService,
useValue: globalTrackingIntervalServiceSpy,
},
],
});
service = TestBed.inject(FocusModeService);
mockStore = TestBed.inject(Store) as jasmine.SpyObj<Store>;
};
beforeEach(() => {
isRunningValue = false;
});
it('should be created', () => {
it('should be created', fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
expect(service).toBeTruthy();
});
discardPeriodicTasks();
}));
describe('signals', () => {
it('should initialize currentScreen signal', () => {
beforeEach(fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
}));
afterEach(fakeAsync(() => {
discardPeriodicTasks();
}));
it('should initialize currentScreen signal', fakeAsync(() => {
expect(service.currentScreen()).toBe(FocusScreen.Main);
});
}));
it('should initialize mainState signal', () => {
it('should initialize mainState signal', fakeAsync(() => {
expect(service.mainState()).toBe(FocusMainUIState.Preparation);
});
it('should initialize mode signal', () => {
}));
it('should initialize mode signal', fakeAsync(() => {
expect(service.mode()).toBe(FocusModeMode.Pomodoro);
});
}));
it('should initialize isOverlayShown signal', () => {
it('should initialize isOverlayShown signal', fakeAsync(() => {
expect(service.isOverlayShown()).toBe(false);
});
}));
it('should initialize currentCycle signal', () => {
it('should initialize currentCycle signal', fakeAsync(() => {
expect(service.currentCycle()).toBe(0);
});
}));
it('should initialize timer signals', () => {
it('should initialize timer signals', fakeAsync(() => {
expect(service.isRunning()).toBe(false);
expect(service.timeElapsed()).toBe(0);
expect(service.timeRemaining()).toBe(1500000);
expect(service.progress()).toBe(0);
expect(service.sessionDuration()).toBe(300000);
});
}));
it('should initialize session signals', () => {
it('should initialize session signals', fakeAsync(() => {
expect(service.isSessionRunning()).toBe(false);
expect(service.isSessionPaused()).toBe(false);
});
}));
it('should initialize break signals', () => {
it('should initialize break signals', fakeAsync(() => {
expect(service.isBreakActive()).toBe(false);
expect(service.isLongBreak()).toBe(false);
});
}));
});
describe('computed signals', () => {
it('should compute isCountTimeDown correctly for Pomodoro mode', () => {
// Since the service is initialized with Pomodoro mode from the setup
expect(service.isCountTimeDown()).toBe(true);
});
beforeEach(fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
}));
it('should compute isCountTimeDown correctly for Flowtime mode', () => {
// Test the logic: mode() !== FocusModeMode.Flowtime should return false for Flowtime
// Since we initialized with Pomodoro mode, we can test the negation
// The isCountTimeDown computed returns mode() !== FocusModeMode.Flowtime
// For Pomodoro: true !== false = true (which we test above)
// For Flowtime: Flowtime !== Flowtime = false (expected behavior)
afterEach(fakeAsync(() => {
discardPeriodicTasks();
}));
it('should compute isCountTimeDown correctly for Pomodoro mode', fakeAsync(() => {
expect(service.isCountTimeDown()).toBe(true);
}));
it('should compute isCountTimeDown correctly for Flowtime mode', fakeAsync(() => {
// The current service setup returns Pomodoro mode, so isCountTimeDown should be true
expect(service.isCountTimeDown()).toBe(true);
});
}));
});
describe('compatibility aliases', () => {
it('should provide isBreakLong alias', () => {
beforeEach(fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
}));
afterEach(fakeAsync(() => {
discardPeriodicTasks();
}));
it('should provide isBreakLong alias', fakeAsync(() => {
expect(service.isBreakLong).toBe(service.isLongBreak);
});
}));
it('should provide timeElapsed signal', () => {
it('should provide timeElapsed signal', fakeAsync(() => {
expect(service.timeElapsed()).toBe(0);
});
}));
it('should provide progress signal', () => {
it('should provide progress signal', fakeAsync(() => {
expect(service.progress()).toBe(0);
});
}));
it('should provide focusModeConfig signal', () => {
it('should provide focusModeConfig signal', fakeAsync(() => {
expect(service.focusModeConfig).toBeDefined();
});
}));
it('should provide pomodoroConfig signal', () => {
it('should provide pomodoroConfig signal', fakeAsync(() => {
expect(service.pomodoroConfig).toBeDefined();
});
}));
});
describe('timer subscription', () => {
it('should not dispatch tick action when timer is not running', () => {
// The service is initialized with isRunning = false from the setup
// So ticking should not dispatch any actions
tickSubject.next(1);
it('should not dispatch tick action when timer is not running', fakeAsync(() => {
isRunningValue = false;
setupTestBed();
service = TestBed.inject(FocusModeService);
// Advance time by 1 second
testTick(1000);
expect(mockStore.dispatch).not.toHaveBeenCalledWith(actions.tick());
});
discardPeriodicTasks();
}));
it('should have timer subscription active', () => {
// Test that the timer subscription is working by verifying the service starts properly
it('should dispatch tick action when timer is running', fakeAsync(() => {
isRunningValue = true;
setupTestBed();
service = TestBed.inject(FocusModeService);
// Advance time by 1 second
testTick(1000);
expect(mockStore.dispatch).toHaveBeenCalledWith(actions.tick());
discardPeriodicTasks();
}));
it('should dispatch tick action every second when running', fakeAsync(() => {
isRunningValue = true;
setupTestBed();
service = TestBed.inject(FocusModeService);
// Advance time by 3 seconds
testTick(3000);
// Should have dispatched tick 3 times
const tickCalls = mockStore.dispatch.calls
.allArgs()
.filter(
(args) => (args[0] as unknown as { type: string }).type === actions.tick().type,
);
expect(tickCalls.length).toBe(3);
discardPeriodicTasks();
}));
it('should have timer subscription active', fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
expect(service).toBeDefined();
expect(service.isRunning).toBeDefined();
});
discardPeriodicTasks();
}));
});
describe('observable versions for compatibility', () => {
it('should provide sessionProgress$ observable', () => {
beforeEach(fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
}));
afterEach(fakeAsync(() => {
discardPeriodicTasks();
}));
it('should provide sessionProgress$ observable', fakeAsync(() => {
service.sessionProgress$.subscribe((progress) => {
expect(progress).toBe(0);
});
});
}));
it('should provide currentSessionTime$ observable', () => {
it('should provide currentSessionTime$ observable', fakeAsync(() => {
service.currentSessionTime$.subscribe((time) => {
expect(time).toBe(0);
});
});
}));
it('should provide timeToGo$ observable', () => {
it('should provide timeToGo$ observable', fakeAsync(() => {
service.timeToGo$.subscribe((time) => {
expect(time).toBe(1500000);
});
});
}));
});
describe('currentScreen signal', () => {
it('should initialize with Main screen by default', () => {
beforeEach(fakeAsync(() => {
setupTestBed();
service = TestBed.inject(FocusModeService);
}));
afterEach(fakeAsync(() => {
discardPeriodicTasks();
}));
it('should initialize with Main screen by default', fakeAsync(() => {
expect(service.currentScreen()).toBe(FocusScreen.Main);
});
}));
});
});

View file

@ -1,12 +1,12 @@
import { computed, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { interval } from 'rxjs';
import { filter, tap } from 'rxjs/operators';
import * as actions from './store/focus-mode.actions';
import * as selectors from './store/focus-mode.selectors';
import { GlobalConfigService } from '../config/global-config.service';
import { selectFocusModeConfig } from '../config/store/global-config.reducer';
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
import { FocusModeMode } from './focus-mode.model';
@Injectable({
@ -15,7 +15,6 @@ import { FocusModeMode } from './focus-mode.model';
export class FocusModeService {
private _store = inject(Store);
private _globalConfigService = inject(GlobalConfigService);
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
// State signals
currentScreen = this._store.selectSignal(selectors.selectCurrentScreen);
@ -59,10 +58,11 @@ export class FocusModeService {
currentSessionTime$ = this._store.select(selectors.selectTimeElapsed);
timeToGo$ = this._store.select(selectors.selectTimeRemaining);
// Single timer that updates the store
// Single timer that updates the store at 1-second intervals for smooth UI
constructor() {
// Start the timer subscription with proper cleanup
this._globalTrackingIntervalService.tick$
// Use a fixed 1-second interval for focus mode timer
// This is independent of the global tracking interval (which controls disk writes)
interval(1000)
.pipe(
filter(() => this.isRunning() === true),
tap(() => this._store.dispatch(actions.tick())),

View file

@ -0,0 +1,484 @@
/**
* Bug reproduction test for GitHub issue #5117
* https://github.com/johannesjo/super-productivity/issues/5117
*
* Bug: Flowtime focus mode stops counting up at 25:00 minutes (or whatever
* the Countdown duration was set to).
*
* User reproduction steps:
* 1. Tap start focus session
* 2. Open the Countdown tab
* 3. Set countdown to 5 minutes (but do not start the session)
* 4. Open the Flowtime tab
* 5. Start focus session counting up from 0
*
* Expected: Timer counts indefinitely
* Actual: Timer stops at 5 minutes (the Countdown value)
*/
import { FocusModeMode, FocusModeState } from '../focus-mode.model';
import * as actions from './focus-mode.actions';
import { focusModeReducer, initialState } from './focus-mode.reducer';
describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
let initialTime: number;
beforeEach(() => {
initialTime = Date.now();
jasmine.clock().install();
jasmine.clock().mockDate(new Date(initialTime));
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should reproduce the bug: Flowtime timer stops at previously set Countdown duration', () => {
// Step 1: Start in default state (could be any mode)
let state = { ...initialState };
// Step 2: Switch to Countdown mode
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
expect(state.mode).toBe(FocusModeMode.Countdown);
// Step 3: Set countdown duration to 5 minutes (but do NOT start session)
const fiveMinutes = 5 * 60 * 1000;
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: fiveMinutes }),
);
expect(state.timer.duration).toBe(fiveMinutes);
expect(state.timer.purpose).toBeNull(); // Session not started
// Step 4: Switch to Flowtime mode
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
expect(state.mode).toBe(FocusModeMode.Flowtime);
// NOTE: At this point, state.timer.duration is still 5 minutes!
// This is the bug - the duration was not reset when switching to Flowtime
console.log('Duration after switching to Flowtime:', state.timer.duration);
// Step 5: Start Flowtime session with duration: 0
state = focusModeReducer(state, actions.startFocusSession({ duration: 0 }));
expect(state.timer.isRunning).toBe(true);
expect(state.timer.purpose).toBe('work');
expect(state.timer.duration).toBe(0); // Should be 0 for Flowtime
// Simulate 6 minutes passing (past the 5-minute Countdown value)
const sixMinutes = 6 * 60 * 1000;
jasmine.clock().tick(sixMinutes);
// Process tick
state = focusModeReducer(state, actions.tick());
// Timer should still be running at 6 minutes
expect(state.timer.isRunning).toBe(true);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(sixMinutes);
expect(state.timer.purpose).toBe('work');
});
it('should verify that startFocusSession creates a new timer (potential fix confirmation)', () => {
// This test verifies that startFocusSession({ duration: 0 }) creates a fresh timer
// If this passes, the bug is likely NOT in the reducer but in what calls startFocusSession
let state = { ...initialState };
// Set up a "contaminated" state with Countdown duration
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: 5 * 60 * 1000 }),
);
// Switch to Flowtime
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
// The timer.duration is still 5 minutes at this point (before starting session)
const durationBeforeStart = state.timer.duration;
console.log('Duration before startFocusSession:', durationBeforeStart);
// Start session with explicit duration: 0
state = focusModeReducer(state, actions.startFocusSession({ duration: 0 }));
// Verify new timer was created with duration: 0
expect(state.timer.duration).toBe(0);
expect(state.timer.isRunning).toBe(true);
// Run for 10 minutes
jasmine.clock().tick(10 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
expect(state.timer.isRunning).toBe(true);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(10 * 60 * 1000);
});
it('should test what happens if startFocusSession is called WITHOUT duration parameter', () => {
// This tests if the bug could be caused by calling startFocusSession({})
// instead of startFocusSession({ duration: 0 })
let state = { ...initialState };
// Set up Countdown duration
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: 5 * 60 * 1000 }),
);
// Switch to Flowtime
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
// Start session WITHOUT passing duration (should use default 25 min)
state = focusModeReducer(state, actions.startFocusSession({}));
// What duration does the timer have?
console.log(
'Duration when startFocusSession called without duration:',
state.timer.duration,
);
// According to the reducer: duration ?? FOCUS_MODE_DEFAULTS.SESSION_DURATION
// If duration is undefined, it uses 25 minutes default!
expect(state.timer.duration).toBe(25 * 60 * 1000); // This would be the bug!
// Run for 26 minutes
jasmine.clock().tick(26 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
// Timer would stop at 25 minutes if duration wasn't 0
console.log('Timer running after 26 min:', state.timer.isRunning);
console.log('Timer elapsed:', state.timer.elapsed);
});
it('should test effect-driven session start (simulating syncTrackingStartToSession$)', () => {
// The effects use strategy.initialSessionDuration to start sessions
// FlowtimeStrategy.initialSessionDuration is 0, so this should work
// But what if the effect uses the wrong strategy?
let state = { ...initialState };
// Simulate: mode is Flowtime but duration was set from Countdown earlier
state = {
...state,
mode: FocusModeMode.Flowtime,
timer: {
...state.timer,
duration: 5 * 60 * 1000, // "contaminated" from Countdown
},
};
// Start session with duration: 0 (as FlowtimeStrategy would provide)
state = focusModeReducer(state, actions.startFocusSession({ duration: 0 }));
expect(state.timer.duration).toBe(0);
// Run for 10 minutes
jasmine.clock().tick(10 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
expect(state.timer.isRunning).toBe(true);
});
describe('Component simulation - exact user flow', () => {
/**
* This test simulates the exact behavior of FocusModeMainComponent
* to identify if there's a race condition or timing issue
*/
it('should simulate component startSession() behavior', () => {
let state = { ...initialState };
// Simulate component state
let displayDuration = 25 * 60 * 1000; // Default
// Helper: simulate component effect that sets displayDuration
const updateDisplayDuration = (focusModeState: FocusModeState): void => {
const duration = focusModeState.timer.duration; // sessionDuration signal
const mode = focusModeState.mode;
if (mode === FocusModeMode.Flowtime) {
displayDuration = 0;
return;
}
if (duration > 0) {
displayDuration = duration;
return;
}
if (mode === FocusModeMode.Countdown) {
displayDuration = 5 * 60 * 1000; // Simulated stored value
}
};
// Step 1: Initial state (assume Countdown mode)
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
updateDisplayDuration(state);
console.log('Step 1 - Countdown mode, displayDuration:', displayDuration);
// Step 2: User sets duration to 5 minutes via slider
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: 5 * 60 * 1000 }),
);
updateDisplayDuration(state);
console.log('Step 2 - Set 5 min, displayDuration:', displayDuration);
console.log('Step 2 - timer.duration:', state.timer.duration);
// Step 3: User switches to Flowtime
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
// IMPORTANT: Does the effect run immediately? In Angular, it should.
updateDisplayDuration(state);
console.log('Step 3 - Flowtime mode, displayDuration:', displayDuration);
console.log('Step 3 - timer.duration:', state.timer.duration);
console.log('Step 3 - mode:', state.mode);
// Step 4: User clicks play button - simulating startSession()
const modeAtClickTime = state.mode;
const durationToDispatch =
modeAtClickTime === FocusModeMode.Flowtime ? 0 : displayDuration;
console.log('Step 4 - modeAtClickTime:', modeAtClickTime);
console.log('Step 4 - durationToDispatch:', durationToDispatch);
state = focusModeReducer(
state,
actions.startFocusSession({ duration: durationToDispatch }),
);
console.log('After startFocusSession:');
console.log('- timer.duration:', state.timer.duration);
console.log('- timer.isRunning:', state.timer.isRunning);
// Verify the session was started correctly
expect(durationToDispatch).toBe(0);
expect(state.timer.duration).toBe(0);
// Run for 6 minutes
jasmine.clock().tick(6 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
console.log('After 6 minutes:');
console.log('- timer.elapsed:', state.timer.elapsed);
console.log('- timer.isRunning:', state.timer.isRunning);
expect(state.timer.isRunning).toBe(true);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(6 * 60 * 1000);
});
/**
* Test the scenario where timer.duration from Countdown bleeds into tick
* BEFORE startFocusSession resets it - a potential race condition
*/
it('should test hypothetical race condition: tick before startFocusSession', () => {
let state = { ...initialState };
// Set up Countdown with 5 min duration
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: 5 * 60 * 1000 }),
);
// Switch to Flowtime
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
// At this point:
// - mode = Flowtime
// - timer.duration = 5 minutes (from Countdown!)
// - timer.isRunning = false
// - timer.purpose = null
console.log('=== Before session starts ===');
console.log('mode:', state.mode);
console.log('timer.duration:', state.timer.duration);
console.log('timer.isRunning:', state.timer.isRunning);
console.log('timer.purpose:', state.timer.purpose);
// The timer.duration is "contaminated" but since isRunning is false
// and purpose is null, tick() should have no effect
state = focusModeReducer(state, actions.tick());
// tick should not affect idle timer
expect(state.timer.isRunning).toBe(false);
// Now start the session properly
state = focusModeReducer(state, actions.startFocusSession({ duration: 0 }));
expect(state.timer.duration).toBe(0);
expect(state.timer.isRunning).toBe(true);
// This is the correct state - duration is reset by startFocusSession
});
});
describe('Selector behavior analysis', () => {
it('should analyze selector outputs during bug scenario', () => {
// Build state that simulates the bug scenario
let state = { ...initialState };
// Set Countdown mode and duration
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Countdown }),
);
state = focusModeReducer(
state,
actions.setFocusSessionDuration({ focusSessionDuration: 5 * 60 * 1000 }),
);
// Switch to Flowtime (duration not reset)
state = focusModeReducer(
state,
actions.setFocusModeMode({ mode: FocusModeMode.Flowtime }),
);
console.log('=== BEFORE SESSION START ===');
console.log('state.timer.duration:', state.timer.duration);
console.log('state.mode:', state.mode);
// Simulate what selectors would return
const focusModeState: FocusModeState = state;
const timer = focusModeState.timer;
const mode = focusModeState.mode;
// selectTimeElapsed
const timeElapsed = timer.elapsed;
console.log('selectTimeElapsed:', timeElapsed);
// selectTimeDuration
const timeDuration = timer.duration;
console.log('selectTimeDuration:', timeDuration);
// selectTimeRemaining = Math.max(0, duration - elapsed)
const timeRemaining = Math.max(0, timeDuration - timeElapsed);
console.log('selectTimeRemaining:', timeRemaining);
// isCountTimeDown = mode !== FocusModeMode.Flowtime
const isCountTimeDown = mode !== FocusModeMode.Flowtime;
console.log('isCountTimeDown:', isCountTimeDown);
// What would be displayed?
const displayedTime = isCountTimeDown ? timeRemaining : timeElapsed;
console.log('displayedTime:', displayedTime);
// At this point, before session starts, duration is 5 min
// But isCountTimeDown is false (mode is Flowtime)
// So it would display timeElapsed (0)
expect(isCountTimeDown).toBe(false);
// Now start the session with duration: 0
state = focusModeReducer(state, actions.startFocusSession({ duration: 0 }));
console.log('=== AFTER SESSION START ===');
console.log('state.timer.duration:', state.timer.duration);
console.log('state.timer.isRunning:', state.timer.isRunning);
// After session starts, duration should be 0
expect(state.timer.duration).toBe(0);
// Run for 6 minutes
jasmine.clock().tick(6 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
console.log('=== AFTER 6 MINUTES ===');
console.log('state.timer.elapsed:', state.timer.elapsed);
console.log('state.timer.duration:', state.timer.duration);
console.log('state.timer.isRunning:', state.timer.isRunning);
// Check selector outputs
const elapsedAfter = state.timer.elapsed;
const durationAfter = state.timer.duration;
const remainingAfter = Math.max(0, durationAfter - elapsedAfter);
const isCountDownAfter = state.mode !== FocusModeMode.Flowtime;
const displayedAfter = isCountDownAfter ? remainingAfter : elapsedAfter;
console.log('elapsedAfter:', elapsedAfter);
console.log('durationAfter:', durationAfter);
console.log('remainingAfter:', remainingAfter);
console.log('isCountDownAfter:', isCountDownAfter);
console.log('displayedAfter:', displayedAfter);
// Timer should still be running
expect(state.timer.isRunning).toBe(true);
expect(elapsedAfter).toBeGreaterThanOrEqual(6 * 60 * 1000);
});
it('should test what happens if timer.duration was NOT reset by startFocusSession', () => {
// This simulates a hypothetical bug where duration isn't reset
let state: FocusModeState = {
...initialState,
mode: FocusModeMode.Flowtime,
timer: {
isRunning: true,
startedAt: Date.now(),
elapsed: 0,
duration: 5 * 60 * 1000, // Bug: duration is 5 min instead of 0
purpose: 'work',
},
};
console.log('=== SIMULATED BUG STATE ===');
console.log('timer.duration:', state.timer.duration);
console.log('mode:', state.mode);
// Run for 5 minutes
jasmine.clock().tick(5 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
console.log('=== AFTER 5 MINUTES ===');
console.log('timer.elapsed:', state.timer.elapsed);
console.log('timer.isRunning:', state.timer.isRunning);
// With the bug, timer would stop because elapsed >= duration
// THIS IS THE BUG!
if (!state.timer.isRunning) {
console.log('BUG CONFIRMED: Timer stopped at duration limit!');
}
// Check what time would be displayed
const isCountDown = state.mode !== FocusModeMode.Flowtime;
const displayed = isCountDown
? Math.max(0, state.timer.duration - state.timer.elapsed)
: state.timer.elapsed;
console.log('isCountDown:', isCountDown);
console.log('displayed time:', displayed);
// With buggy state:
// - isCountDown = false (Flowtime)
// - displayed = elapsed = ~5 minutes
// - timer.isRunning = false
// So display would show ~5:00 and freeze!
});
});
});

View file

@ -0,0 +1,223 @@
/**
* Integration tests for GitHub issue #5813
* https://github.com/johannesjo/super-productivity/issues/5813
*
* Bug: New Pomodoro Timer doesn't work if you change the time tracking interval
*
* Root cause: FocusModeService was using GlobalTrackingIntervalService.tick$
* which emits at the configured trackingInterval (up to 100 seconds).
* When users set a high tracking interval to reduce disk writes,
* the Pomodoro timer would only update every N seconds instead of every second.
*
* Fix: FocusModeService now uses its own interval(1000) independent of
* the global tracking interval.
*
* These tests verify:
* 1. The reducer correctly calculates elapsed time based on Date.now() - startedAt
* 2. Session completion is detected regardless of tick frequency
* 3. Timer updates work correctly with various tick intervals
*/
import { FocusModeMode } from '../focus-mode.model';
import * as actions from './focus-mode.actions';
import { focusModeReducer, initialState } from './focus-mode.reducer';
describe('FocusMode Bug #5813: Timer works with high tracking interval', () => {
let initialTime: number;
beforeEach(() => {
initialTime = Date.now();
jasmine.clock().install();
jasmine.clock().mockDate(new Date(initialTime));
});
afterEach(() => {
jasmine.clock().uninstall();
});
describe('Reducer: Timer elapsed calculation', () => {
it('should calculate elapsed time correctly based on Date.now() - startedAt', () => {
// Start a Pomodoro session
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: 25 * 60 * 1000 }),
);
expect(state.timer.isRunning).toBe(true);
expect(state.timer.startedAt).toBe(initialTime);
expect(state.timer.elapsed).toBe(0);
// Simulate 5 seconds passing
jasmine.clock().tick(5000);
state = focusModeReducer(state, actions.tick());
// Elapsed should be ~5000ms (calculated from Date.now() - startedAt)
expect(state.timer.elapsed).toBeGreaterThanOrEqual(5000);
expect(state.timer.isRunning).toBe(true);
});
it('should handle infrequent ticks (simulating high tracking interval)', () => {
// Start a 1-minute Pomodoro session
const oneMinute = 60 * 1000;
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: oneMinute }),
);
// Simulate 30 seconds passing with NO ticks (like high tracking interval)
jasmine.clock().tick(30000);
// Now dispatch a single tick
state = focusModeReducer(state, actions.tick());
// Even with just one tick, elapsed should correctly show ~30 seconds
expect(state.timer.elapsed).toBeGreaterThanOrEqual(30000);
expect(state.timer.isRunning).toBe(true);
});
it('should detect session completion even with infrequent ticks', () => {
// Start a 10-second Pomodoro session (for faster testing)
const tenSeconds = 10 * 1000;
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: tenSeconds }),
);
expect(state.timer.isRunning).toBe(true);
// Simulate 15 seconds passing with NO ticks (simulating 100s tracking interval)
jasmine.clock().tick(15000);
// Now dispatch a single tick - session should be detected as complete
state = focusModeReducer(state, actions.tick());
// Timer should stop when elapsed >= duration
expect(state.timer.isRunning).toBe(false);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(tenSeconds);
expect(state.lastCompletedDuration).toBeGreaterThanOrEqual(tenSeconds);
});
});
describe('Scenario: User with 100s tracking interval', () => {
it('should complete a 25-minute Pomodoro even if ticks are 100 seconds apart', () => {
const twentyFiveMinutes = 25 * 60 * 1000;
// Start a standard 25-minute Pomodoro session
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: twentyFiveMinutes }),
);
expect(state.timer.isRunning).toBe(true);
expect(state.timer.duration).toBe(twentyFiveMinutes);
// Simulate ticks every 100 seconds (worst case: trackingInterval = 100000)
const tickInterval = 100 * 1000; // 100 seconds
let elapsedTime = 0;
// Tick until we reach 25 minutes
while (elapsedTime < twentyFiveMinutes) {
jasmine.clock().tick(tickInterval);
elapsedTime += tickInterval;
state = focusModeReducer(state, actions.tick());
if (elapsedTime < twentyFiveMinutes) {
// Timer should still be running before completion
expect(state.timer.isRunning).toBe(true);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(elapsedTime);
}
}
// After 25+ minutes, session should be complete
expect(state.timer.isRunning).toBe(false);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(twentyFiveMinutes);
expect(state.lastCompletedDuration).toBeGreaterThanOrEqual(twentyFiveMinutes);
});
it('should show correct elapsed time at each tick regardless of interval', () => {
const fiveMinutes = 5 * 60 * 1000;
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: fiveMinutes }),
);
// First tick after 1 second (normal)
jasmine.clock().tick(1000);
state = focusModeReducer(state, actions.tick());
expect(state.timer.elapsed).toBeGreaterThanOrEqual(1000);
// Second tick after 100 seconds (high interval)
jasmine.clock().tick(100000);
state = focusModeReducer(state, actions.tick());
expect(state.timer.elapsed).toBeGreaterThanOrEqual(101000);
// Third tick after 1 second again
jasmine.clock().tick(1000);
state = focusModeReducer(state, actions.tick());
expect(state.timer.elapsed).toBeGreaterThanOrEqual(102000);
});
});
describe('Break timer with high tracking interval', () => {
it('should complete break timer even with infrequent ticks', () => {
const fiveMinuteBreak = 5 * 60 * 1000;
// Start a break
let state = focusModeReducer(
initialState,
actions.startBreak({ duration: fiveMinuteBreak, isLongBreak: false }),
);
expect(state.timer.isRunning).toBe(true);
expect(state.timer.purpose).toBe('break');
// Simulate 6 minutes passing with a single tick
jasmine.clock().tick(6 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
// Break should be detected as complete
expect(state.timer.isRunning).toBe(false);
expect(state.timer.elapsed).toBeGreaterThanOrEqual(fiveMinuteBreak);
});
});
describe('Pause/Resume with high tracking interval', () => {
it('should correctly resume timer after pause regardless of tracking interval', () => {
const tenMinutes = 10 * 60 * 1000;
// Start session
let state = focusModeReducer(
{ ...initialState, mode: FocusModeMode.Pomodoro },
actions.startFocusSession({ duration: tenMinutes }),
);
// Run for 2 minutes
jasmine.clock().tick(2 * 60 * 1000);
state = focusModeReducer(state, actions.tick());
const elapsedBeforePause = state.timer.elapsed;
expect(elapsedBeforePause).toBeGreaterThanOrEqual(2 * 60 * 1000);
// Pause
state = focusModeReducer(state, actions.pauseFocusSession({}));
expect(state.timer.isRunning).toBe(false);
// Time passes while paused (should not count)
jasmine.clock().tick(5 * 60 * 1000);
// Resume - should adjust startedAt to account for elapsed time
state = focusModeReducer(state, actions.unPauseFocusSession());
expect(state.timer.isRunning).toBe(true);
// Tick after resume
jasmine.clock().tick(1000);
state = focusModeReducer(state, actions.tick());
// Elapsed should be approximately elapsedBeforePause + 1 second
// Not elapsedBeforePause + 5 minutes + 1 second
const twoMinutes = 2 * 60 * 1000;
expect(state.timer.elapsed).toBeLessThan(elapsedBeforePause + twoMinutes);
});
});
});

View file

@ -404,6 +404,26 @@ describe('FocusModeEffects', () => {
});
});
it('should NOT dispatch startBreak when isManualBreakStart is enabled', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: false,
isSkipPreparation: false,
isManualBreakStart: true,
});
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeUndefined();
done();
});
});
it('should dispatch correct isLongBreak based on cycle', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);

View file

@ -283,7 +283,12 @@ 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) {
// Also skip if manual break start is enabled (user must click "Start Break")
const shouldAutoStartBreak =
!action.isManual &&
strategy.shouldStartBreakAfterSession &&
!focusModeConfig?.isManualBreakStart;
if (shouldAutoStartBreak) {
// Pause task tracking during break if enabled
const shouldPauseTracking =
focusModeConfig?.isPauseTrackingDuringBreak && currentTaskId;
@ -720,15 +725,18 @@ export class FocusModeEffects {
])
.pipe(take(1))
.subscribe(([mode, pausedTaskId]) => {
const strategy = this.strategyFactory.getStrategy(mode);
// Skip break (with pausedTaskId to resume tracking)
this.store.dispatch(actions.skipBreak({ pausedTaskId }));
// Then start new session
const strategy = this.strategyFactory.getStrategy(mode);
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
// Only manually start session if strategy doesn't auto-start
// (Pomodoro auto-starts via skipBreak$ effect)
if (!strategy.shouldAutoStartNextSession) {
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
}
});
} else {
// Start a new session using the current mode's strategy
@ -763,10 +771,22 @@ export class FocusModeEffects {
};
// 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
// Hide when session is completed or break time is up (Start button takes priority)
const endAction = shouldShowStartButton
? undefined
: isOnBreak
? {
label: T.F.FOCUS_MODE.SKIP_BREAK,
icon: 'skip_next',
fn: () => {
this.store
.select(selectors.selectPausedTaskId)
.pipe(take(1))
.subscribe((pausedTaskId) => {
this.store.dispatch(actions.skipBreak({ pausedTaskId }));
});
},
}
: {
label: T.F.FOCUS_MODE.B.END_SESSION,
icon: 'done_all',

View file

@ -24,7 +24,7 @@
cdkTextareaAutosize
#autosize="cdkTextareaAutosize"
[placeholder]="placeholder()"
[ngModel]="reflectionText()"
[ngModel]="inputText()"
(ngModelChange)="onReflectionChange($event)"
cdkAutosizeMinRows="3"
cdkAutosizeMaxRows="5"

View file

@ -0,0 +1,429 @@
import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { EnvironmentInjector, runInInjectionContext } from '@angular/core';
import { of, Subject, BehaviorSubject } from 'rxjs';
import { ReflectionNoteComponent } from './reflection-note.component';
import { MetricService } from '../metric.service';
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_METRIC_FOR_DAY } from '../metric.const';
import { Metric } from '../metric.model';
describe('ReflectionNoteComponent', () => {
let component: ReflectionNoteComponent;
let mockMetricService: jasmine.SpyObj<MetricService>;
let mockGlobalTrackingIntervalService: { todayDateStr: () => string };
let mockTranslateService: {
onLangChange: Subject<LangChangeEvent>;
instant: jasmine.Spy;
};
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let environmentInjector: EnvironmentInjector;
let metricForDay$: BehaviorSubject<Metric>;
const TODAY = '2025-01-15';
beforeEach(() => {
metricForDay$ = new BehaviorSubject<Metric>({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [],
} as Metric);
mockMetricService = jasmine.createSpyObj('MetricService', [
'getMetricForDay$',
'getAllMetrics$',
'upsertMetric',
]);
mockMetricService.getMetricForDay$.and.returnValue(metricForDay$.asObservable());
mockMetricService.getAllMetrics$.and.returnValue(of([]));
mockGlobalTrackingIntervalService = {
todayDateStr: () => TODAY,
};
mockTranslateService = {
onLangChange: new Subject<LangChangeEvent>(),
instant: jasmine.createSpy('instant').and.returnValue('placeholder text'),
};
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
TestBed.configureTestingModule({
providers: [
{ provide: MetricService, useValue: mockMetricService },
{
provide: GlobalTrackingIntervalService,
useValue: mockGlobalTrackingIntervalService,
},
{ provide: TranslateService, useValue: mockTranslateService },
{ provide: MatDialog, useValue: mockMatDialog },
],
});
environmentInjector = TestBed.inject(EnvironmentInjector);
});
const createComponent = (): void => {
runInInjectionContext(environmentInjector, () => {
component = new ReflectionNoteComponent();
});
};
it('should create', () => {
createComponent();
expect(component).toBeTruthy();
});
describe('trailing space bug fix', () => {
it('should preserve trailing spaces in inputText while typing', fakeAsync(() => {
createComponent();
tick(); // Allow initial effect to run
// User types "Hello "
component.onReflectionChange('Hello ');
tick();
// inputText should preserve the trailing space
expect(component.inputText()).toBe('Hello ');
}));
it('should NOT overwrite trailing spaces after debounce', fakeAsync(() => {
createComponent();
tick();
// User types "Hello "
component.onReflectionChange('Hello ');
expect(component.inputText()).toBe('Hello ');
// Wait for debounce (500ms)
tick(500);
// Simulate store update with trimmed value
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Hello', created: Date.now() }],
} as Metric);
tick();
// inputText should still preserve the user's trailing space
// because trimmed stored value matches trimmed user input
expect(component.inputText()).toBe('Hello ');
flush();
}));
it('should persist trimmed value to store', fakeAsync(() => {
createComponent();
tick();
// User types "Hello "
component.onReflectionChange('Hello ');
// Wait for debounce
tick(500);
// Verify upsertMetric was called with trimmed text
expect(mockMetricService.upsertMetric).toHaveBeenCalled();
const calledWith = mockMetricService.upsertMetric.calls.mostRecent().args[0];
expect(calledWith.reflections?.[0]?.text).toBe('Hello');
flush();
}));
it('should update inputText when external change differs from user input', fakeAsync(() => {
createComponent();
tick();
// User types "Hello"
component.onReflectionChange('Hello');
tick(500);
// External sync updates with completely different text
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Different text from sync', created: Date.now() }],
} as Metric);
tick();
// inputText should update because stored text differs from user input
expect(component.inputText()).toBe('Different text from sync');
flush();
}));
it('should update inputText when day changes', fakeAsync(() => {
createComponent();
tick();
// User types something for today
component.onReflectionChange('Today note');
tick(500);
// Simulate day change - getMetricForDay$ now returns different day's data
metricForDay$.next({
id: '2025-01-16',
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Different day note', created: Date.now() }],
} as Metric);
tick();
// inputText should update to show the new day's note
expect(component.inputText()).toBe('Different day note');
flush();
}));
it('should handle empty input correctly', fakeAsync(() => {
createComponent();
tick();
// Start with some text
component.onReflectionChange('Hello');
tick(500);
// Clear the input
component.onReflectionChange('');
tick(500);
// Should call upsertMetric with empty reflections array
const calledWith = mockMetricService.upsertMetric.calls.mostRecent().args[0];
expect(calledWith.reflections).toEqual([]);
flush();
}));
it('should handle null/undefined input', fakeAsync(() => {
createComponent();
tick();
// Pass null (via type casting for test purposes)
component.onReflectionChange(null as unknown as string);
expect(component.inputText()).toBe('');
flush();
}));
it('should preserve multiple trailing spaces', fakeAsync(() => {
createComponent();
tick();
// User types with multiple trailing spaces
component.onReflectionChange('Hello ');
tick();
expect(component.inputText()).toBe('Hello ');
tick(500);
// Store gets trimmed value
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Hello', created: Date.now() }],
} as Metric);
tick();
// User's multiple trailing spaces should be preserved
expect(component.inputText()).toBe('Hello ');
flush();
}));
it('should preserve leading and trailing spaces while typing', fakeAsync(() => {
createComponent();
tick();
// User types with leading and trailing spaces
component.onReflectionChange(' Hello ');
tick();
expect(component.inputText()).toBe(' Hello ');
flush();
}));
it('should handle rapid typing that resets debounce', fakeAsync(() => {
createComponent();
tick();
// User types quickly, each keystroke resets debounce
component.onReflectionChange('H');
tick(100);
component.onReflectionChange('He');
tick(100);
component.onReflectionChange('Hel');
tick(100);
component.onReflectionChange('Hell');
tick(100);
component.onReflectionChange('Hello');
tick(100);
component.onReflectionChange('Hello '); // trailing space
// Not yet 500ms since last change
expect(mockMetricService.upsertMetric).not.toHaveBeenCalled();
expect(component.inputText()).toBe('Hello ');
// Wait for debounce to complete
tick(500);
// Now it should persist
expect(mockMetricService.upsertMetric).toHaveBeenCalledTimes(1);
const calledWith = mockMetricService.upsertMetric.calls.mostRecent().args[0];
expect(calledWith.reflections?.[0]?.text).toBe('Hello');
flush();
}));
it('should allow continued typing after debounce + store update', fakeAsync(() => {
createComponent();
tick();
// First typing session
component.onReflectionChange('Hello ');
tick(500);
// Store updates with trimmed value
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Hello', created: Date.now() }],
} as Metric);
tick();
// User continues typing (adding " world")
component.onReflectionChange('Hello world ');
tick();
// Should preserve new input with trailing space
expect(component.inputText()).toBe('Hello world ');
// Wait for second debounce
tick(500);
// Store updates again
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Hello world', created: Date.now() }],
} as Metric);
tick();
// Should still preserve trailing space
expect(component.inputText()).toBe('Hello world ');
flush();
}));
it('should handle whitespace-only input', fakeAsync(() => {
createComponent();
tick();
// User types only spaces
component.onReflectionChange(' ');
tick();
expect(component.inputText()).toBe(' ');
// Wait for debounce
tick(500);
// Trimmed value is empty, so reflections should be empty array
const calledWith = mockMetricService.upsertMetric.calls.mostRecent().args[0];
expect(calledWith.reflections).toEqual([]);
flush();
}));
it('should handle store update while user is actively typing', fakeAsync(() => {
createComponent();
tick();
// User starts typing
component.onReflectionChange('My note ');
tick(200); // Part way through debounce
// Unexpected store update (maybe from another source)
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'My note', created: Date.now() }],
} as Metric);
tick();
// User's input should be preserved (trimmed values match)
expect(component.inputText()).toBe('My note ');
// User continues typing before debounce completes
component.onReflectionChange('My note is ');
tick(300); // Complete the debounce from first input
tick(500); // Complete debounce from second input
expect(component.inputText()).toBe('My note is ');
flush();
}));
it('should correctly detect genuinely different external updates', fakeAsync(() => {
createComponent();
tick();
// User types something
component.onReflectionChange('Original text ');
tick(500);
// Simulate store reflecting the trimmed save
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Original text', created: Date.now() }],
} as Metric);
tick();
// User's trailing space preserved
expect(component.inputText()).toBe('Original text ');
// Now external sync brings completely different content
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Synced from another device', created: Date.now() }],
} as Metric);
tick();
// This IS a genuine external update - should override
expect(component.inputText()).toBe('Synced from another device');
flush();
}));
});
describe('initial load', () => {
it('should load existing reflection text on init', fakeAsync(() => {
// Set up existing reflection before component creation
metricForDay$.next({
id: TODAY,
...DEFAULT_METRIC_FOR_DAY,
reflections: [{ text: 'Existing note', created: Date.now() }],
} as Metric);
createComponent();
tick();
expect(component.inputText()).toBe('Existing note');
flush();
}));
it('should start with empty input when no reflection exists', fakeAsync(() => {
createComponent();
tick();
expect(component.inputText()).toBe('');
flush();
}));
});
});

View file

@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
@ -75,11 +76,15 @@ export class ReflectionNoteComponent {
{ initialValue: { id: '', ...DEFAULT_METRIC_FOR_DAY } as MetricCopy },
);
readonly reflectionText = computed(() => {
private readonly _storedText = computed(() => {
return this._metricForDay()?.reflections?.[0]?.text ?? '';
});
// Local input text for textarea binding - prevents trimmed store value from overwriting user input
readonly inputText = signal('');
private readonly _reflectionChanges$ = new Subject<string>();
private _lastUserInput = '';
constructor() {
this._reflectionChanges$
@ -91,10 +96,28 @@ export class ReflectionNoteComponent {
this._translate.onLangChange
.pipe(takeUntilDestroyed())
.subscribe(() => this.placeholder.set(this._pickRandomPlaceholder()));
// Sync inputText from store only when:
// 1. Day changes (navigation)
// 2. External update that differs from user's input (e.g., sync)
effect(() => {
const storedText = this._storedText();
const userInputTrimmed = this._lastUserInput.trim();
// Only update if stored text differs from what user typed (after trim comparison)
// This prevents the store's trimmed value from overwriting user's trailing spaces
if (storedText !== userInputTrimmed) {
this.inputText.set(storedText);
this._lastUserInput = storedText;
}
});
}
onReflectionChange(value: string): void {
this._reflectionChanges$.next(value ?? '');
const v = value ?? '';
this._lastUserInput = v;
this.inputText.set(v);
this._reflectionChanges$.next(v);
}
async openHistory(): Promise<void> {

View file

@ -470,6 +470,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
break;
case 4:
const nextMonth = tDate;
nextMonth.setDate(1);
nextMonth.setMonth(nextMonth.getMonth() + 1);
this.selectedDate = nextMonth;
break;

View file

@ -80,7 +80,7 @@
}
<button
[disabled]="!formGroup1().valid || !formGroup2().valid"
[disabled]="!formGroup1().valid || !formGroup2().valid || isLoading()"
color="primary"
mat-stroked-button
type="submit"

View file

@ -1,25 +1,262 @@
// import {async, ComponentFixture, TestBed} from '@angular/core/testing';
//
// import {DialogEditTaskRepeatCfgComponent} from './dialog-edit-task-repeat-cfg.component';
//
// describe('DialogEditTaskRepeatCfgComponent', () => {
// let component: DialogEditTaskRepeatCfgComponent;
// let fixture: ComponentFixture<DialogEditTaskRepeatCfgComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [DialogEditTaskRepeatCfgComponent]
// })
// .compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { provideMockStore } from '@ngrx/store/testing';
import { Observable, of, Subject } from 'rxjs';
import { ReactiveFormsModule } from '@angular/forms';
import { FormlyConfigModule } from '../../../ui/formly-config.module';
import { DialogEditTaskRepeatCfgComponent } from './dialog-edit-task-repeat-cfg.component';
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
import { TagService } from '../../tag/tag.service';
import { GlobalConfigService } from '../../config/global-config.service';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
import { TaskCopy } from '../../tasks/task.model';
describe('DialogEditTaskRepeatCfgComponent', () => {
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
let mockTaskRepeatCfgService: jasmine.SpyObj<TaskRepeatCfgService>;
let mockTagService: jasmine.SpyObj<TagService>;
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
let mockDateTimeFormatService: jasmine.SpyObj<DateTimeFormatService>;
const mockRepeatCfg: TaskRepeatCfg = {
...DEFAULT_TASK_REPEAT_CFG,
id: 'repeat-cfg-123',
title: 'Test Repeat Task',
startDate: '2026-01-02',
};
const mockTask = {
id: 'task-123',
title: 'Test Task',
projectId: 'project-123',
tagIds: [],
subTaskIds: [],
timeSpentOnDay: {},
timeSpent: 0,
timeEstimate: 0,
isDone: false,
notes: '',
created: Date.now(),
attachmentIds: [],
attachments: [],
} as unknown as TaskCopy;
const setupTestBed = async (
dialogData: {
task?: TaskCopy;
repeatCfg?: TaskRepeatCfg;
targetDate?: string;
},
getTaskRepeatCfgById$ReturnValue?: Observable<TaskRepeatCfg> | Subject<TaskRepeatCfg>,
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockTaskRepeatCfgService = jasmine.createSpyObj('TaskRepeatCfgService', [
'getTaskRepeatCfgById$',
'updateTaskRepeatCfg',
'addTaskRepeatCfgToTask',
'deleteTaskRepeatCfgWithDialog',
]);
// Set up the return value for getTaskRepeatCfgById$ before creating the component
if (getTaskRepeatCfgById$ReturnValue) {
mockTaskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(
getTaskRepeatCfgById$ReturnValue,
);
}
mockTagService = jasmine.createSpyObj('TagService', ['addTag'], {
tags$: of([]),
});
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
cfg: () => ({ reminder: { defaultTaskRemindOption: null } }),
});
mockDateTimeFormatService = jasmine.createSpyObj('DateTimeFormatService', [], {
currentLocale: 'en-US',
});
await TestBed.configureTestingModule({
imports: [
DialogEditTaskRepeatCfgComponent,
MatDialogModule,
NoopAnimationsModule,
TranslateModule.forRoot(),
FormlyConfigModule,
ReactiveFormsModule,
],
schemas: [NO_ERRORS_SCHEMA],
providers: [
provideMockStore(),
{ provide: MatDialogRef, useValue: mockDialogRef },
{ provide: MAT_DIALOG_DATA, useValue: dialogData },
{ provide: TaskRepeatCfgService, useValue: mockTaskRepeatCfgService },
{ provide: TagService, useValue: mockTagService },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
],
}).compileComponents();
return TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
};
afterEach(() => {
TestBed.resetTestingModule();
});
describe('isLoading signal', () => {
it('should be false when repeatCfg is provided directly (sync path)', async () => {
const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg });
const component = fixture.componentInstance;
expect(component.isLoading()).toBe(false);
});
it('should be false when creating new repeat config for task without repeatCfgId', async () => {
const fixture = await setupTestBed({ task: mockTask });
const component = fixture.componentInstance;
expect(component.isLoading()).toBe(false);
});
it('should be true while loading existing repeat config for task with repeatCfgId', fakeAsync(async () => {
const taskWithRepeatCfg = {
...mockTask,
repeatCfgId: 'repeat-cfg-123',
} as TaskCopy;
const repeatCfgSubject = new Subject<TaskRepeatCfg>();
const fixture = await setupTestBed({ task: taskWithRepeatCfg }, repeatCfgSubject);
const component = fixture.componentInstance;
fixture.detectChanges();
tick();
// Should be loading while waiting for async response
expect(component.isLoading()).toBe(true);
// Emit the repeat config
repeatCfgSubject.next(mockRepeatCfg);
tick();
// Should no longer be loading after response
expect(component.isLoading()).toBe(false);
}));
it('should set repeatCfgInitial after async load completes', fakeAsync(async () => {
const taskWithRepeatCfg = {
...mockTask,
repeatCfgId: 'repeat-cfg-123',
} as TaskCopy;
const repeatCfgSubject = new Subject<TaskRepeatCfg>();
const fixture = await setupTestBed({ task: taskWithRepeatCfg }, repeatCfgSubject);
const component = fixture.componentInstance;
fixture.detectChanges();
tick();
// repeatCfgInitial should be undefined while loading
expect(component.repeatCfgInitial()).toBeUndefined();
// Emit the repeat config
repeatCfgSubject.next(mockRepeatCfg);
tick();
// repeatCfgInitial should now be set
expect(component.repeatCfgInitial()).toBeDefined();
expect(component.repeatCfgInitial()?.id).toBe('repeat-cfg-123');
}));
});
describe('isEdit computed', () => {
it('should return true when repeatCfg is provided', async () => {
const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg });
const component = fixture.componentInstance;
expect(component.isEdit()).toBe(true);
});
it('should return true when task has repeatCfgId', fakeAsync(async () => {
const taskWithRepeatCfg = {
...mockTask,
repeatCfgId: 'repeat-cfg-123',
} as TaskCopy;
const fixture = await setupTestBed({ task: taskWithRepeatCfg }, of(mockRepeatCfg));
const component = fixture.componentInstance;
fixture.detectChanges();
tick();
expect(component.isEdit()).toBe(true);
}));
it('should return false when task has no repeatCfgId (create mode)', async () => {
const fixture = await setupTestBed({ task: mockTask });
const component = fixture.componentInstance;
expect(component.isEdit()).toBe(false);
});
});
describe('save button disabled state (issue #5828)', () => {
it('should not allow save while isLoading is true', fakeAsync(async () => {
const taskWithRepeatCfg = {
...mockTask,
repeatCfgId: 'repeat-cfg-123',
} as TaskCopy;
const repeatCfgSubject = new Subject<TaskRepeatCfg>();
const fixture = await setupTestBed({ task: taskWithRepeatCfg }, repeatCfgSubject);
const component = fixture.componentInstance;
fixture.detectChanges();
tick();
// While loading, isLoading should be true
expect(component.isLoading()).toBe(true);
// Attempting to save while loading would have thrown the error before the fix
// After the fix, the button should be disabled so save() won't be called
// We verify the condition that disables the button
const formValid = component.formGroup1().valid && component.formGroup2().valid;
const saveButtonShouldBeDisabled = !formValid || component.isLoading();
expect(saveButtonShouldBeDisabled).toBe(true);
// Complete loading
repeatCfgSubject.next(mockRepeatCfg);
tick();
// Now isLoading should be false
expect(component.isLoading()).toBe(false);
}));
it('should have repeatCfgInitial set before save can proceed in edit mode', fakeAsync(async () => {
const taskWithRepeatCfg = {
...mockTask,
repeatCfgId: 'repeat-cfg-123',
} as TaskCopy;
const repeatCfgSubject = new Subject<TaskRepeatCfg>();
const fixture = await setupTestBed({ task: taskWithRepeatCfg }, repeatCfgSubject);
const component = fixture.componentInstance;
fixture.detectChanges();
tick();
// Before async completes: isLoading=true, repeatCfgInitial=undefined
expect(component.isLoading()).toBe(true);
expect(component.repeatCfgInitial()).toBeUndefined();
// After async completes: isLoading=false, repeatCfgInitial is set
repeatCfgSubject.next(mockRepeatCfg);
tick();
expect(component.isLoading()).toBe(false);
expect(component.repeatCfgInitial()).toBeDefined();
// This was the race condition: save() requires repeatCfgInitial in edit mode
// Now the button is disabled until isLoading becomes false,
// which only happens after repeatCfgInitial is set
}));
});
});

View file

@ -88,6 +88,7 @@ export class DialogEditTaskRepeatCfgComponent {
repeatCfg = signal<Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg>(
this._initializeRepeatCfg(),
);
isLoading = signal<boolean>(false);
isEdit = computed(() => {
if (this._data.repeatCfg) return true;
if (this._data.task?.repeatCfgId) return true;
@ -126,12 +127,14 @@ export class DialogEditTaskRepeatCfgComponent {
// Set up effect to load task repeat config if editing
effect(() => {
if (this.isEdit() && this._data.task?.repeatCfgId) {
this.isLoading.set(true);
this._taskRepeatCfgService
.getTaskRepeatCfgById$(this._data.task.repeatCfgId)
.pipe(first())
.subscribe((cfg) => {
this._setRepeatCfgInitiallyForEditOnly(cfg);
this._checkCanRemoveInstance();
this.isLoading.set(false);
});
}
this._checkCanRemoveInstance();
@ -240,8 +243,13 @@ export class DialogEditTaskRepeatCfgComponent {
// workaround for formly not always updating hidden fields correctly (in time??)
if (currentRepeatCfg.quickSetting !== 'CUSTOM') {
// Pass startDate to use correct weekday for WEEKLY_CURRENT_WEEKDAY (fixes #5806)
const referenceDate = currentRepeatCfg.startDate
? dateStrToUtcDate(currentRepeatCfg.startDate)
: undefined;
const updatesForQuickSetting = getQuickSettingUpdates(
currentRepeatCfg.quickSetting,
referenceDate,
);
if (updatesForQuickSetting) {
this.repeatCfg.update((cfg) => ({ ...cfg, ...updatesForQuickSetting }));
@ -358,7 +366,8 @@ export class DialogEditTaskRepeatCfgComponent {
if (processedCfg.quickSetting === 'WEEKLY_CURRENT_WEEKDAY') {
if (!processedCfg.startDate) {
throw new Error('Invalid repeat cfg');
// Gracefully fall back to CUSTOM when data is incomplete
return { ...processedCfg, quickSetting: 'CUSTOM' } as T;
}
if (new Date(processedCfg.startDate).getDay() !== new Date().getDay()) {
processedCfg = { ...processedCfg, quickSetting: 'CUSTOM' };
@ -366,7 +375,8 @@ export class DialogEditTaskRepeatCfgComponent {
}
if (processedCfg.quickSetting === 'YEARLY_CURRENT_DATE') {
if (!processedCfg.startDate) {
throw new Error('Invalid repeat cfg');
// Gracefully fall back to CUSTOM when data is incomplete
return { ...processedCfg, quickSetting: 'CUSTOM' } as T;
}
if (
new Date(processedCfg.startDate).getDate() !== new Date().getDate() ||
@ -377,7 +387,8 @@ export class DialogEditTaskRepeatCfgComponent {
}
if (processedCfg.quickSetting === 'MONTHLY_CURRENT_DATE') {
if (!processedCfg.startDate) {
throw new Error('Invalid repeat cfg');
// Gracefully fall back to CUSTOM when data is incomplete
return { ...processedCfg, quickSetting: 'CUSTOM' } as T;
}
if (new Date(processedCfg.startDate).getDate() !== new Date().getDate()) {
processedCfg = { ...processedCfg, quickSetting: 'CUSTOM' };

View file

@ -28,7 +28,7 @@ describe('getQuickSettingUpdates', () => {
expect(result!.startDate).toBeUndefined();
});
it('should set only today weekday to true', () => {
it('should set only today weekday to true when no referenceDate provided', () => {
const result = getQuickSettingUpdates('WEEKLY_CURRENT_WEEKDAY');
const weekdays = [
'sunday',
@ -49,6 +49,49 @@ describe('getQuickSettingUpdates', () => {
}
});
});
// Issue #5806: Use referenceDate weekday when provided
it('should set Sunday to true when referenceDate is a Sunday (fixes #5806)', () => {
// Sunday Dec 28, 2025
const sunday = new Date(2025, 11, 28);
const result = getQuickSettingUpdates('WEEKLY_CURRENT_WEEKDAY', sunday);
expect(result).toBeDefined();
expect((result as any).sunday).toBe(true);
expect((result as any).monday).toBe(false);
expect((result as any).tuesday).toBe(false);
expect((result as any).wednesday).toBe(false);
expect((result as any).thursday).toBe(false);
expect((result as any).friday).toBe(false);
expect((result as any).saturday).toBe(false);
});
it('should set Friday to true when referenceDate is a Friday (fixes #5806)', () => {
// Friday Dec 26, 2025
const friday = new Date(2025, 11, 26);
const result = getQuickSettingUpdates('WEEKLY_CURRENT_WEEKDAY', friday);
expect(result).toBeDefined();
expect((result as any).sunday).toBe(false);
expect((result as any).monday).toBe(false);
expect((result as any).tuesday).toBe(false);
expect((result as any).wednesday).toBe(false);
expect((result as any).thursday).toBe(false);
expect((result as any).friday).toBe(true);
expect((result as any).saturday).toBe(false);
});
it('should set Wednesday to true when referenceDate is a Wednesday (fixes #5806)', () => {
// Wednesday Dec 31, 2025
const wednesday = new Date(2025, 11, 31);
const result = getQuickSettingUpdates('WEEKLY_CURRENT_WEEKDAY', wednesday);
expect(result).toBeDefined();
expect((result as any).sunday).toBe(false);
expect((result as any).monday).toBe(false);
expect((result as any).tuesday).toBe(false);
expect((result as any).wednesday).toBe(true);
expect((result as any).thursday).toBe(false);
expect((result as any).friday).toBe(false);
expect((result as any).saturday).toBe(false);
});
});
describe('MONDAY_TO_FRIDAY', () => {

View file

@ -4,8 +4,15 @@ import {
TaskRepeatCfg,
} from '../task-repeat-cfg.model';
/**
* Returns partial TaskRepeatCfg updates based on the quick setting.
* @param quickSetting The quick setting to apply
* @param referenceDate Optional date to use for weekday calculation (fixes #5806).
* If not provided, uses current date.
*/
export const getQuickSettingUpdates = (
quickSetting: RepeatQuickSetting,
referenceDate?: Date,
): Partial<TaskRepeatCfg> | undefined => {
switch (quickSetting) {
case 'DAILY': {
@ -16,7 +23,8 @@ export const getQuickSettingUpdates = (
}
case 'WEEKLY_CURRENT_WEEKDAY': {
const todayWeekdayStr = TASK_REPEAT_WEEKDAY_MAP[new Date().getDay()];
const dateToUse = referenceDate || new Date();
const weekdayStr = TASK_REPEAT_WEEKDAY_MAP[dateToUse.getDay()];
return {
repeatCycle: 'WEEKLY',
repeatEvery: 1,
@ -27,7 +35,7 @@ export const getQuickSettingUpdates = (
friday: false,
saturday: false,
sunday: false,
[todayWeekdayStr as keyof TaskRepeatCfg]: true,
[weekdayStr as keyof TaskRepeatCfg]: true,
};
}

View file

@ -949,6 +949,120 @@ describe('selectTaskRepeatCfgsSortedByTitleAndProject', () => {
});
});
// Issue #5806: Planner shows less scheduled tasks than scheduled/planned view
describe('Issue #5806 - Weekly tasks with correct weekday show in planner', () => {
it('should show task on Sunday when sunday=true and checking Sunday', () => {
// This is the CORRECT scenario after the fix
const sunday = dateStrToUtcDate('2025-12-28'); // Sunday
const result = selectTaskRepeatCfgsForExactDay.projector(
[
dummyRepeatable('R1', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26', // Friday (when task was created)
lastTaskCreationDay: '1970-01-01',
sunday: true, // Correctly set to Sunday (the scheduled day)
}),
],
{ dayDate: sunday.getTime() },
);
expect(result.map((r) => r.id)).toEqual(['R1']);
});
it('should NOT show task on Sunday when only friday=true (the bug scenario)', () => {
// This demonstrates the bug: task created on Friday with WEEKLY_CURRENT_WEEKDAY
// would set friday=true instead of sunday=true
const sunday = dateStrToUtcDate('2025-12-28'); // Sunday
const result = selectTaskRepeatCfgsForExactDay.projector(
[
dummyRepeatable('R1', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26', // Friday
lastTaskCreationDay: '1970-01-01',
friday: true, // Wrong! Should be sunday=true
sunday: false,
}),
],
{ dayDate: sunday.getTime() },
);
// Task does NOT show on Sunday because sunday=false
expect(result.map((r) => r.id)).toEqual([]);
});
it('should show multiple tasks when all have correct weekday set', () => {
// Simulates 4 tasks all scheduled for Sunday with sunday=true
const sunday = dateStrToUtcDate('2025-12-28');
const result = selectTaskRepeatCfgsForExactDay.projector(
[
dummyRepeatable('R1', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true,
}),
dummyRepeatable('R2', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true,
}),
dummyRepeatable('R3', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true,
}),
dummyRepeatable('R4', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true,
}),
],
{ dayDate: sunday.getTime() },
);
// All 4 tasks should show
expect(result.map((r) => r.id)).toEqual(['R1', 'R2', 'R3', 'R4']);
});
it('should show only tasks with correct weekday (mixed scenario)', () => {
// 2 with sunday=true, 2 with friday=true (wrong)
const sunday = dateStrToUtcDate('2025-12-28');
const result = selectTaskRepeatCfgsForExactDay.projector(
[
dummyRepeatable('R1', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true, // Correct
}),
dummyRepeatable('R2', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
friday: true, // Wrong
sunday: false,
}),
dummyRepeatable('R3', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
sunday: true, // Correct
}),
dummyRepeatable('R4', {
repeatCycle: 'WEEKLY',
startDate: '2025-12-26',
lastTaskCreationDay: '1970-01-01',
friday: true, // Wrong
sunday: false,
}),
],
{ dayDate: sunday.getTime() },
);
// Only 2 tasks with sunday=true should show (explains "2 of 4" from bug report)
expect(result.map((r) => r.id)).toEqual(['R1', 'R3']);
});
});
describe('Timezone Edge Cases for selectTaskRepeatCfgsForExactDay', () => {
const createTaskRepeatCfg = (id: string, lastDay: string): TaskRepeatCfg => ({
...DEFAULT_TASK_REPEAT_CFG,

View file

@ -1,25 +1,141 @@
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
//
// import { DialogViewTaskReminderComponent } from './dialog-view-task-reminder.component';
//
// describe('DialogViewTaskReminderComponent', () => {
// let component: DialogViewTaskReminderComponent;
// let fixture: ComponentFixture<DialogViewTaskReminderComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [DialogViewTaskReminderComponent]
// })
// .compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(DialogViewTaskReminderComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });
import { BehaviorSubject, Subject } from 'rxjs';
import { Reminder } from '../../reminder/reminder.model';
/**
* Tests for the dismissed reminder tracking logic in DialogViewTaskRemindersComponent.
*
* These tests verify that dismissed reminders are tracked and filtered out when
* the worker sends stale data, preventing the race condition described in issue #5826.
*
* The tests focus on the core filtering logic without needing full component rendering.
*/
describe('DialogViewTaskRemindersComponent dismissed reminder tracking', () => {
// Simulate the component's internal state
let reminders$: BehaviorSubject<Reminder[]>;
let dismissedReminderIds: Set<string>;
let onRemindersActiveSubject: Subject<Reminder[]>;
const createMockReminder = (id: string, relatedId: string): Reminder => ({
id,
relatedId,
title: `Task ${id}`,
remindAt: Date.now() - 1000,
type: 'TASK',
});
// Simulate the component's _removeReminderFromList method
const removeReminderFromList = (reminderId: string): void => {
dismissedReminderIds.add(reminderId);
const newReminders = reminders$.getValue().filter((r) => r.id !== reminderId);
reminders$.next(newReminders);
};
// Simulate the component's onRemindersActive$ subscription handler
const handleRemindersActive = (reminders: Reminder[]): void => {
const filtered = reminders.filter((r) => !dismissedReminderIds.has(r.id));
if (filtered.length > 0) {
reminders$.next(filtered);
}
};
beforeEach(() => {
const initialReminders = [
createMockReminder('reminder-1', 'task-1'),
createMockReminder('reminder-2', 'task-2'),
];
reminders$ = new BehaviorSubject<Reminder[]>(initialReminders);
dismissedReminderIds = new Set<string>();
onRemindersActiveSubject = new Subject<Reminder[]>();
// Set up the subscription like the component does
onRemindersActiveSubject.subscribe(handleRemindersActive);
});
it('should track dismissed reminder IDs when removing from list', () => {
expect(reminders$.getValue().length).toBe(2);
removeReminderFromList('reminder-1');
expect(dismissedReminderIds.has('reminder-1')).toBe(true);
expect(reminders$.getValue().length).toBe(1);
expect(reminders$.getValue().find((r) => r.id === 'reminder-1')).toBeUndefined();
});
it('should filter out dismissed reminders when worker sends stale data', () => {
// Dismiss a reminder
removeReminderFromList('reminder-1');
expect(reminders$.getValue().length).toBe(1);
// Simulate worker sending stale data that includes the dismissed reminder
const staleReminders = [
createMockReminder('reminder-1', 'task-1'), // This was dismissed
createMockReminder('reminder-2', 'task-2'),
createMockReminder('reminder-3', 'task-3'), // New reminder
];
onRemindersActiveSubject.next(staleReminders);
// The dismissed reminder should be filtered out
const currentReminders = reminders$.getValue();
expect(currentReminders.find((r) => r.id === 'reminder-1')).toBeUndefined();
expect(currentReminders.find((r) => r.id === 'reminder-2')).toBeDefined();
expect(currentReminders.find((r) => r.id === 'reminder-3')).toBeDefined();
});
it('should track multiple dismissed reminders', () => {
// Dismiss both reminders
removeReminderFromList('reminder-1');
removeReminderFromList('reminder-2');
expect(dismissedReminderIds.has('reminder-1')).toBe(true);
expect(dismissedReminderIds.has('reminder-2')).toBe(true);
// Simulate worker sending stale data
const staleReminders = [
createMockReminder('reminder-1', 'task-1'),
createMockReminder('reminder-2', 'task-2'),
];
onRemindersActiveSubject.next(staleReminders);
// Both should be filtered out, leaving empty array
// Note: In the actual component, this would close the dialog
// Here we just verify the filtering works
const currentReminders = reminders$.getValue();
expect(currentReminders.length).toBe(0);
});
it('should allow new reminders that were not dismissed', () => {
// Dismiss reminder-1
removeReminderFromList('reminder-1');
// Worker sends completely new reminders
const newReminders = [
createMockReminder('reminder-3', 'task-3'),
createMockReminder('reminder-4', 'task-4'),
];
onRemindersActiveSubject.next(newReminders);
// New reminders should be accepted
const currentReminders = reminders$.getValue();
expect(currentReminders.length).toBe(2);
expect(currentReminders.find((r) => r.id === 'reminder-3')).toBeDefined();
expect(currentReminders.find((r) => r.id === 'reminder-4')).toBeDefined();
});
it('should not affect reminders that were never shown', () => {
// Don't dismiss any reminders, just receive new ones
const newReminders = [
createMockReminder('reminder-5', 'task-5'),
createMockReminder('reminder-6', 'task-6'),
];
onRemindersActiveSubject.next(newReminders);
const currentReminders = reminders$.getValue();
expect(currentReminders.length).toBe(2);
expect(currentReminders.find((r) => r.id === 'reminder-5')).toBeDefined();
expect(currentReminders.find((r) => r.id === 'reminder-6')).toBeDefined();
});
});

View file

@ -113,11 +113,19 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
overdueThreshold = Date.now() - 30 * 60 * 1000; // 30 minutes
private _subs: Subscription = new Subscription();
// Track dismissed reminder IDs to prevent stale data from worker re-triggering them
private _dismissedReminderIds = new Set<string>();
constructor() {
this._subs.add(
this._reminderService.onRemindersActive$.subscribe((reminders) => {
this.taskIds$.next(reminders.map((r) => r.id));
// Filter out reminders that were already dismissed in this dialog session
const filtered = reminders.filter((r) => !this._dismissedReminderIds.has(r.id));
if (filtered.length > 0) {
this.taskIds$.next(filtered.map((r) => r.id));
} else {
this._close();
}
}),
);
this._subs.add(
@ -324,6 +332,8 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
}
private _removeTaskFromList(taskId: string): void {
// Track dismissed ID to prevent stale data from worker re-adding it
this._dismissedReminderIds.add(taskId);
const newTaskIds = this.taskIds$.getValue().filter((id) => id !== taskId);
if (newTaskIds.length <= 0) {
this._close();

View file

@ -9,6 +9,9 @@ import { SnackService } from '../../../core/snack/snack.service';
import { TaskService } from '../task.service';
import { Store } from '@ngrx/store';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { androidInterface } from '../../android/android-interface';
import { generateNotificationId } from '../../android/android-notification-id.util';
@Injectable()
export class TaskReminderEffects {
@ -116,4 +119,77 @@ export class TaskReminderEffects {
),
{ dispatch: false },
);
// Cancel native Android reminders when tasks are deleted
cancelNativeRemindersOnDelete$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._localActions$.pipe(
ofType(TaskSharedActions.deleteTask),
tap(({ task }) => {
const deletedTaskIds = [task.id, ...task.subTaskIds];
deletedTaskIds.forEach((id) => {
try {
const notificationId = generateNotificationId(id);
androidInterface.cancelNativeReminder?.(notificationId);
} catch (e) {
console.error('Failed to cancel native reminder:', e);
}
});
}),
),
{ dispatch: false },
);
// Cancel native Android reminders when multiple tasks are deleted
cancelNativeRemindersOnBulkDelete$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._localActions$.pipe(
ofType(TaskSharedActions.deleteTasks),
tap(({ taskIds }) => {
taskIds.forEach((id) => {
try {
const notificationId = generateNotificationId(id);
androidInterface.cancelNativeReminder?.(notificationId);
} catch (e) {
console.error('Failed to cancel native reminder:', e);
}
});
}),
),
{ dispatch: false },
);
// Cancel native Android reminders when tasks are archived
cancelNativeRemindersOnArchive$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._localActions$.pipe(
ofType(TaskSharedActions.moveToArchive),
tap(({ tasks }) => {
tasks.forEach((task) => {
try {
const notificationId = generateNotificationId(task.id);
androidInterface.cancelNativeReminder?.(notificationId);
} catch (e) {
console.error('Failed to cancel native reminder:', e);
}
// Also cancel for subtasks
task.subTaskIds?.forEach((subId) => {
try {
const notificationId = generateNotificationId(subId);
androidInterface.cancelNativeReminder?.(notificationId);
} catch (e) {
console.error('Failed to cancel native reminder:', e);
}
});
});
}),
),
{ dispatch: false },
);
}

View file

@ -1,56 +1,47 @@
import { SoundConfig } from '../../config/global-config.model';
import { TaskLog } from '../../../core/log';
import { getAudioBuffer, getAudioContext } from '../../../util/audio-context';
const BASE = './assets/snd';
const PITCH_OFFSET = -400;
/**
* Plays the task completion sound with optional pitch variation.
* Uses a singleton AudioContext and caches audio buffers to prevent resource leaks.
*
* @param soundCfg - Sound configuration including volume and pitch settings
* @param nrOfDoneTasks - Number of completed tasks (affects pitch if enabled)
*/
export const playDoneSound = (soundCfg: SoundConfig, nrOfDoneTasks: number = 0): void => {
const speed = 1;
const BASE = './assets/snd';
const PITCH_OFFSET = -400;
const file = `${BASE}/${soundCfg.doneSound}`;
// const speed = 0.5;
// const a = new Audio('/assets/snd/done4.mp3');
// TaskLog.log(a);
// a.volume = .4;
// a.playbackRate = 1.5;
// (a as any).mozPreservesPitch = false;
// (a as any).webkitPreservesPitch = false;
// a.play();
TaskLog.log(file);
const pitchIncrement = nrOfDoneTasks * 50;
const pitchFactor = soundCfg.isIncreaseDoneSoundPitch
? // prettier-ignore
PITCH_OFFSET + (nrOfDoneTasks * 50)
? PITCH_OFFSET + pitchIncrement
: 0;
const audioCtx = new ((window as any).AudioContext ||
(window as any).webkitAudioContext)();
const source = audioCtx.createBufferSource();
const request = new XMLHttpRequest();
request.open('GET', file, true);
request.responseType = 'arraybuffer';
request.onload = () => {
const audioData = request.response;
audioCtx.decodeAudioData(
audioData,
(buffer: AudioBuffer) => {
source.buffer = buffer;
source.playbackRate.value = speed;
// source.detune.value = 100; // value in cents
source.detune.value = pitchFactor; // value in cents
getAudioBuffer(file)
.then((buffer) => {
const audioCtx = getAudioContext();
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.playbackRate.value = speed;
source.detune.value = pitchFactor;
if (soundCfg.volume !== 100) {
const gainNode = audioCtx.createGain();
gainNode.gain.value = soundCfg.volume / 100;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
} else {
source.connect(audioCtx.destination);
}
},
(e: DOMException) => {
throw new Error('Error with decoding audio data SP: ' + e.message);
},
);
};
request.send();
source.start(0);
if (soundCfg.volume !== 100) {
const gainNode = audioCtx.createGain();
gainNode.gain.value = soundCfg.volume / 100;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
} else {
source.connect(audioCtx.destination);
}
source.start(0);
})
.catch((e) => {
console.error('Error playing done sound:', e);
});
};

View file

@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { BehaviorSubject, Subject } from 'rxjs';
import { WorkContextEffects } from './work-context.effects';
import { setActiveWorkContext } from './work-context.actions';
@ -8,12 +8,14 @@ import { setSelectedTask } from '../../tasks/store/task.actions';
import { TaskService } from '../../tasks/task.service';
import { BannerService } from '../../../core/banner/banner.service';
import { BannerId } from '../../../core/banner/banner.model';
import { Router, NavigationEnd } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
import { WorkContextType } from '../work-context.model';
import { WorkContextService } from '../work-context.service';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
import { TODAY_TAG } from '../../tag/tag.const';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { selectActiveContextTypeAndId } from './work-context.selectors';
describe('WorkContextEffects', () => {
let effects: WorkContextEffects;
@ -58,7 +60,17 @@ describe('WorkContextEffects', () => {
providers: [
WorkContextEffects,
provideMockActions(() => actions$),
provideMockStore(),
provideMockStore({
selectors: [
{
selector: selectActiveContextTypeAndId,
value: {
activeId: 'project-1',
activeType: WorkContextType.PROJECT,
},
},
],
}),
{ provide: TaskService, useValue: taskServiceMock },
{ provide: BannerService, useValue: bannerServiceSpy },
{ provide: Router, useValue: routerMock },
@ -237,4 +249,130 @@ describe('WorkContextEffects', () => {
}, 50);
});
});
describe('validateContextAfterDataLoad$', () => {
const createMockAppDataComplete = (
projectEntities: Record<string, unknown> = {},
): Record<string, unknown> => ({
project: {
ids: Object.keys(projectEntities),
entities: projectEntities,
},
task: { ids: [], entities: {} },
tag: { ids: [], entities: {} },
globalConfig: {},
});
it('should redirect to TODAY when active project no longer exists in new data', (done) => {
store.overrideSelector(selectActiveContextTypeAndId, {
activeId: 'project-123',
activeType: WorkContextType.PROJECT,
});
const appDataComplete = createMockAppDataComplete({
otherProject: { id: 'otherProject', title: 'Other Project' },
});
effects.validateContextAfterDataLoad$.subscribe((action) => {
expect(action).toEqual(
setActiveWorkContext({
activeId: TODAY_TAG.id,
activeType: WorkContextType.TAG,
}),
);
done();
});
actions$.next(loadAllData({ appDataComplete: appDataComplete as any }));
});
it('should not dispatch action when active project still exists', (done) => {
store.overrideSelector(selectActiveContextTypeAndId, {
activeId: 'project-123',
activeType: WorkContextType.PROJECT,
});
const appDataComplete = createMockAppDataComplete({
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'project-123': { id: 'project-123', title: 'Test Project' },
});
let actionDispatched = false;
effects.validateContextAfterDataLoad$.subscribe(() => {
actionDispatched = true;
});
actions$.next(loadAllData({ appDataComplete: appDataComplete as any }));
// Give some time for potential emission
setTimeout(() => {
expect(actionDispatched).toBe(false);
done();
}, 50);
});
it('should not dispatch action when active context is a tag', (done) => {
store.overrideSelector(selectActiveContextTypeAndId, {
activeId: TODAY_TAG.id,
activeType: WorkContextType.TAG,
});
const appDataComplete = createMockAppDataComplete({});
let actionDispatched = false;
effects.validateContextAfterDataLoad$.subscribe(() => {
actionDispatched = true;
});
actions$.next(loadAllData({ appDataComplete: appDataComplete as any }));
// Give some time for potential emission
setTimeout(() => {
expect(actionDispatched).toBe(false);
done();
}, 50);
});
it('should redirect to TODAY when project data is undefined', (done) => {
store.overrideSelector(selectActiveContextTypeAndId, {
activeId: 'project-123',
activeType: WorkContextType.PROJECT,
});
const appDataComplete = { project: undefined } as any;
effects.validateContextAfterDataLoad$.subscribe((action) => {
expect(action).toEqual(
setActiveWorkContext({
activeId: TODAY_TAG.id,
activeType: WorkContextType.TAG,
}),
);
done();
});
actions$.next(loadAllData({ appDataComplete }));
});
it('should redirect to TODAY when project entities is undefined', (done) => {
store.overrideSelector(selectActiveContextTypeAndId, {
activeId: 'project-123',
activeType: WorkContextType.PROJECT,
});
const appDataComplete = { project: { ids: [], entities: undefined } } as any;
effects.validateContextAfterDataLoad$.subscribe((action) => {
expect(action).toEqual(
setActiveWorkContext({
activeId: TODAY_TAG.id,
activeType: WorkContextType.TAG,
}),
);
done();
});
actions$.next(loadAllData({ appDataComplete }));
});
});
});

View file

@ -13,10 +13,15 @@ import { NavigationEnd, Router } from '@angular/router';
import { TODAY_TAG } from '../../tag/tag.const';
import { WorkContextType } from '../work-context.model';
import { WorkContextService } from '../work-context.service';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { Store } from '@ngrx/store';
import { selectActiveContextTypeAndId } from './work-context.selectors';
import { Log } from '../../../core/log';
@Injectable()
export class WorkContextEffects {
private _actions$ = inject(LOCAL_ACTIONS);
private _store$ = inject(Store);
private _taskService = inject(TaskService);
private _bannerService = inject(BannerService);
private _router = inject(Router);
@ -69,4 +74,43 @@ export class WorkContextEffects {
),
),
);
/**
* Validates the active work context after data is reloaded (e.g., from sync).
* If the active project or tag no longer exists in the new data, redirects to TODAY tag.
* Fixes: https://github.com/johannesjo/super-productivity/issues/5859
*/
validateContextAfterDataLoad$: Observable<unknown> = createEffect(() =>
this._actions$.pipe(
ofType(loadAllData),
withLatestFrom(this._store$.select(selectActiveContextTypeAndId)),
filter(([{ appDataComplete }, { activeType, activeId }]) => {
if (activeType === WorkContextType.PROJECT) {
const exists = !!appDataComplete.project?.entities?.[activeId];
if (!exists) {
Log.warn(
`Active project ${activeId} not found after data load, redirecting to TODAY`,
);
}
return !exists;
}
if (activeType === WorkContextType.TAG && activeId !== TODAY_TAG.id) {
const exists = !!appDataComplete.tag?.entities?.[activeId];
if (!exists) {
Log.warn(
`Active tag ${activeId} not found after data load, redirecting to TODAY`,
);
}
return !exists;
}
return false;
}),
map(() =>
setActiveWorkContext({
activeId: TODAY_TAG.id,
activeType: WorkContextType.TAG,
}),
),
),
);
}

View file

@ -56,7 +56,7 @@ export class LocalBackupService {
}
async askForFileStoreBackupIfAvailable(): Promise<void> {
if (!IS_ELECTRON || !IS_ANDROID_WEB_VIEW) {
if (!IS_ELECTRON && !IS_ANDROID_WEB_VIEW) {
return;
}

View file

@ -0,0 +1,150 @@
import { getSyncErrorStr } from './get-sync-error-str';
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
describe('getSyncErrorStr', () => {
it('should return string errors directly', () => {
expect(getSyncErrorStr('sync failed')).toBe('sync failed');
});
it('should handle null', () => {
expect(getSyncErrorStr(null)).toBe('Unknown sync error');
});
it('should handle undefined', () => {
expect(getSyncErrorStr(undefined)).toBe('Unknown sync error');
});
it('should extract message from Error instances', () => {
const err = new Error('sync error message');
expect(getSyncErrorStr(err)).toBe('sync error message');
});
it('should handle HANDLED_ERROR_PROP_STR', () => {
const err = { [HANDLED_ERROR_PROP_STR]: 'handled sync error' };
expect(getSyncErrorStr(err)).toBe('handled sync error');
});
it('should extract response.data string (Axios pattern)', () => {
const err = { response: { data: 'server error response' } };
expect(getSyncErrorStr(err)).toBe('server error response');
});
it('should extract response.data.message (nested API error)', () => {
const err = { response: { data: { message: 'API error message' } } };
expect(getSyncErrorStr(err)).toBe('API error message');
});
it('should extract name property', () => {
const err = { name: 'SyncError' };
expect(getSyncErrorStr(err)).toBe('SyncError');
});
it('should extract statusText for HTTP errors', () => {
const err = { statusText: 'Service Unavailable' };
expect(getSyncErrorStr(err)).toBe('Service Unavailable');
});
it('should never return [object Object]', () => {
const plainObject = { foo: 'bar' };
const result = getSyncErrorStr(plainObject);
expect(result).not.toBe('[object Object]');
});
it('should JSON.stringify objects without standard error properties', () => {
const err = { code: 'NETWORK_ERROR', retry: true };
const result = getSyncErrorStr(err);
expect(result).toContain('code');
expect(result).toContain('NETWORK_ERROR');
});
it('should handle empty objects', () => {
const result = getSyncErrorStr({});
expect(result).toBe('Unknown sync error (unable to extract message)');
});
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
const err = {
[HANDLED_ERROR_PROP_STR]: 'handled',
message: 'regular message',
};
expect(getSyncErrorStr(err)).toBe('handled');
});
it('should truncate long error messages', () => {
const longMessage = 'x'.repeat(500);
const err = { message: longMessage };
const result = getSyncErrorStr(err);
expect(result.length).toBeLessThanOrEqual(403); // 400 + '...'
});
it('should handle objects with custom toString', () => {
const err = {
toString: () => 'custom sync error',
};
expect(getSyncErrorStr(err)).toBe('custom sync error');
});
it('should prefer message over response.data', () => {
const err = {
message: 'direct message',
response: { data: 'response data' },
};
expect(getSyncErrorStr(err)).toBe('direct message');
});
it('should handle circular reference objects gracefully', () => {
const err: any = { message: null, data: {} };
err.data.self = err; // circular reference
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
expect(result).toBe('Unknown sync error (unable to extract message)');
});
it('should handle arrays', () => {
const result = getSyncErrorStr(['sync error 1', 'sync error 2']);
expect(result).not.toBe('[object Object]');
expect(result).toContain('sync error');
});
it('should handle numbers', () => {
expect(getSyncErrorStr(503)).toBe('503');
});
it('should handle objects where toString throws', () => {
const err = {
toString: () => {
throw new Error('toString failed');
},
};
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
});
it('should handle empty string message', () => {
const err = { message: '' };
const result = getSyncErrorStr(err);
// Empty message falls through to JSON.stringify
expect(result).not.toBe('[object Object]');
expect(result).toContain('message');
});
it('should handle WebDAV-style errors', () => {
const err = {
status: 423,
statusText: 'Locked',
response: { data: 'Resource is locked by another process' },
};
expect(getSyncErrorStr(err)).toBe('Resource is locked by another process');
});
it('should handle Dropbox-style API errors', () => {
const err = {
error: {
error_summary: 'path/not_found/...',
error: { tag: 'path', path: { tag: 'not_found' } },
},
};
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
});
});

View file

@ -1,28 +1,76 @@
import { truncate } from '../../util/truncate';
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
import { isObject } from '../../util/is-object';
// ugly little helper to make sure we get the most information out of it for the user
const OBJECT_OBJECT_STR = '[object Object]';
// Helper to extract error string from various error shapes
export const getSyncErrorStr = (err: unknown): string => {
let errorAsString: string =
err && (err as any)?.toString ? (err as any).toString() : '???';
// Check if error has a message property (most Error objects do)
if (err && typeof (err as any)?.message === 'string') {
errorAsString = (err as any).message as string;
// Handle string errors directly
if (typeof err === 'string') {
return truncate(err, 400);
}
if (err && typeof (err as any)?.response?.data === 'string') {
errorAsString = (err as any)?.response?.data as string;
// Handle null/undefined
if (err == null) {
return 'Unknown sync error';
}
if (
errorAsString === '[object Object]' &&
err &&
(err as any)[HANDLED_ERROR_PROP_STR]
) {
errorAsString = (err as any)[HANDLED_ERROR_PROP_STR] as string;
const errAny = err as any;
// Check for handled error marker first (highest priority)
if (errAny[HANDLED_ERROR_PROP_STR]) {
return truncate(String(errAny[HANDLED_ERROR_PROP_STR]), 400);
}
// Increased from 150 to 400 to show more context, especially for HTTP errors
return truncate(errorAsString.toString(), 400);
// Check message property (standard Error objects)
if (typeof errAny.message === 'string' && errAny.message) {
return truncate(errAny.message, 400);
}
// Check response.data (Axios-style errors)
if (typeof errAny.response?.data === 'string' && errAny.response.data) {
return truncate(errAny.response.data, 400);
}
// Check response.data.message (nested API error)
if (typeof errAny.response?.data?.message === 'string') {
return truncate(errAny.response.data.message, 400);
}
// Check for name property
if (typeof errAny.name === 'string' && errAny.name) {
return truncate(errAny.name, 400);
}
// Check for statusText (HTTP errors)
if (typeof errAny.statusText === 'string' && errAny.statusText) {
return truncate(errAny.statusText, 400);
}
// Try toString() but check for [object Object]
if (typeof errAny.toString === 'function') {
try {
const str = errAny.toString();
if (str && str !== OBJECT_OBJECT_STR) {
return truncate(str, 400);
}
} catch {
// toString() threw - fall through to JSON.stringify
}
}
// Try JSON.stringify as last resort
if (isObject(err)) {
try {
const jsonStr = JSON.stringify(err);
if (jsonStr && jsonStr !== '{}') {
return truncate(jsonStr, 400);
}
} catch {
// Circular reference or other JSON error - fall through
}
}
return 'Unknown sync error (unable to extract message)';
};

View file

@ -265,6 +265,42 @@ export class DecompressError extends AdditionalLogErrorBase {
override name = 'DecompressError';
}
export class JsonParseError extends Error {
override name = 'JsonParseError';
position?: number;
dataSample?: string;
constructor(originalError: unknown, dataStr?: string) {
// Extract position from SyntaxError message (e.g., "...at position 80999")
const positionMatch =
originalError instanceof Error
? originalError.message.match(/position\s+(\d+)/i)
: null;
const position = positionMatch ? parseInt(positionMatch[1], 10) : undefined;
// Create human-readable message
const positionInfo = position !== undefined ? ` at position ${position}` : '';
const message = `Failed to parse JSON data${positionInfo}. The sync data may be corrupted or incomplete.`;
super(message);
this.position = position;
// Extract a sample of the data around the error position for debugging
if (dataStr && position !== undefined) {
const start = Math.max(0, position - 50);
const end = Math.min(dataStr.length, position + 50);
this.dataSample = `...${dataStr.substring(start, end)}...`;
}
PFLog.err('JsonParseError:', {
message: this.message,
position: this.position,
dataSample: this.dataSample,
originalError,
});
}
}
// --------------MODEL AND DB ERRORS--------------
export class ClientIdNotFoundError extends Error {
override name = 'ClientIdNotFoundError';

View file

@ -0,0 +1,202 @@
import { PFLog } from '../../../core/log';
import { JsonParseError } from '../errors/errors';
import { EncryptAndCompressHandlerService } from './encrypt-and-compress-handler.service';
import { getErrorTxt } from '../../../util/get-error-text';
describe('EncryptAndCompressHandlerService', () => {
let service: EncryptAndCompressHandlerService;
// Prefix format: pf_{compress?}{encrypt?}{version}__
// e.g., "pf_1__" for uncompressed, unencrypted, version 1
const makePrefix = (version: number = 1): string => `pf_${version}__`;
beforeEach(() => {
service = new EncryptAndCompressHandlerService();
spyOn(PFLog, 'err').and.stub();
spyOn(PFLog, 'normal').and.stub();
spyOn(PFLog, 'log').and.stub();
});
describe('decompressAndDecrypt', () => {
it('should parse valid JSON successfully', async () => {
const testData = { test: 'value', number: 42 };
const jsonStr = JSON.stringify(testData);
const dataStr = `${makePrefix(1)}${jsonStr}`;
const result = await service.decompressAndDecrypt<typeof testData>({
dataStr,
encryptKey: undefined,
});
expect(result.data).toEqual(testData);
expect(result.modelVersion).toBe(1);
});
it('should throw JsonParseError for invalid JSON', async () => {
const invalidJson = '{ invalid json }';
const dataStr = `${makePrefix(1)}${invalidJson}`;
await expectAsync(
service.decompressAndDecrypt({
dataStr,
encryptKey: undefined,
}),
).toBeRejectedWithError(JsonParseError);
});
it('should throw JsonParseError with position info for truncated JSON', async () => {
const truncatedJson = '{"key": "value", "truncated';
const dataStr = `${makePrefix(1)}${truncatedJson}`;
try {
await service.decompressAndDecrypt({
dataStr,
encryptKey: undefined,
});
fail('Expected JsonParseError to be thrown');
} catch (e) {
expect(e instanceof JsonParseError).toBeTrue();
const error = e as JsonParseError;
expect(error.message).toContain('Failed to parse JSON data');
expect(error.message).toContain('corrupted or incomplete');
}
});
it('should include data sample in JsonParseError for debugging', async () => {
const invalidJson = '{"valid": true}extra garbage here';
const dataStr = `${makePrefix(1)}${invalidJson}`;
try {
await service.decompressAndDecrypt({
dataStr,
encryptKey: undefined,
});
fail('Expected JsonParseError to be thrown');
} catch (e) {
expect(e instanceof JsonParseError).toBeTrue();
const error = e as JsonParseError;
// Position should be extracted from the SyntaxError
expect(error.position).toBeDefined();
}
});
it('should handle empty JSON string', async () => {
const dataStr = `${makePrefix(1)}`;
await expectAsync(
service.decompressAndDecrypt({
dataStr,
encryptKey: undefined,
}),
).toBeRejectedWithError(JsonParseError);
});
it('should parse complex nested JSON', async () => {
const complexData = {
tasks: [{ id: '1', title: 'Test' }],
config: { nested: { deep: { value: true } } },
numbers: [1, 2, 3],
};
const dataStr = `${makePrefix(2)}${JSON.stringify(complexData)}`;
const result = await service.decompressAndDecrypt<typeof complexData>({
dataStr,
encryptKey: undefined,
});
expect(result.data).toEqual(complexData);
expect(result.modelVersion).toBe(2);
});
});
});
describe('JsonParseError', () => {
beforeEach(() => {
spyOn(PFLog, 'err').and.stub();
});
it('should extract position from SyntaxError message', () => {
const syntaxError = new SyntaxError('Unexpected token at position 12345');
const error = new JsonParseError(syntaxError, 'some data');
expect(error.position).toBe(12345);
});
it('should handle SyntaxError without position', () => {
const syntaxError = new SyntaxError('Unexpected token');
const error = new JsonParseError(syntaxError, 'some data');
expect(error.position).toBeUndefined();
expect(error.message).toBe(
'Failed to parse JSON data. The sync data may be corrupted or incomplete.',
);
});
it('should include position in message when available', () => {
const syntaxError = new SyntaxError('Unexpected token at position 100');
const error = new JsonParseError(syntaxError, 'some data');
expect(error.message).toContain('at position 100');
});
it('should extract data sample around error position', () => {
const syntaxError = new SyntaxError('Unexpected token at position 50');
const longData = 'a'.repeat(100);
const error = new JsonParseError(syntaxError, longData);
expect(error.dataSample).toBeDefined();
expect(error.dataSample!.length).toBeLessThan(longData.length + 10);
});
it('should have error name set to JsonParseError', () => {
const error = new JsonParseError(new Error('test'), 'data');
expect(error.name).toBe('JsonParseError');
});
it('should produce human-readable error text via getErrorTxt()', () => {
const syntaxError = new SyntaxError('Unexpected token at position 80999');
const error = new JsonParseError(syntaxError, 'corrupted data');
const errorText = getErrorTxt(error);
// Should NOT be [object Object]
expect(errorText).not.toBe('[object Object]');
expect(errorText).not.toContain('[object Object]');
// Should contain meaningful message
expect(errorText).toContain('Failed to parse JSON data');
expect(errorText).toContain('80999');
});
it('should handle non-Error original error', () => {
const error = new JsonParseError('string error', 'data');
expect(error.position).toBeUndefined();
expect(error.message).toContain('Failed to parse JSON data');
});
it('should handle undefined dataStr', () => {
const syntaxError = new SyntaxError('Unexpected token at position 10');
const error = new JsonParseError(syntaxError, undefined);
expect(error.dataSample).toBeUndefined();
expect(error.position).toBe(10);
});
it('should handle position at start of data', () => {
const syntaxError = new SyntaxError('Unexpected token at position 0');
const error = new JsonParseError(syntaxError, 'invalid json');
expect(error.position).toBe(0);
expect(error.dataSample).toBeDefined();
});
it('should handle position beyond data length', () => {
const syntaxError = new SyntaxError('Unexpected token at position 1000');
const error = new JsonParseError(syntaxError, 'short');
expect(error.position).toBe(1000);
// dataSample should still be set but truncated to actual data length
expect(error.dataSample).toBeDefined();
});
});

View file

@ -4,7 +4,7 @@ import {
} from '../util/sync-file-prefix';
import { PFLog } from '../../../core/log';
import { decrypt, encrypt } from '../encryption/encryption';
import { DecryptError, DecryptNoPasswordError } from '../errors/errors';
import { DecryptError, DecryptNoPasswordError, JsonParseError } from '../errors/errors';
import {
compressWithGzipToString,
decompressGzipFromString,
@ -123,8 +123,15 @@ export class EncryptAndCompressHandlerService {
outStr = await decompressGzipFromString(outStr);
}
let parsedData: T;
try {
parsedData = JSON.parse(outStr);
} catch (e) {
throw new JsonParseError(e, outStr);
}
return {
data: JSON.parse(outStr),
data: parsedData,
modelVersion,
};
}

View file

@ -2006,4 +2006,128 @@ describe('dataRepair()', () => {
]);
expect(result.archiveYoung.task.entities['ARCHIVE_TASK2']?.tagIds).toEqual([]);
});
describe('should fix repeat configs with invalid quickSetting (issue #5802)', () => {
it('should change quickSetting to CUSTOM when WEEKLY_CURRENT_WEEKDAY has no startDate', () => {
const taskRepeatCfgState = {
...mock.taskRepeatCfg,
...fakeEntityStateFromArray<TaskRepeatCfg>([
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST',
title: 'TEST',
quickSetting: 'WEEKLY_CURRENT_WEEKDAY',
startDate: undefined,
},
]),
} as any;
const result = dataRepair({
...mock,
taskRepeatCfg: taskRepeatCfgState,
} as any);
expect(result.taskRepeatCfg.entities['TEST']?.quickSetting).toEqual('CUSTOM');
});
it('should change quickSetting to CUSTOM when YEARLY_CURRENT_DATE has no startDate', () => {
const taskRepeatCfgState = {
...mock.taskRepeatCfg,
...fakeEntityStateFromArray<TaskRepeatCfg>([
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST',
title: 'TEST',
quickSetting: 'YEARLY_CURRENT_DATE',
startDate: undefined,
},
]),
} as any;
const result = dataRepair({
...mock,
taskRepeatCfg: taskRepeatCfgState,
} as any);
expect(result.taskRepeatCfg.entities['TEST']?.quickSetting).toEqual('CUSTOM');
});
it('should change quickSetting to CUSTOM when MONTHLY_CURRENT_DATE has no startDate', () => {
const taskRepeatCfgState = {
...mock.taskRepeatCfg,
...fakeEntityStateFromArray<TaskRepeatCfg>([
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST',
title: 'TEST',
quickSetting: 'MONTHLY_CURRENT_DATE',
startDate: undefined,
},
]),
} as any;
const result = dataRepair({
...mock,
taskRepeatCfg: taskRepeatCfgState,
} as any);
expect(result.taskRepeatCfg.entities['TEST']?.quickSetting).toEqual('CUSTOM');
});
it('should NOT change quickSetting when startDate is provided', () => {
const taskRepeatCfgState = {
...mock.taskRepeatCfg,
...fakeEntityStateFromArray<TaskRepeatCfg>([
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST',
title: 'TEST',
quickSetting: 'WEEKLY_CURRENT_WEEKDAY',
startDate: '2024-01-15',
},
]),
} as any;
const result = dataRepair({
...mock,
taskRepeatCfg: taskRepeatCfgState,
} as any);
expect(result.taskRepeatCfg.entities['TEST']?.quickSetting).toEqual(
'WEEKLY_CURRENT_WEEKDAY',
);
});
it('should NOT change quickSetting for DAILY or CUSTOM', () => {
const taskRepeatCfgState = {
...mock.taskRepeatCfg,
...fakeEntityStateFromArray<TaskRepeatCfg>([
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST_DAILY',
title: 'TEST_DAILY',
quickSetting: 'DAILY',
startDate: undefined,
},
{
...DEFAULT_TASK_REPEAT_CFG,
id: 'TEST_CUSTOM',
title: 'TEST_CUSTOM',
quickSetting: 'CUSTOM',
startDate: undefined,
},
]),
} as any;
const result = dataRepair({
...mock,
taskRepeatCfg: taskRepeatCfgState,
} as any);
expect(result.taskRepeatCfg.entities['TEST_DAILY']?.quickSetting).toEqual('DAILY');
expect(result.taskRepeatCfg.entities['TEST_CUSTOM']?.quickSetting).toEqual(
'CUSTOM',
);
});
});
});

View file

@ -112,6 +112,7 @@ export const dataRepair = (
dataOut = _removeDuplicatesFromArchive(dataOut);
dataOut = _clearLegacyReminderIds(dataOut);
dataOut = _fixTaskRepeatMissingWeekday(dataOut);
dataOut = _fixTaskRepeatCfgInvalidQuickSetting(dataOut);
dataOut = _createInboxProjectIfNecessary(dataOut);
dataOut = _fixOrphanedNotes(dataOut);
dataOut = _removeNonExistentProjectIdsFromTasks(dataOut);
@ -140,6 +141,33 @@ const _fixTaskRepeatMissingWeekday = (data: AppDataCompleteNew): AppDataComplete
return data;
};
// Fix for issue #5802: repeat configs with date-dependent quickSetting but missing startDate
const _fixTaskRepeatCfgInvalidQuickSetting = (
data: AppDataCompleteNew,
): AppDataCompleteNew => {
if (data.taskRepeatCfg && data.taskRepeatCfg.entities) {
const quickSettingsRequiringStartDate = [
'WEEKLY_CURRENT_WEEKDAY',
'YEARLY_CURRENT_DATE',
'MONTHLY_CURRENT_DATE',
];
Object.keys(data.taskRepeatCfg.entities).forEach((key) => {
const cfg = data.taskRepeatCfg.entities[key] as TaskRepeatCfgCopy;
if (
cfg.quickSetting &&
quickSettingsRequiringStartDate.includes(cfg.quickSetting) &&
!cfg.startDate
) {
PFLog.log(
`Fixing repeat config ${cfg.id}: ${cfg.quickSetting} with missing startDate -> CUSTOM`,
);
cfg.quickSetting = 'CUSTOM';
}
});
}
return data;
};
const _fixEntityStates = (data: AppDataCompleteNew): AppDataCompleteNew => {
ENTITY_STATE_KEYS.forEach((key) => {
data[key] = _resetEntityIdsFromObjects(

View file

@ -0,0 +1,261 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { PluginBridgeService } from './plugin-bridge.service';
import {
SimpleCounter,
SimpleCounterType,
} from '../features/simple-counter/simple-counter.model';
import { EMPTY_SIMPLE_COUNTER } from '../features/simple-counter/simple-counter.const';
import { SnackService } from '../core/snack/snack.service';
import { NotifyService } from '../core/notify/notify.service';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { TaskService } from '../features/tasks/task.service';
import { ProjectService } from '../features/project/project.service';
import { TagService } from '../features/tag/tag.service';
import { WorkContextService } from '../features/work-context/work-context.service';
import { PluginHooksService } from './plugin-hooks';
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
import { PluginConfigService } from './plugin-config.service';
import { TaskArchiveService } from '../features/time-tracking/task-archive.service';
import { TranslateService } from '@ngx-translate/core';
import { SyncWrapperService } from '../imex/sync/sync-wrapper.service';
import { Injector } from '@angular/core';
/* eslint-disable @typescript-eslint/naming-convention */
describe('PluginBridgeService.setCounter()', () => {
let service: PluginBridgeService;
let store: jasmine.SpyObj<Store>;
const createMockCounter = (
id: string,
countOnDay: Record<string, number> = {},
): SimpleCounter => ({
...EMPTY_SIMPLE_COUNTER,
id,
title: id,
isEnabled: true,
type: SimpleCounterType.ClickCounter,
countOnDay,
});
const getToday = (): string => new Date().toISOString().split('T')[0];
beforeEach(() => {
const storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']);
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['allTasks$']);
const projectServiceSpy = jasmine.createSpyObj('ProjectService', ['list$']);
const tagServiceSpy = jasmine.createSpyObj('TagService', ['getTags$']);
const workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [
'activeWorkContext$',
]);
TestBed.configureTestingModule({
providers: [
PluginBridgeService,
{ provide: Store, useValue: storeSpy },
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: ProjectService, useValue: projectServiceSpy },
{ provide: TagService, useValue: tagServiceSpy },
{ provide: WorkContextService, useValue: workContextServiceSpy },
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{
provide: NotifyService,
useValue: jasmine.createSpyObj('NotifyService', ['notify']),
},
{ provide: MatDialog, useValue: jasmine.createSpyObj('MatDialog', ['open']) },
{ provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) },
{
provide: PluginHooksService,
useValue: jasmine.createSpyObj('PluginHooksService', ['registerHook']),
},
{
provide: PluginUserPersistenceService,
useValue: jasmine.createSpyObj('PluginUserPersistenceService', ['get', 'set']),
},
{
provide: PluginConfigService,
useValue: jasmine.createSpyObj('PluginConfigService', ['get', 'set']),
},
{
provide: TaskArchiveService,
useValue: jasmine.createSpyObj('TaskArchiveService', ['getAll']),
},
{
provide: TranslateService,
useValue: jasmine.createSpyObj('TranslateService', ['instant']),
},
{
provide: SyncWrapperService,
useValue: jasmine.createSpyObj('SyncWrapperService', ['sync']),
},
Injector,
],
});
service = TestBed.inject(PluginBridgeService);
store = TestBed.inject(Store) as jasmine.SpyObj<Store>;
// Default: return empty counter list
store.select.and.returnValue(of([]));
});
describe('input validation', () => {
it('should throw error for invalid counter ID with special characters', async () => {
await expectAsync(service.setCounter('invalid@id!', 5)).toBeRejectedWithError(
'Invalid counter key: must be alphanumeric with hyphens',
);
});
it('should throw error for ID with spaces', async () => {
await expectAsync(service.setCounter('invalid id', 5)).toBeRejectedWithError(
'Invalid counter key: must be alphanumeric with hyphens',
);
});
it('should throw error for negative value', async () => {
await expectAsync(service.setCounter('valid-id', -5)).toBeRejectedWithError(
'Invalid counter value: must be a non-negative number',
);
});
it('should throw error for NaN value', async () => {
await expectAsync(service.setCounter('valid-id', NaN)).toBeRejectedWithError(
'Invalid counter value: must be a non-negative number',
);
});
it('should throw error for Infinity value', async () => {
await expectAsync(service.setCounter('valid-id', Infinity)).toBeRejectedWithError(
'Invalid counter value: must be a non-negative number',
);
});
it('should accept valid alphanumeric ID with hyphens and underscores', async () => {
await service.setCounter('Valid_Counter-123', 10);
expect(store.dispatch).toHaveBeenCalled();
});
});
describe('creating new counter', () => {
it('should dispatch upsertSimpleCounter for new counter', async () => {
const today = getToday();
await service.setCounter('new-counter', 42);
expect(store.dispatch).toHaveBeenCalled();
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
expect(action.type).toBe('[SimpleCounter] Upsert SimpleCounter');
expect(action.simpleCounter.id).toBe('new-counter');
expect(action.simpleCounter.title).toBe('new-counter');
expect(action.simpleCounter.isEnabled).toBe(true);
expect(action.simpleCounter.type).toBe(SimpleCounterType.ClickCounter);
expect(action.simpleCounter.countOnDay[today]).toBe(42);
});
it('should set counter value to 0 for new counter', async () => {
const today = getToday();
await service.setCounter('zero-counter', 0);
expect(store.dispatch).toHaveBeenCalled();
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
expect(action.type).toBe('[SimpleCounter] Upsert SimpleCounter');
expect(action.simpleCounter.id).toBe('zero-counter');
expect(action.simpleCounter.countOnDay[today]).toBe(0);
});
it('should create counter with all required SimpleCounter fields', async () => {
await service.setCounter('full-counter', 5);
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
const counter = action.simpleCounter;
// Verify all mandatory fields are present
expect(counter.id).toBe('full-counter');
expect(counter.title).toBe('full-counter');
expect(counter.isEnabled).toBe(true);
expect(counter.type).toBe(SimpleCounterType.ClickCounter);
expect(typeof counter.countOnDay).toBe('object');
});
});
describe('updating existing counter', () => {
it('should dispatch updateSimpleCounter for existing counter', async () => {
const existingCounter = createMockCounter('existing-counter', { '2024-01-01': 10 });
store.select.and.returnValue(of([existingCounter]));
const today = getToday();
await service.setCounter('existing-counter', 25);
expect(store.dispatch).toHaveBeenCalled();
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
expect(action.type).toBe('[SimpleCounter] Update SimpleCounter');
expect(action.simpleCounter.id).toBe('existing-counter');
expect(action.simpleCounter.changes.countOnDay['2024-01-01']).toBe(10);
expect(action.simpleCounter.changes.countOnDay[today]).toBe(25);
});
it('should preserve other days when updating existing counter', async () => {
const existingCounter = createMockCounter('my-counter', {
'2024-01-01': 5,
'2024-01-02': 10,
'2024-01-03': 15,
});
store.select.and.returnValue(of([existingCounter]));
const today = getToday();
await service.setCounter('my-counter', 99);
expect(store.dispatch).toHaveBeenCalled();
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
const countOnDay = action.simpleCounter.changes.countOnDay;
expect(countOnDay['2024-01-01']).toBe(5);
expect(countOnDay['2024-01-02']).toBe(10);
expect(countOnDay['2024-01-03']).toBe(15);
expect(countOnDay[today]).toBe(99);
});
it('should overwrite today value if already set', async () => {
const today = getToday();
const existingCounter = createMockCounter('today-counter', { [today]: 50 });
store.select.and.returnValue(of([existingCounter]));
await service.setCounter('today-counter', 100);
expect(store.dispatch).toHaveBeenCalled();
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
expect(action.simpleCounter.changes.countOnDay[today]).toBe(100);
});
it('should use updateSimpleCounter action not upsertSimpleCounter for existing', async () => {
const existingCounter = createMockCounter('check-action', {});
store.select.and.returnValue(of([existingCounter]));
await service.setCounter('check-action', 1);
const call = store.dispatch.calls.mostRecent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = call.args[0] as any;
expect(action.type).toBe('[SimpleCounter] Update SimpleCounter');
});
});
});

View file

@ -275,6 +275,7 @@ const T = {
SHORT_BREAK_TITLE: 'F.FOCUS_MODE.SHORT_BREAK_TITLE',
SHOW_HIDE_NOTES_AND_ATTACHMENTS: 'F.FOCUS_MODE.SHOW_HIDE_NOTES_AND_ATTACHMENTS',
SKIP_BREAK: 'F.FOCUS_MODE.SKIP_BREAK',
START_BREAK: 'F.FOCUS_MODE.START_BREAK',
START_FOCUS_SESSION: 'F.FOCUS_MODE.START_FOCUS_SESSION',
START_NEXT_FOCUS_SESSION: 'F.FOCUS_MODE.START_NEXT_FOCUS_SESSION',
SWITCH_TASK: 'F.FOCUS_MODE.SWITCH_TASK',
@ -1833,6 +1834,7 @@ const T = {
DISMISS: 'G.DISMISS',
DONT_SHOW_AGAIN: 'G.DONT_SHOW_AGAIN',
DO_IT: 'G.DO_IT',
DUPLICATE: 'G.DUPLICATE',
DURATION_DESCRIPTION: 'G.DURATION_DESCRIPTION',
EDIT: 'G.EDIT',
ENABLED: 'G.ENABLED',
@ -1908,6 +1910,7 @@ const T = {
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',
L_MANUAL_BREAK_START: 'GCF.FOCUS_MODE.L_MANUAL_BREAK_START',
TITLE: 'GCF.FOCUS_MODE.TITLE',
},
IDLE: {
@ -2243,6 +2246,7 @@ const T = {
TOGGLE_SHOW_ISSUE_PANEL: 'MH.TOGGLE_SHOW_ISSUE_PANEL',
TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES',
TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME',
NO_TASKS_TO_TRACK: 'MH.NO_TASKS_TO_TRACK',
TRIGGER_SYNC: 'MH.TRIGGER_SYNC',
UNPLAN_ALL_TASKS: 'MH.UNPLAN_ALL_TASKS',
WORKLOG: 'MH.WORKLOG',

View file

@ -48,14 +48,6 @@
</mat-button-toggle-group>
</div>
<button
(click)="close(true)"
mat-button
>
<mat-icon>close</mat-icon>
{{ T.G.CANCEL | translate }}
</button>
<button
id="T-save-note"
(click)="close()"

View file

@ -3,11 +3,14 @@ import {
Component,
inject,
OnDestroy,
output,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { T } from '../../t.const';
import { Subscription } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { ESCAPE } from '@angular/cdk/keycodes';
import { LS } from '../../core/persistence/storage-keys.const';
import { isSmallScreen } from '../../util/is-small-screen';
@ -45,7 +48,9 @@ export class DialogFullscreenMarkdownComponent implements OnDestroy {
T: typeof T = T;
viewMode: ViewMode = isSmallScreen() ? 'TEXT_ONLY' : 'SPLIT';
readonly previewEl = viewChild<MarkdownComponent>('previewEl');
readonly contentChanged = output<string>();
private _subs: Subscription = new Subscription();
private readonly _contentChanges$ = new Subject<string>();
constructor() {
const lastViewMode = localStorage.getItem(LS.LAST_FULLSCREEN_EDIT_VIEW_MODE);
@ -62,13 +67,20 @@ export class DialogFullscreenMarkdownComponent implements OnDestroy {
}
}
// we want to save as default
// Auto-save with debounce
this._contentChanges$
.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe((value) => {
this.contentChanged.emit(value);
});
// Handle Escape key - save and close
this._matDialogRef.disableClose = true;
this._subs.add(
this._matDialogRef.keydownEvents().subscribe((e) => {
if ((e as any).keyCode === ESCAPE) {
e.preventDefault();
this.close(undefined, true);
this.close();
}
}),
);
@ -80,14 +92,16 @@ export class DialogFullscreenMarkdownComponent implements OnDestroy {
}
}
ngModelChange(data: string): void {}
ngModelChange(content: string): void {
this._contentChanges$.next(content);
}
ngOnDestroy(): void {
this._subs.unsubscribe();
}
close(isSkipSave: boolean = false, isEscapeClose: boolean = false): void {
this._matDialogRef.close(!isSkipSave ? this.data?.content : undefined);
close(): void {
this._matDialogRef.close(this.data?.content);
}
onViewModeChange(): void {
@ -125,6 +139,8 @@ export class DialogFullscreenMarkdownComponent implements OnDestroy {
? item.replace('[ ]', '[x]').replace('[]', '[x]')
: item.replace('[x]', '[ ]');
this.data.content = allLines.join('\n');
// Emit change for auto-save
this._contentChanges$.next(this.data.content);
}
}
}

View file

@ -197,22 +197,31 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
}
openFullScreen(): void {
this._matDialog
.open(DialogFullscreenMarkdownComponent, {
minWidth: '100vw',
height: '100vh',
restoreFocus: true,
data: {
content: this.modelCopy(),
},
})
.afterClosed()
.subscribe((res) => {
if (typeof res === 'string') {
this.modelCopy.set(res);
this.changed.emit(res);
}
});
const dialogRef = this._matDialog.open(DialogFullscreenMarkdownComponent, {
minWidth: '100vw',
height: '100vh',
restoreFocus: true,
data: {
content: this.modelCopy(),
},
});
let lastEmittedContent: string | null = null;
// Subscribe to live auto-save updates from fullscreen dialog
dialogRef.componentInstance.contentChanged.subscribe((content: string) => {
lastEmittedContent = content;
this.modelCopy.set(content);
this.changed.emit(content);
});
dialogRef.afterClosed().subscribe((res) => {
// Only emit if content differs from last auto-saved content
if (typeof res === 'string' && res !== lastEmittedContent) {
this.modelCopy.set(res);
this.changed.emit(res);
}
});
}
resizeParsedToFit(): void {

View file

@ -0,0 +1,222 @@
import {
getAudioContext,
getAudioBuffer,
clearAudioBufferCache,
closeAudioContext,
} from './audio-context';
describe('audio-context', () => {
let originalAudioContext: typeof AudioContext;
let originalFetch: typeof window.fetch;
let mockCloseContext: jasmine.Spy;
beforeEach(() => {
originalAudioContext = (window as any).AudioContext;
originalFetch = window.fetch;
// Create a mock context that has the close method
mockCloseContext = jasmine.createSpy('close');
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: mockCloseContext,
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
// Reset the module state
closeAudioContext();
// Now we can set up our real test mocks
});
afterEach(() => {
(window as any).AudioContext = originalAudioContext;
(window as any).fetch = originalFetch;
});
describe('getAudioContext', () => {
it('should create an AudioContext if none exists', () => {
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
const ctx = getAudioContext();
expect((window as any).AudioContext).toHaveBeenCalled();
expect(ctx).toBe(mockContext as unknown as AudioContext);
});
it('should return the same AudioContext on subsequent calls', () => {
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
const ctx1 = getAudioContext();
const ctx2 = getAudioContext();
expect((window as any).AudioContext).toHaveBeenCalledTimes(1);
expect(ctx1).toBe(ctx2);
});
it('should resume the context if suspended', () => {
const mockContext = {
state: 'suspended',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
getAudioContext();
expect(mockContext.resume).toHaveBeenCalled();
});
it('should not resume if context is running', () => {
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
getAudioContext();
expect(mockContext.resume).not.toHaveBeenCalled();
});
});
describe('getAudioBuffer', () => {
let mockContext: any;
let mockArrayBuffer: ArrayBuffer;
let mockAudioBuffer: AudioBuffer;
let fetchSpy: jasmine.Spy;
beforeEach(() => {
mockArrayBuffer = new ArrayBuffer(8);
mockAudioBuffer = {} as AudioBuffer;
mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
decodeAudioData: jasmine
.createSpy('decodeAudioData')
.and.returnValue(Promise.resolve(mockAudioBuffer)),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
// Create fetch spy by assigning directly to window.fetch
fetchSpy = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
} as Response),
);
(window as any).fetch = fetchSpy;
});
it('should fetch and decode audio on first call', async () => {
const buffer = await getAudioBuffer('./assets/snd/test.mp3');
expect(fetchSpy).toHaveBeenCalledWith('./assets/snd/test.mp3');
expect(mockContext.decodeAudioData).toHaveBeenCalledWith(mockArrayBuffer);
expect(buffer).toBe(mockAudioBuffer);
});
it('should return cached buffer on subsequent calls', async () => {
await getAudioBuffer('./assets/snd/test.mp3');
const buffer = await getAudioBuffer('./assets/snd/test.mp3');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(buffer).toBe(mockAudioBuffer);
});
it('should cache different files separately', async () => {
await getAudioBuffer('./assets/snd/test1.mp3');
await getAudioBuffer('./assets/snd/test2.mp3');
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(fetchSpy).toHaveBeenCalledWith('./assets/snd/test1.mp3');
expect(fetchSpy).toHaveBeenCalledWith('./assets/snd/test2.mp3');
});
});
describe('clearAudioBufferCache', () => {
it('should clear the buffer cache', async () => {
const mockArrayBuffer = new ArrayBuffer(8);
const mockAudioBuffer = {} as AudioBuffer;
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
decodeAudioData: jasmine
.createSpy('decodeAudioData')
.and.returnValue(Promise.resolve(mockAudioBuffer)),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
// Create fetch spy by assigning directly to window.fetch
const fetchSpy = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
} as Response),
);
(window as any).fetch = fetchSpy;
await getAudioBuffer('./assets/snd/test.mp3');
clearAudioBufferCache();
await getAudioBuffer('./assets/snd/test.mp3');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
describe('closeAudioContext', () => {
it('should close the context and clear cache', () => {
const mockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockContext);
getAudioContext();
closeAudioContext();
expect(mockContext.close).toHaveBeenCalled();
// Verify a new context is created after close
const newMockContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(newMockContext);
const ctx = getAudioContext();
expect(ctx).toBe(newMockContext as unknown as AudioContext);
});
});
});

View file

@ -0,0 +1,65 @@
/**
* Singleton AudioContext manager to avoid creating multiple AudioContext instances.
* This prevents memory leaks and browser resource exhaustion when playing sounds frequently.
*/
let audioContext: AudioContext | null = null;
const audioBufferCache = new Map<string, AudioBuffer>();
/**
* Returns the singleton AudioContext instance, creating it if necessary.
* Handles the AudioContext suspended state that can occur due to browser autoplay policies.
*/
export const getAudioContext = (): AudioContext => {
if (!audioContext) {
audioContext = new ((window as any).AudioContext ||
(window as any).webkitAudioContext)();
}
// Resume if suspended (can happen due to browser autoplay policies)
// Intentionally fire-and-forget - audio playback is async anyway
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume();
}
return audioContext!;
};
/**
* Retrieves a cached audio buffer or fetches and decodes it if not cached.
* @param filePath - Path to the audio file
* @returns Promise resolving to the decoded AudioBuffer
*/
export const getAudioBuffer = async (filePath: string): Promise<AudioBuffer> => {
const cached = audioBufferCache.get(filePath);
if (cached) {
return cached;
}
const ctx = getAudioContext();
const response = await fetch(filePath);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
audioBufferCache.set(filePath, audioBuffer);
return audioBuffer;
};
/**
* Clears the audio buffer cache. Useful for testing or memory management.
*/
export const clearAudioBufferCache = (): void => {
audioBufferCache.clear();
};
/**
* Closes the AudioContext and clears all caches.
* Should only be called when audio is no longer needed (e.g., app shutdown).
*/
export const closeAudioContext = (): void => {
if (audioContext) {
audioContext.close();
audioContext = null;
}
audioBufferCache.clear();
};

View file

@ -0,0 +1,178 @@
import { getErrorTxt } from './get-error-text';
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
describe('getErrorTxt', () => {
it('should return string errors directly', () => {
expect(getErrorTxt('simple error')).toBe('simple error');
});
it('should handle null', () => {
expect(getErrorTxt(null)).toBe('Unknown error (null/undefined)');
});
it('should handle undefined', () => {
expect(getErrorTxt(undefined)).toBe('Unknown error (null/undefined)');
});
it('should extract message from Error instances', () => {
const err = new Error('test error message');
expect(getErrorTxt(err)).toBe('test error message');
});
it('should extract message from plain objects with message property', () => {
expect(getErrorTxt({ message: 'object error' })).toBe('object error');
});
it('should handle HANDLED_ERROR_PROP_STR', () => {
const err = { [HANDLED_ERROR_PROP_STR]: 'handled error message' };
expect(getErrorTxt(err)).toBe('handled error message');
});
it('should extract nested error.message (HttpErrorResponse pattern)', () => {
const err = { error: { message: 'nested error message' } };
expect(getErrorTxt(err)).toBe('nested error message');
});
it('should extract error.name when message is not available', () => {
const err = { error: { name: 'ValidationError' } };
expect(getErrorTxt(err)).toBe('ValidationError');
});
it('should extract deeply nested error.error.message', () => {
const err = { error: { error: { message: 'deep nested message' } } };
expect(getErrorTxt(err)).toBe('deep nested message');
});
it('should extract name property when message is not available', () => {
const err = { name: 'CustomError' };
expect(getErrorTxt(err)).toBe('CustomError');
});
it('should extract statusText for HTTP errors', () => {
const err = { statusText: 'Not Found' };
expect(getErrorTxt(err)).toBe('Not Found');
});
it('should never return [object Object]', () => {
const plainObject = { foo: 'bar' };
const result = getErrorTxt(plainObject);
expect(result).not.toBe('[object Object]');
expect(result).toContain('foo');
});
it('should JSON.stringify objects without standard error properties', () => {
const err = { code: 500, details: 'server error' };
const result = getErrorTxt(err);
expect(result).toContain('code');
expect(result).toContain('500');
});
it('should handle empty objects', () => {
const result = getErrorTxt({});
expect(result).toBe('Unknown error (unable to extract message)');
});
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
const err = {
[HANDLED_ERROR_PROP_STR]: 'handled',
message: 'regular message',
};
expect(getErrorTxt(err)).toBe('handled');
});
it('should handle TypeError instances', () => {
const err = new TypeError('Cannot read property of undefined');
expect(getErrorTxt(err)).toBe('Cannot read property of undefined');
});
it('should handle objects with custom toString that does not return [object Object]', () => {
const err = {
toString: () => 'custom error string',
};
expect(getErrorTxt(err)).toBe('custom error string');
});
it('should truncate long JSON strings', () => {
const longValue = 'x'.repeat(300);
const err = { data: longValue };
const result = getErrorTxt(err);
expect(result.length).toBeLessThanOrEqual(203); // 200 + '...'
});
it('should handle circular reference objects gracefully', () => {
const err: any = { message: null, data: {} };
err.data.self = err; // circular reference
const result = getErrorTxt(err);
// Should not throw and should not return [object Object]
expect(result).not.toBe('[object Object]');
expect(result).toBe('Unknown error (unable to extract message)');
});
it('should handle arrays', () => {
const result = getErrorTxt(['error1', 'error2']);
expect(result).not.toBe('[object Object]');
expect(result).toContain('error1');
});
it('should handle numbers', () => {
expect(getErrorTxt(404)).toBe('404');
});
it('should handle booleans', () => {
expect(getErrorTxt(false)).toBe('false');
});
it('should handle objects where toString throws', () => {
const err = {
toString: () => {
throw new Error('toString failed');
},
};
const result = getErrorTxt(err);
expect(result).not.toBe('[object Object]');
});
it('should handle empty string message', () => {
const err = { message: '' };
const result = getErrorTxt(err);
// Empty message falls through to JSON.stringify
expect(result).not.toBe('[object Object]');
expect(result).toContain('message');
});
it('should handle RangeError instances', () => {
const err = new RangeError('Maximum call stack size exceeded');
expect(getErrorTxt(err)).toBe('Maximum call stack size exceeded');
});
it('should handle SyntaxError instances', () => {
const err = new SyntaxError('Unexpected token');
expect(getErrorTxt(err)).toBe('Unexpected token');
});
it('should handle DOMException-like objects', () => {
const err = { name: 'NotFoundError', message: 'Node was not found' };
expect(getErrorTxt(err)).toBe('Node was not found');
});
it('should handle HTTP response error objects', () => {
const err = {
status: 500,
statusText: 'Internal Server Error',
error: { message: 'Database connection failed' },
};
expect(getErrorTxt(err)).toBe('Database connection failed');
});
it('should handle Axios-style error with response.data.error', () => {
const err = {
response: {
status: 401,
data: { error: 'Unauthorized access' },
},
};
const result = getErrorTxt(err);
// Should JSON.stringify since no direct message property
expect(result).toContain('Unauthorized');
});
});

View file

@ -1,24 +1,80 @@
import { isObject } from './is-object';
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
const OBJECT_OBJECT_STR = '[object Object]';
export const getErrorTxt = (err: unknown): string => {
if (err && isObject((err as any).error)) {
return (
(err as any).error.message ||
(err as any).error.name ||
// for ngx translate...
(isObject((err as any).error.error)
? (err as any).error.error.toString()
: (err as any).error) ||
(err as any).error
);
} else if (err && (err as any)[HANDLED_ERROR_PROP_STR]) {
return (err as any)[HANDLED_ERROR_PROP_STR];
} else if (err && (err as any).toString) {
return (err as any).toString();
} else if (typeof err === 'string') {
// Handle string errors directly
if (typeof err === 'string') {
return err;
} else {
return 'Unknown getErrorTxt error';
}
// Handle null/undefined
if (err == null) {
return 'Unknown error (null/undefined)';
}
const errAny = err as any;
// Check for handled error marker first
if (errAny[HANDLED_ERROR_PROP_STR]) {
return errAny[HANDLED_ERROR_PROP_STR];
}
// Check direct message property (standard Error objects)
if (typeof errAny.message === 'string' && errAny.message) {
return errAny.message;
}
// Check nested error.message (HttpErrorResponse pattern)
if (isObject(errAny.error)) {
if (typeof errAny.error.message === 'string' && errAny.error.message) {
return errAny.error.message;
}
if (typeof errAny.error.name === 'string' && errAny.error.name) {
return errAny.error.name;
}
// Handle deeper nesting (ngx-translate pattern)
if (isObject(errAny.error.error)) {
if (typeof errAny.error.error.message === 'string') {
return errAny.error.error.message;
}
}
}
// Check for name property (some Error subclasses)
if (typeof errAny.name === 'string' && errAny.name) {
return errAny.name;
}
// Check for statusText (HTTP errors)
if (typeof errAny.statusText === 'string' && errAny.statusText) {
return errAny.statusText;
}
// Try toString() but check for [object Object]
if (typeof errAny.toString === 'function') {
try {
const str = errAny.toString();
if (str && str !== OBJECT_OBJECT_STR) {
return str;
}
} catch {
// toString() threw - fall through to JSON.stringify
}
}
// Try JSON.stringify as last resort for objects
if (isObject(err)) {
try {
const jsonStr = JSON.stringify(err);
if (jsonStr && jsonStr !== '{}') {
return jsonStr.length > 200 ? jsonStr.substring(0, 200) + '...' : jsonStr;
}
} catch {
// Circular reference or other JSON error - fall through
}
}
return 'Unknown error (unable to extract message)';
};

View file

@ -0,0 +1,105 @@
import { fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
import { isOnline, isOnline$ } from './is-online';
describe('isOnline utilities', () => {
describe('isOnline()', () => {
it('should return true when navigator.onLine is true', () => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
expect(isOnline()).toBe(true);
});
it('should return true when navigator.onLine is undefined (not false)', () => {
spyOnProperty(navigator, 'onLine').and.returnValue(undefined as any);
expect(isOnline()).toBe(true);
});
it('should return false when navigator.onLine is false', () => {
spyOnProperty(navigator, 'onLine').and.returnValue(false);
expect(isOnline()).toBe(false);
});
});
describe('isOnline$', () => {
it('should emit a boolean value when subscribed', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// Due to shareReplay(1), a value may already be cached
// After debounce time, we should have a value
tick(1000);
expect(values.length).toBeGreaterThanOrEqual(1);
expect(typeof values[0]).toBe('boolean');
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should share the same stream across multiple subscribers (shareReplay)', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values1: boolean[] = [];
const values2: boolean[] = [];
const sub1 = isOnline$.subscribe((v) => values1.push(v));
const sub2 = isOnline$.subscribe((v) => values2.push(v));
tick(1000);
// Both subscribers should receive the same value
expect(values1).toEqual([true]);
expect(values2).toEqual([true]);
sub1.unsubscribe();
sub2.unsubscribe();
discardPeriodicTasks();
}));
it('should debounce rapid state changes', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
// Simulate rapid online/offline events (faster than debounce time)
window.dispatchEvent(new Event('offline'));
tick(200);
window.dispatchEvent(new Event('online'));
tick(200);
window.dispatchEvent(new Event('offline'));
tick(200);
window.dispatchEvent(new Event('online'));
// Still within debounce window, no emissions yet except possibly initial
tick(1000);
// After debounce, only the final state should be emitted
// The exact behavior depends on timing, but we verify no rapid flip-flopping
expect(values.length).toBeLessThanOrEqual(2);
sub.unsubscribe();
discardPeriodicTasks();
}));
it('should not emit duplicate values due to distinctUntilChanged', fakeAsync(() => {
spyOnProperty(navigator, 'onLine').and.returnValue(true);
const values: boolean[] = [];
const sub = isOnline$.subscribe((v) => values.push(v));
tick(1000);
expect(values).toEqual([true]);
// Dispatch online event when already online - should not emit duplicate
window.dispatchEvent(new Event('online'));
tick(1000);
// Still only one value due to distinctUntilChanged
expect(values).toEqual([true]);
sub.unsubscribe();
discardPeriodicTasks();
}));
});
});

View file

@ -1,5 +1,5 @@
import { fromEvent, merge, of } from 'rxjs';
import { mapTo } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, mapTo, shareReplay } from 'rxjs/operators';
export const isOnline = (): boolean => navigator.onLine !== false;
@ -7,9 +7,10 @@ export const isOnline$ = merge(
fromEvent(window, 'offline').pipe(mapTo(false)),
fromEvent(window, 'online').pipe(mapTo(true)),
of(navigator.onLine),
).pipe(
// Debounce to prevent rapid oscillations from triggering repeated banner changes
// This is especially important on Linux/Electron where navigator.onLine can be unreliable
debounceTime(1000),
distinctUntilChanged(),
shareReplay(1),
);
// NOTE this is not working, since we are not a singleton service
// .pipe(
// shareReplay(1),
// );

View file

@ -0,0 +1,170 @@
import { playSound } from './play-sound';
import { closeAudioContext } from './audio-context';
describe('playSound', () => {
let mockAudioContext: any;
let mockGainNode: any;
let mockBufferSource: any;
let mockAudioBuffer: AudioBuffer;
let originalAudioContext: typeof AudioContext;
let originalFetch: typeof window.fetch;
let fetchSpy: jasmine.Spy;
beforeEach(() => {
originalAudioContext = (window as any).AudioContext;
originalFetch = window.fetch;
mockGainNode = {
connect: jasmine.createSpy('connect'),
gain: { value: 1 },
};
mockBufferSource = {
connect: jasmine.createSpy('connect'),
start: jasmine.createSpy('start'),
buffer: null,
};
mockAudioBuffer = {} as AudioBuffer;
mockAudioContext = {
state: 'running',
resume: jasmine.createSpy('resume'),
close: jasmine.createSpy('close'),
createBufferSource: jasmine
.createSpy('createBufferSource')
.and.returnValue(mockBufferSource),
createGain: jasmine.createSpy('createGain').and.returnValue(mockGainNode),
destination: {} as AudioDestinationNode,
decodeAudioData: jasmine
.createSpy('decodeAudioData')
.and.callFake(() => Promise.resolve(mockAudioBuffer)),
};
(window as any).AudioContext = jasmine
.createSpy('AudioContext')
.and.returnValue(mockAudioContext);
// Create fetch spy by assigning a jasmine spy directly to window.fetch
fetchSpy = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
} as Response),
);
(window as any).fetch = fetchSpy;
// Reset the singleton and cache for each test
closeAudioContext();
});
afterEach(() => {
(window as any).AudioContext = originalAudioContext;
(window as any).fetch = originalFetch;
closeAudioContext();
});
it('should create an AudioContext', (done) => {
playSound('test.mp3');
setTimeout(() => {
expect((window as any).AudioContext).toHaveBeenCalled();
done();
}, 10);
});
it('should fetch the audio file', (done) => {
playSound('test.mp3');
setTimeout(() => {
expect(fetchSpy).toHaveBeenCalledWith('./assets/snd/test.mp3');
done();
}, 10);
});
it('should create a new buffer source for each playback', (done) => {
playSound('test.mp3');
setTimeout(() => {
expect(mockAudioContext.createBufferSource).toHaveBeenCalled();
done();
}, 10);
});
it('should start playback after buffer is assigned', (done) => {
playSound('test.mp3');
setTimeout(() => {
expect(mockBufferSource.start).toHaveBeenCalledWith(0);
done();
}, 10);
});
it('should connect directly to destination at full volume', (done) => {
playSound('test.mp3', 100);
setTimeout(() => {
expect(mockBufferSource.connect).toHaveBeenCalledWith(mockAudioContext.destination);
expect(mockAudioContext.createGain).not.toHaveBeenCalled();
done();
}, 10);
});
it('should use gain node for volume adjustment', (done) => {
playSound('test.mp3', 50);
setTimeout(() => {
expect(mockAudioContext.createGain).toHaveBeenCalled();
expect(mockGainNode.gain.value).toBe(0.5);
expect(mockBufferSource.connect).toHaveBeenCalledWith(mockGainNode);
expect(mockGainNode.connect).toHaveBeenCalledWith(mockAudioContext.destination);
done();
}, 10);
});
it('should handle errors gracefully', (done) => {
const consoleErrorSpy = spyOn(console, 'error');
fetchSpy.and.returnValue(Promise.reject(new Error('Test error')));
playSound('nonexistent.mp3');
setTimeout(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
done();
}, 10);
});
it('should reuse the same AudioContext for multiple sounds', (done) => {
playSound('test1.mp3');
setTimeout(() => {
playSound('test2.mp3');
setTimeout(() => {
// AudioContext should only be created once
expect((window as any).AudioContext).toHaveBeenCalledTimes(1);
done();
}, 10);
}, 10);
});
it('should cache audio buffers and not re-fetch', (done) => {
playSound('cached-test.mp3');
setTimeout(() => {
// Reset createBufferSource call count to verify it's called again
mockAudioContext.createBufferSource.calls.reset();
mockBufferSource.connect.calls.reset();
mockBufferSource.start.calls.reset();
playSound('cached-test.mp3');
setTimeout(() => {
// Fetch should only be called once for the same file
expect(fetchSpy).toHaveBeenCalledTimes(1);
// But we should still create a new buffer source (required by Web Audio API)
expect(mockAudioContext.createBufferSource).toHaveBeenCalled();
done();
}, 10);
}, 10);
});
});

View file

@ -1,35 +1,35 @@
import { getAudioBuffer, getAudioContext } from './audio-context';
const BASE = './assets/snd';
/**
* Plays a sound file at the specified volume.
* Uses a singleton AudioContext and caches audio buffers to prevent resource leaks.
*
* @param filePath - Path to the sound file relative to assets/snd
* @param vol - Volume level from 0 to 100 (default: 100)
*/
export const playSound = (filePath: string, vol = 100): void => {
const file = `${BASE}/${filePath}`;
const audioCtx = new ((window as any).AudioContext ||
(window as any).webkitAudioContext)();
const source = audioCtx.createBufferSource();
const request = new XMLHttpRequest();
request.open('GET', file, true);
request.responseType = 'arraybuffer';
request.onload = () => {
const audioData = request.response;
audioCtx.decodeAudioData(
audioData,
(buffer: AudioBuffer) => {
source.buffer = buffer;
getAudioBuffer(file)
.then((buffer) => {
const audioCtx = getAudioContext();
const source = audioCtx.createBufferSource();
source.buffer = buffer;
if (vol !== 100) {
const gainNode = audioCtx.createGain();
gainNode.gain.value = vol / 100;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
} else {
source.connect(audioCtx.destination);
}
},
(e: DOMException) => {
throw new Error('Error with decoding audio data SP: ' + e.message);
},
);
};
request.send();
source.start(0);
if (vol !== 100) {
const gainNode = audioCtx.createGain();
gainNode.gain.value = vol / 100;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
} else {
source.connect(audioCtx.destination);
}
source.start(0);
})
.catch((e) => {
console.error('Error playing sound:', e);
});
};

View file

@ -1540,6 +1540,7 @@
"FILTER_BY": "تصفية حسب",
"FILTER_DEFAULT": "لا يوجد مرشح",
"FILTER_ESTIMATED_TIME": "الوقت المقدر",
"FILTER_NOT_SPECIFIED": "غير محدد",
"FILTER_PROJECT": "مشروع",
"FILTER_SCHEDULED_DATE": "التاريخ المحدد",
"FILTER_TAG": "العلامه",
@ -1567,7 +1568,6 @@
"TIME_2HOUR": "> 2 ساعة",
"TIME_10MIN": "> 10 دقائق",
"TIME_30MIN": "> 30 دقيقة",
"FILTER_NOT_SPECIFIED": "غير محدد",
"TIME_SPENT": "الوقت المستغرق",
"TITLE": "تخصيص عرض المهام"
}
@ -1883,8 +1883,6 @@
"IS_DARK_MODE": "وضع الظلام",
"IS_DISABLE_ANIMATIONS": "تعطيل جميع الرسوم المتحركة",
"IS_DISABLE_CELEBRATION": "تعطيل الاحتفال في ملخص اليوم",
"USER_PROFILES": "تمكين ملفات تعريف المستخدمين (النسخة التجريبية)",
"USER_PROFILES_HINT": "يسمح لك بإنشاء وتبديل ملفات تعريف مستخدم مختلفة، كل منها بإعدادات ومهام وتزامن منفصلة. سيظهر زر إدارة الملف الشخصي في الزاوية العلوية اليمنى عند تفعيله. ملاحظة: تعطيل هذه الميزة سيخفي واجهة المستخدم لكنه يحافظ على بيانات ملفك الشخصي (ميزة بيتا، لا ضمانات). تأكد من وجود نسخة احتياطية).",
"IS_HIDE_NAV": "إخفاء التنقل حتى يتم تمرير الرأس الرئيسي (سطح المكتب فقط)",
"IS_MINIMIZE_TO_TRAY": "تصغير إلى درج (سطح المكتب فقط)",
"IS_OVERLAY_INDICATOR_ENABLED": "تفعيل نافذة مؤشر التراكب (لينكس مكتبي/جنوم)",
@ -1899,7 +1897,9 @@
"THEME": "موضوع",
"THEME_EXPERIMENTAL": "الموضوع (تجريبي)",
"THEME_SELECT_LABEL": "اختيار الموضوع",
"TITLE": "متفرقات الإعدادات"
"TITLE": "متفرقات الإعدادات",
"USER_PROFILES": "تمكين ملفات تعريف المستخدمين (النسخة التجريبية)",
"USER_PROFILES_HINT": "يسمح لك بإنشاء وتبديل ملفات تعريف مستخدم مختلفة، كل منها بإعدادات ومهام وتزامن منفصلة. سيظهر زر إدارة الملف الشخصي في الزاوية العلوية اليمنى عند تفعيله. ملاحظة: تعطيل هذه الميزة سيخفي واجهة المستخدم لكنه يحافظ على بيانات ملفك الشخصي (ميزة بيتا، لا ضمانات). تأكد من وجود نسخة احتياطية)."
},
"POMODORO": {
"BREAK_DURATION": "مدة الاستراحة القصيرة",

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "z.B., Natur, Berge, abstrakt",
"TITLE": "Hintergrundbild von Unsplash auswählen"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Über GitHub Sponsors spenden",
"INTRO_1": "Super Productivity wird vollständig von der Community finanziert. Es gibt kein Tracking, keine Werbung und keine Datenerfassung deine Aufgaben bleiben auf deinem Gerät.",
"INTRO_2": "Wenn du diesen Ansatz schätzt und das Projekt gesund und weiterentwickelt halten möchtest, wird eine freiwillige Spende sehr geschätzt."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -157,6 +162,7 @@
"BANNER": {
"ADD_AS_TASK": "Als Aufgabe hinzufügen",
"FOCUS_TASK": "Fokusaufgabe",
"SHOW_TASK": "Aufgabe anzeigen",
"TXT": "<strong>{{title}}</strong> beginnt um <strong>{{start}}</strong> !",
"TXT_MULTIPLE": "<strong>{{title}}</strong> beginnt um <strong>{{start}}</strong> !<br> (und {{nrOfOtherBanners}} weitere Ereignisse sind fällig)",
"TXT_PAST": "<strong>{{title}}</strong> startete um <strong>{{start}}</strong> !",
@ -209,15 +215,23 @@
}
},
"FOCUS_MODE": {
"ADD_TIME_MINUTE": "1 Minute hinzufügen",
"B": {
"BREAK_RUNNING": "Die Pause läuft",
"END_BREAK": "Pause beenden",
"END_SESSION": "Sitzung beenden",
"PAUSE": "Pause",
"POMODORO_BREAK_RUNNING": "Die Pause #{{cycleNr}} läuft",
"POMODORO_SESSION_RUNNING": "Pomodoro Sitzung #{{cycleNr}} läuft",
"RESUME": "Lebenslauf",
"SESSION_RUNNING": "Fokussierungssitzung läuft",
"START": "Start",
"TO_FOCUS_OVERLAY": "Zur Fokussierungsüberlagerung"
},
"BACK_TO_PLANNING": "Zurück zur Planung",
"BREAK_RELAX_MSG": "Nehmen Sie sich einen Moment Zeit zum Entspannen",
"CLICK_TO_EDIT_DURATION": "Klicke, um die Dauer zu bearbeiten",
"COMPLETE_FOCUS_SESSION": "Fokussierte Sitzung abschließen",
"COMPLETE_SESSION": "Vollständige Sitzung",
"CONGRATS": "Herzlichen Glückwunsch zum Abschluss dieser Sitzung!",
"CONTINUE_SESSION": "Sitzung fortsetzen",
@ -238,23 +252,32 @@
"NEXT": "Weiter",
"ON": "An",
"OPEN_ISSUE_IN_BROWSER": "Issue im Browser öffnen",
"PAUSE_SESSION": "Sitzung pausieren",
"PAUSE_TRACKING": "Tracking pausieren",
"PAUSE_TRACKING_FOR_CURRENT_TASK": "Tracking für aktuelle Aufgabe pausieren",
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Strukturierte Sprints mit geplanten Pausen",
"POMODORO_SESSION_COMPLETED": "Pomodoro Session Completed!",
"POMODORO_SETTINGS": "Pomodoro-Einstellungen",
"PREP_GET_MENTALLY_READY": "Machen Sie sich mental bereit, konzentriert und produktiv zu sein",
"PREP_SIT_UPRIGHT": "Sitzen (oder stehen) Sie aufrecht",
"PREP_STRETCH": "Machen Sie leichte Dehnübungen",
"REMOVE_TIME_MINUTE": "1 Minute entfernen",
"RESUME_SESSION": "Sitzung fortsetzen",
"SELECT_ANOTHER_TASK": "Wählen Sie eine andere Aufgabe aus",
"SELECT_MODE": "Wählen Sie Ihren Fokusmodus",
"SELECT_TASK": "Wählen Sie die Aufgabe aus, auf die Sie sich konzentrieren möchten",
"SELECT_TASK_TO_FOCUS": "Aufgabe zum Fokussieren auswählen",
"SESSION_COMPLETED": "Fokussierungssitzung abgeschlossen!",
"SET_FOCUS_SESSION_DURATION": "Legen Sie die Dauer der Fokussitzung fest",
"SHORT_BREAK": "Kurze Pause",
"SHORT_BREAK_TITLE": "Kurze Pause - Zyklus {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Aufgabennotizen und Anhänge ein-/ausblenden",
"SKIP_BREAK": "Pause überspringen",
"START_BREAK": "Pause starten",
"START_FOCUS_SESSION": "Starten Sie die Fokussitzung",
"START_NEXT_FOCUS_SESSION": "Starten Sie die nächste Fokussitzung",
"SWITCH_TASK": "Aufgabe wechseln",
"WORKED_FOR": "Sie arbeiten bereits seit"
},
"GITEA": {
@ -433,6 +456,11 @@
"POLLING_CHANGES": "{{issueProviderName}}: Polling-Änderungen für {{issuesStr}}"
}
},
"ISSUE_PANEL": {
"CALENDAR_AGENDA": {
"OFFLINE_BANNER_MSG": "Zwischengespeicherte Kalenderergebnisse werden angezeigt. Daten könnten veraltet sein."
}
},
"JIRA": {
"BANNER": {
"BLOCK_ACCESS_MSG": "Jira: Um zu verhindern, dass das API geschlossen wird, wurde der Zugriff von Super Productivity blockiert. Sie sollten wahrscheinlich Ihre Jira-Einstellungen überprüfen!",
@ -631,10 +659,31 @@
},
"FOCUS_SESSION_DIALOG": {
"ADD_BTN": "Hinzufügen",
"ADD_SESSION": "Sitzung hinzufügen",
"CHART_LABEL": "Fokuszeit (Minuten)",
"NEW_SESSION_DURATION": "Dauer",
"NO_SESSIONS": "Keine Fokussitzungen für diesen Tag",
"SESSIONS_LIST": "Sitzungen",
"TITLE": "Fokussitzungen",
"TOTAL_SESSIONS": "Gesamtsitzungen",
"TOTAL_TIME": "Gesamtzeit"
},
"REFLECTION": {
"HISTORY_BTN": "Vergangene Reflexionen ansehen",
"HISTORY_EMPTY": "Noch keine Reflexionen gespeichert. Erfasse die heutige Notiz, um eine Serie zu starten.",
"HISTORY_TITLE": "Reflexionsverlauf",
"PLACEHOLDER_1": "Was hat heute gut funktioniert? Was sollte sich morgen ändern?",
"PLACEHOLDER_2": "Kleine Verbesserung für morgen?",
"PLACEHOLDER_3": "Wo ist der Fokus verloren gegangen und wie kannst du ihn schützen?",
"PLACEHOLDER_4": "Welche Aufgabe hat dir Energie gegeben? Welche hat sie geraubt?",
"PLACEHOLDER_5": "Eine Sache zum Wiederholen. Eine Sache zum Ändern.",
"REMIND_LABEL": "Erinnere mich morgen daran",
"REMINDER_CREATED": "Erinnerung zur Reflexion für morgen geplant",
"REMINDER_ERROR": "Erinnerung konnte nicht geplant werden",
"REMINDER_NEEDS_TEXT": "Schreibe eine Reflexion, bevor du um eine Erinnerung bittest",
"REMINDER_TASK_TITLE": "Reflexionsnotiz erneut ansehen",
"TITLE": "Reflexionsnotiz"
},
"S": {
"SAVE_METRIC": "Metrik erfolgreich gespeichert"
}
@ -805,6 +854,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Abbrechen",
"MSG": "Dieses Projekt ist ziemlich groß und könnte eine Weile dauern, um es zu duplizieren. Möchtest du fortfahren?",
"OK": "Trotzdem duplizieren",
"TITLE": "Projekt duplizieren?"
},
"D_CREATE": {
"CREATE": "Projekt erstellen",
"EDIT": "Projekt bearbeiten",
@ -908,7 +963,8 @@
}
},
"REFLECTION_NOTE": {
"ACTION_DISMISS": "Verwerfen"
"ACTION_DISMISS": "Verwerfen",
"MSG": "Reflexionsnotiz: ({{date}}): {{content}}"
},
"REMINDER": {
"COUNTDOWN_BANNER": {
@ -1087,11 +1143,16 @@
"TITLE": "Sync",
"WEB_DAV": {
"CORS_INFO": "<p><strong>Experimental!!</strong> <strong>Damit dies für Mobil oder den Browser funktioniert, müssen Sie Super Productivity für CORS-Anfragen bei Ihrer Nextcloud-Instanz auf die Whitelist setzen</strong>, dies kann negative Auswirkungen auf die Sicherheit haben! Bitte beziehen Sie sich <a href='https://github.com/nextcloud/server/issues/3131'>auf diesen Thread</a> für weitere Informationen. Ein Ansatz, um dies auch mobil zu ermöglichen, ist das Whitelisting \"https://app.super-productivity.com\" über die Nextcloud-App <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Benutzung auf eigene Gefahr!",
"D_SYNC_FOLDER_PATH": "Pfad relativ zum WebDAV-Server-Stamm, in dem die Synchronisationsdateien gespeichert werden (z.B. '\\/super-productivity' oder '\\/'). Dies ist NICHT der interne Verzeichnispfad Ihres Servers.",
"INFO": "Leider unterscheiden sich WebDAV-Implementierungen stark voneinander. Super Productivity funktioniert bekanntermaßen gut mit Nextcloud, <strong>aber möglicherweise nicht mit Ihrem Anbieter</strong>.",
"L_BASE_URL": "Basis-URL",
"L_PASSWORD": "Passwort",
"L_SYNC_FOLDER_PATH": "Sync-Ordnerpfad",
"L_USER_NAME": "Nutzername"
"L_TEST_CONNECTION": "Verbindung testen",
"L_USER_NAME": "Nutzername",
"S_FILL_ALL_FIELDS": "Bitte füllen Sie zuerst alle WebDAV-Felder aus",
"S_TEST_FAIL": "Verbindungstest fehlgeschlagen: {{error}} - Ziel-URL: {{url}}",
"S_TEST_SUCCESS": "Verbindungstest erfolgreich! Ziel-URL: {{url}}"
}
},
"S": {
@ -1104,6 +1165,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Beim Importieren der Daten ist ein Fehler aufgetreten. Zurückgreifen auf lokales Backup.",
"ERROR_INVALID_DATA": "Fehler beim Synchronisieren. Ungültige Daten",
"ERROR_NO_REV": "Keine gültige rev für entfernte Datei",
"ERROR_PERMISSION": "Dateizugriff verweigert. Bitte überprüfen Sie Ihre Dateisystemberechtigungen.",
"ERROR_PERMISSION_FLATPAK": "Dateizugriff verweigert. Gewähren Sie Dateisystemberechtigungen über Flatseal oder verwenden Sie einen Pfad innerhalb von ~\\/var\\/app\\/",
"ERROR_PERMISSION_SNAP": "Dateizugriff verweigert. Führen Sie 'snap connect super-productivity:home' aus oder verwenden Sie einen Pfad innerhalb von ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Fehler beim Synchronisieren. Remote-Daten können nicht gelesen werden. Vielleicht haben Sie die Verschlüsselung aktiviert und Ihr lokales Passwort stimmt nicht mit dem überein, das zur Verschlüsselung der Remote-Daten verwendet wurde?",
"IMPORTING": "Daten importieren",
"INCOMPLETE_CFG": "Die Authentifizierung für die Synchronisierung ist fehlgeschlagen. Bitte überprüfen Sie Ihre Konfiguration!",
@ -1487,6 +1551,7 @@
"FILTER_BY": "Nach filtern",
"FILTER_DEFAULT": "Kein Filter",
"FILTER_ESTIMATED_TIME": "Geschätzte Zeit",
"FILTER_NOT_SPECIFIED": "Nicht angegeben",
"FILTER_PROJECT": "Projekt",
"FILTER_SCHEDULED_DATE": "Geplantes Datum",
"FILTER_TAG": "Tag",
@ -1513,7 +1578,6 @@
"TIME_2HOUR": "> 2 Stunden",
"TIME_10MIN": "> 10 Minuten",
"TIME_30MIN": "> 30 Minuten",
"FILTER_NOT_SPECIFIED": "Nicht angegeben",
"TIME_SPENT": "Aufgewendete Zeit",
"TITLE": "Aufgabenansicht anpassen"
}
@ -1647,6 +1711,7 @@
"DISMISS": "Verwerfen",
"DO_IT": "Tu es!",
"DONT_SHOW_AGAIN": "Nicht mehr anzeigen",
"DUPLICATE": "Duplizieren",
"DURATION_DESCRIPTION": "z. B. \"5h 23m\" was in 5 Stunden und 23 Minuten resultiert",
"EDIT": "Bearbeiten",
"ENABLED": "Aktiviert",
@ -1679,6 +1744,22 @@
"YESTERDAY": "Gestern"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Bretter",
"DONATE_PAGE": "Spenden-Seite",
"FOCUS_MODE": "Fokus-Modus",
"HELP": "Aktivieren oder deaktivieren Sie bestimmte App-Funktionen in der gesamten Benutzeroberfläche.",
"ISSUES_PANEL": "Probleme-Panel",
"PLANNER": "Planer",
"PROJECT_NOTES": "Projektnotizen",
"SCHEDULE": "Zeitplan",
"SCHEDULE_DAY_PANEL": "Tagesplan-Panel",
"SYNC_BUTTON": "Synchronisations-Schaltfläche",
"TIME_TRACKING": "Stoppuhr-Zeiterfassung",
"TITLE": "App-Funktionen",
"USER_PROFILES": "Benutzerprofile (Experimentell)",
"USER_PROFILES_HINT": "Ermöglicht das Erstellen und Wechseln zwischen verschiedenen Benutzerprofilen, jeweils mit separaten Einstellungen, Aufgaben und Synchronisationskonfigurationen. Die Schaltfläche zur Profilverwaltung erscheint oben rechts, wenn aktiviert. Hinweis: Das Deaktivieren dieser Funktion blendet die Benutzeroberfläche aus, bewahrt jedoch Ihre Profildaten (Experimentelle Funktion, keine Garantien. Bitte sichern Sie Ihre Daten)."
},
"AUTO_BACKUPS": {
"HELP": "Speichern Sie alle Daten automatisch in Ihrem App-Ordner, um sie für den Fall, dass ein Fehler auftritt, bereit zu halten.",
"LABEL_IS_ENABLED": "Aktivieren Sie automatische Sicherungen",
@ -1701,7 +1782,12 @@
"FOCUS_MODE": {
"HELP": "Der Fokusmodus öffnet einen ablenkungsfreien Bildschirm, damit Sie sich auf Ihre aktuelle Aufgabe konzentrieren können.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Öffnen Sie beim Zeiterfassen immer den Fokusmodus",
"L_IS_PLAY_TICK": "Tick-Geräusch während Fokus-Sitzungen abspielen",
"L_MANUAL_BREAK_START": "Pausen manuell starten (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Aufgabenverfolgung während Pausen pausieren",
"L_SKIP_PREPARATION_SCREEN": "Vorbereitungsbildschirm überspringen (Dehnen usw.)",
"L_START_IN_BACKGROUND": "Fokus-Sitzungen nur mit Banner starten (kein Overlay)",
"L_SYNC_SESSION_WITH_TRACKING": "Fokus-Sitzung mit Zeiterfassung synchronisieren",
"TITLE": "Fokusmodus"
},
"IDLE": {
@ -1790,6 +1876,7 @@
"NL": "Niederländisch",
"PL": "Polnisch",
"PT": "Portugiesisch",
"PT_BR": "Portugiesisch (Brasilien)",
"RU": "Russisch",
"SK": "Slowakisch",
"TIME_LOCALE": "Zeitformat Gebietsschema",
@ -1820,6 +1907,7 @@
"DARK_MODE_LIGHT": "Hell",
"DARK_MODE_SYSTEM": "System",
"DEFAULT_PROJECT": "Standardprojekt für Aufgaben, wenn keines angegeben ist",
"DEFAULT_START_PAGE": "Standard-Startseite",
"FIRST_DAY_OF_WEEK": "Erster Tag der Woche",
"HELP": "<p><strong>Desktop-Benachrichtigungen werden nicht angezeigt?</strong> Unter Windows sollten Sie unter System -> Benachrichtigungen und Aktionen prüfen, ob die erforderlichen Benachrichtigungen aktiviert wurden.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Fügen Sie heute automatisch das Tag \"Heute\" hinzu, um an Aufgaben zu arbeiten",
@ -1829,8 +1917,6 @@
"IS_DARK_MODE": "Dunkler Modus",
"IS_DISABLE_ANIMATIONS": "Deaktiviere alle Animationen",
"IS_DISABLE_CELEBRATION": "Feier im Tagesbericht deaktivieren",
"USER_PROFILES": "Benutzerprofile aktivieren (Beta)",
"USER_PROFILES_HINT": "Ermöglicht das Erstellen und Wechseln zwischen verschiedenen Benutzerprofilen mit jeweils separaten Einstellungen, Aufgaben und Synchronisierungskonfigurationen. Die Profilverwaltungsschaltfläche wird in der oberen rechten Ecke angezeigt, wenn aktiviert. Hinweis: Das Deaktivieren dieser Funktion blendet die Benutzeroberfläche aus, behält aber Ihre Profildaten bei (Beta-Funktion, keine Garantien. Stellen Sie sicher, dass Sie ein Backup haben).",
"IS_HIDE_NAV": "Navigation verbergen, bis die Hauptüberschrift angezeigt wird (nur Desktop)",
"IS_MINIMIZE_TO_TRAY": "Anwendung als Trayicon minimieren (nur Deskop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)",
@ -1838,13 +1924,17 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Aktuellen Countdown im Tray / Statusmenü anzeigen (nur Desktop Mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Aktuelle Aufgabe im Tray / Status-Menu zeigen (nur Desktop)",
"IS_TURN_OFF_MARKDOWN": "Deaktivieren Sie das Markdown-Parsing für Notizen",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Benutzerdefinierte Titelleiste verwenden (nur Windows/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Neustart erforderlich, damit die Änderung wirksam wird",
"START_OF_NEXT_DAY": "Startzeit des nächsten Tages",
"START_OF_NEXT_DAY_HINT": "Ab wann (in Stunden) der nächste Tag beginnen. Der Standardwert ist Mitternacht, also 0.",
"TASK_NOTES_TPL": "Aufgabenbeschreibungsvorlage",
"THEME": "Thema",
"THEME_EXPERIMENTAL": "Thema (experimentell)",
"THEME_SELECT_LABEL": "Thema auswählen",
"TITLE": "Verschiedene Einstellungen"
"TITLE": "Verschiedene Einstellungen",
"USER_PROFILES": "Benutzerprofile aktivieren (Beta)",
"USER_PROFILES_HINT": "Ermöglicht das Erstellen und Wechseln zwischen verschiedenen Benutzerprofilen mit jeweils separaten Einstellungen, Aufgaben und Synchronisierungskonfigurationen. Die Profilverwaltungsschaltfläche wird in der oberen rechten Ecke angezeigt, wenn aktiviert. Hinweis: Das Deaktivieren dieser Funktion blendet die Benutzeroberfläche aus, behält aber Ihre Profildaten bei (Beta-Funktion, keine Garantien. Stellen Sie sicher, dass Sie ein Backup haben)."
},
"POMODORO": {
"BREAK_DURATION": "Dauer der kurzen Pausen",
@ -1855,6 +1945,7 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Zeigen Sie Banner vor der eigentlichen Erinnerung an",
"DEFAULT_TASK_REMIND_OPTION": "Standardmäßig ausgewählte Erinnerungsoption beim Erstellen von Aufgaben",
"DISABLE_REMINDERS": "Alle Erinnerungen deaktivieren",
"IS_COUNTDOWN_BANNER_ENABLED": "Countdown-Banner anzeigen, bevor Erinnerungen fällig werden",
"TITLE": "Erinnerungen"
},
@ -1917,6 +2008,9 @@
"TITLE": "Zeiterfassung"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (Kopie)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "in einem Tag",
@ -1947,18 +2041,22 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "In die Zwischenablage kopiert",
"DUPLICATE_PROJECT_ERROR": "Das Projekt konnte nicht dupliziert werden",
"DUPLICATE_PROJECT_SUCCESS": "Projekt erfolgreich dupliziert",
"ERR_COMPRESSION": "Fehler bei der Komprimierung",
"FILE_DOWNLOADED": "{{fileName}} heruntergeladen",
"FILE_DOWNLOADED_BTN": "Ordner öffnen",
"NAVIGATE_TO_TASK_ERR": "Konnte den Fokusmodus für die Aufgabe nicht startet. Wurde die Aufgabe gelöscht?",
"NO_TASKS_TO_COPY": "Keine Aufgaben zum Kopieren",
"NO_TASKS_TO_UNPLAN": "Keine Aufgaben zum Entplanen",
"PERSISTENCE_DISALLOWED": "Daten werden nicht dauerhaft gespeichert. Beachten Sie, dass dies zu Datenverlust führen kann !!",
"PERSISTENCE_ERROR": "Fehler beim Anfordern, Daten beizubehalten: {{err}}",
"RUNNING_X": "Wird gestartet: \"{{str}}\".",
"SHARE_FAILED": "Das Teilen ist fehlgeschlagen. Bitte kopieren Sie es manuell.",
"SHARE_FAILED_FALLBACK": "Das Teilen ist fehlgeschlagen. Stattdessen in die Zwischenablage kopiert.",
"SHARE_UNAVAILABLE_FALLBACK": "In die Zwischenablage kopiert.",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} gedrückt, aber die Verknüpfung zum Öffnen von Lesezeichen ist nur im Projektkontext verfügbar."
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} gedrückt, aber die Verknüpfung zum Öffnen von Lesezeichen ist nur im Projektkontext verfügbar.",
"UNPLANNED_TODAY_TASKS": "Alle Aufgaben von Heute entplant"
},
"GPB": {
"ASSETS": "Laden von Assets ...",
@ -1982,6 +2080,8 @@
"CREATE_TAG": "Stichwort erstellen",
"DELETE_PROJECT": "Projekt löschen",
"DELETE_TAG": "Stichwort löschen",
"DONATE": "Unterstützen Sie uns",
"DUPLICATE_PROJECT": "Duplikat",
"ENTER_FOCUS_MODE": "Fokusmodus aktivieren",
"GO_TO_TASK_LIST": "Gehe zur Aufgabenliste",
"HELP": "Hilfe",
@ -1998,6 +2098,7 @@
"METRICS": "Metriken",
"NO_PROJECT_INFO": "Keine Projekte verfügbar. Sie können ein neues Projekt erstellen, indem Sie auf die Schaltfläche \"Projekt erstellen\" klicken.",
"NO_TAG_INFO": "Derzeit sind keine Tags vorhanden. Sie können Tags hinzufügen, indem Sie beim Hinzufügen oder Bearbeiten von Aufgaben „#IhrNeuesTag“ eingeben.",
"NO_TASKS_TO_TRACK": "Fügen Sie zuerst eine Aufgabe hinzu, um die Zeit zu verfolgen",
"NOTES": "Anmerkungen",
"NOTES_PANEL_INFO": "Notizen können nur von der Schedule und der normalen Aufgabenliste angezeigt werden.",
"PLANNER": "Planer",
@ -2020,6 +2121,7 @@
"TOGGLE_SHOW_NOTES": "Projektnotizen ein- / ausblenden",
"TOGGLE_TRACK_TIME": "Tracking-Zeit starten / stoppen",
"TRIGGER_SYNC": "Synchronisierung manuell auslösen",
"UNPLAN_ALL_TASKS": "Alle Aufgaben entplanen",
"WORKLOG": "Worklog"
},
"MIGRATE": {
@ -2044,6 +2146,7 @@
"MSG": "Anwendung beenden?",
"OK": "Beenden"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Sie können diesen Bereich nutzen, um Ihre eigenen Endzeitrituale aufzuschreiben, an die Sie erinnert werden möchten.",
"ESTIMATE_TOTAL": "Gesamtschätzung:",
"EVALUATE_DAY": "Bewerten Sie",
"EXPORT_TASK_LIST": "Aufgabenliste exportieren",

View file

@ -34,7 +34,7 @@
},
"CONFIRM": {
"AUTO_FIX": "Your data seems to be damaged (\"{{validityError}}\"). Do you want to try to automatically fix it? This might result in partial data loss.",
"RELOAD_AFTER_IDB_ERROR": "Cannot access database :( Possible causes are an app update to the app in the background or low disk space. If you installed the app on linux as snap you also want to enable refresh awareness 'snap set core experimental.refresh-app-awareness=true' until they fix this issue on their side. Press OK to reload the app (might require manual restarting the app on some platforms).",
"RELOAD_AFTER_IDB_ERROR": "Database Error - App Will Restart\n\nSuper Productivity cannot save data. This is usually caused by:\n• Low disk space (most common)\n• App update in background\n• Linux Snap users: run 'snap set core experimental.refresh-app-awareness=true'\n\nYour recent changes may not have been saved. Please free up disk space if low. The app will restart after you close this dialog.",
"RESTORE_FILE_BACKUP": "There seems to be NO DATA, but there are backups available at \"{{dir}}\". Do you want to restore the latest backup from {{from}}?",
"RESTORE_FILE_BACKUP_ANDROID": "There seems to be NO DATA, but there is a backup available. Do you want to load it?",
"RESTORE_STRAY_BACKUP": "During last sync there might have been some error. Do you want to restore the last backup?"
@ -161,6 +161,7 @@
"CALENDARS": {
"BANNER": {
"ADD_AS_TASK": "Add as Task",
"FOCUS_TASK": "",
"SHOW_TASK": "Show Task",
"TXT": "<strong>{{title}}</strong> starts at <strong>{{start}}</strong>!",
"TXT_MULTIPLE": "<strong>{{title}}</strong> starts at <strong>{{start}}</strong>!<br> (and {{nrOfOtherBanners}} other events are due)",
@ -233,6 +234,7 @@
"COMPLETE_FOCUS_SESSION": "Complete focus session",
"COMPLETE_SESSION": "Complete Session",
"CONGRATS": "Congrats for completing this session!",
"CONTINUE_FOCUS_SESSION": "",
"CONTINUE_SESSION": "Continue Session",
"CONTINUE_TO_NEXT_SESSION": "Continue to next Session",
"COUNTDOWN": "Countdown",
@ -273,6 +275,7 @@
"SHORT_BREAK_TITLE": "Short Break - Cycle {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Show/hide task notes and attachments",
"SKIP_BREAK": "Skip Break",
"START_BREAK": "Start Break",
"START_FOCUS_SESSION": "Start focus session",
"START_NEXT_FOCUS_SESSION": "Start next Focus Session",
"SWITCH_TASK": "Switch task",
@ -630,11 +633,25 @@
"ADD_NOTE_FOR_TOMORROW": "Add Note for tomorrow",
"DAILY_STATE": "Daily State",
"DAILY_STATE_TOOLTIP": "Quadrant system based on both scores (≥50 is high): Deep Flow (high/high), Overdrive (high/low), Recovery (low/high), Drift (low/low)",
"DISABLE_REPEAT_EVERY_DAY": "",
"ENABLE_REPEAT_EVERY_DAY": "",
"ENERGY_LEVEL": "How's your energy?",
"ENERGY_LEVEL_HINT": "😫 Exhausted 😐 OK 😊 Good",
"FOCUS_WORK_TIME": "Focussed work time",
"HELP_H1": "",
"HELP_LINK_TXT": "",
"HELP_P1": "",
"HELP_P2": "",
"IMPACT_OF_WORK": "How do you rate the impact of your work today?",
"IMPACT_OF_WORK_HINT": "1: No meaningful progress 4: Significant impact",
"IMPROVEMENTS": "",
"IMPROVEMENTS_TOMORROW": "",
"MOOD": "",
"MOOD_HINT": "",
"NOTES": "",
"OBSTRUCTIONS": "",
"PRODUCTIVITY": "",
"PRODUCTIVITY_HINT": "",
"PRODUCTIVITY_SCORE": "Productivity Score",
"PRODUCTIVITY_SCORE_TOOLTIP": "65% Impact (1-4 scale) • 30% Focus toward 4h target • 5% total work (capped at 10h)",
"SCORE_BREAKDOWN_TITLE_PRODUCTIVITY": "7-Day Productivity Breakdown",
@ -853,10 +870,10 @@
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"TITLE": "Duplicate Project?",
"CANCEL": "Cancel",
"MSG": "This project is quite large and might take a while to duplicate. Do you want to proceed?",
"OK": "Duplicate Anyway",
"CANCEL": "Cancel"
"TITLE": "Duplicate Project?"
},
"D_CREATE": {
"CREATE": "Create Project",
@ -974,6 +991,40 @@
"S_ACTIVE_TASK_DUE": "The task you are currently working on is now due!<br/> ({{title}})",
"S_REMINDER_ERR": "Error for reminder interface"
},
"SAFETY_BACKUP": {
"BACKUP_NOT_FOUND": "",
"BTN_CLEAR_ALL": "",
"BTN_CREATE_MANUAL": "",
"BTN_DELETE": "",
"BTN_REFRESH": "",
"BTN_RESTORE": "",
"CLEAR_FAILED": "",
"CLEARED_SUCCESS": "",
"CREATE_FAILED": "",
"CREATED_SUCCESS": "",
"DELETE_FAILED": "",
"DELETED_SUCCESS": "",
"DESCRIPTION": "",
"INVALID_ID_ERROR": "",
"LAST_CHANGE_PREFIX": "",
"LOADING": "",
"NO_BACKUPS": "",
"REASON_BEFORE_UPDATE": "",
"REASON_MANUAL": "",
"RESTORE_CONFIRM_MSG": "",
"RESTORE_CONFIRM_TITLE": "",
"RESTORE_FAILED": "",
"RESTORED_SUCCESS": "",
"SLOT_BEFORE_TODAY": "",
"SLOT_RECENT": "",
"SLOT_TODAY": "",
"TITLE": "",
"TOOLTIP_CLEAR_ALL": "",
"TOOLTIP_CREATE_MANUAL": "",
"TOOLTIP_DELETE": "",
"TOOLTIP_REFRESH": "",
"TOOLTIP_RESTORE": ""
},
"SCHEDULE": {
"CONTINUED": "continued",
"D_INITIAL": {
@ -1207,12 +1258,12 @@
"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. Please check your filesystem permissions.",
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
"ERROR_FALLBACK_TO_BACKUP": "Something went wrong while importing the data. Falling back to local backup.",
"ERROR_INVALID_DATA": "Error while syncing. Invalid data",
"ERROR_NO_REV": "No valid rev for remote file",
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Error while syncing. Unable to read remote data. Maybe you enabled encryption and your local password does not match the one used to encrypt the remote data?",
"HYDRATION_FAILED": "Failed to load data. Please reload the app.",
"INTEGRITY_CHECK_FAILED": "Data integrity issue detected. Auto-repair attempted. If you experience issues, please reload the app.",
@ -1627,6 +1678,7 @@
"FILTER_BY": "Filter By",
"FILTER_DEFAULT": "No Filter",
"FILTER_ESTIMATED_TIME": "Estimated Time",
"FILTER_NOT_SPECIFIED": "Not specified",
"FILTER_PROJECT": "Project",
"FILTER_SCHEDULED_DATE": "Scheduled Date",
"FILTER_TAG": "Tag",
@ -1643,6 +1695,7 @@
"SCHEDULED_THIS_WEEK": "This Week",
"SCHEDULED_TODAY": "Today",
"SCHEDULED_TOMORROW": "Tomorrow",
"SORT_BY": "",
"SORT_CREATION_DATE": "Creation Date",
"SORT_DEFAULT": "Default",
"SORT_NAME": "Name",
@ -1653,7 +1706,6 @@
"TIME_1HOUR": "> 1 Hour",
"TIME_2HOUR": "> 2 Hours",
"TIME_30MIN": "> 30 Minutes",
"FILTER_NOT_SPECIFIED": "Not specified",
"TIME_SPENT": "Time Spent",
"TITLE": "Customize Task View"
}
@ -1804,6 +1856,7 @@
"DISMISS": "Dismiss",
"DONT_SHOW_AGAIN": "Don't show again",
"DO_IT": "Do it!",
"DUPLICATE": "Duplicate",
"DURATION_DESCRIPTION": "e.g. \"5h 23m\" which results in 5 hours and 23 minutes",
"EDIT": "Edit",
"ENABLED": "Enabled",
@ -1874,11 +1927,12 @@
"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_SYNC_SESSION_WITH_TRACKING": "Sync focus session with time tracking",
"L_IS_PLAY_TICK": "Play ticking sound during focus sessions",
"L_MANUAL_BREAK_START": "Manually start breaks (Pomodoro)",
"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)",
"L_SYNC_SESSION_WITH_TRACKING": "Sync focus session with time tracking",
"TITLE": "Focus Mode"
},
"IDLE": {
@ -1972,8 +2026,8 @@
"SK": "Slovak",
"TIME_LOCALE": "Datetime format locale",
"TIME_LOCALE_AUTO": "System default",
"TIME_LOCALE_DESCRIPTION": "NOTE: now this options can not change time input format (12/24 hour) because it is controlled by your OS",
"TIME_LOCALE_DE_DE": "German: 24-hour, DD.MM.YYYY",
"TIME_LOCALE_DESCRIPTION": "NOTE: now this options can not change time input format (12/24 hour) because it is controlled by your OS",
"TIME_LOCALE_EN_GB": "English (UK): 24-hour, DD/MM/YYYY",
"TIME_LOCALE_EN_US": "English (US): 12-hour AM/PM, MM/DD/YYYY",
"TIME_LOCALE_ES_ES": "Spanish: 24-hour, DD/MM/YYYY",
@ -2017,13 +2071,29 @@
"IS_TURN_OFF_MARKDOWN": "Turn off markdown parsing for task notes",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Use custom title bar (Windows/Linux only)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Requires restart to take effect",
"IS_USE_MINIMAL_SIDE_NAV": "",
"START_OF_NEXT_DAY": "Start time of the next day",
"START_OF_NEXT_DAY_HINT": "from when (in hour) you want to count the next day has started. default is midnight which is 0.",
"TASK_NOTES_TPL": "Task description template",
"THEME": "Theme",
"THEME_EXPERIMENTAL": "Theme (experimental)",
"THEME_SELECT_LABEL": "Select Theme",
"TITLE": "Misc Settings"
"TITLE": "Misc Settings",
"USER_PROFILES": "",
"USER_PROFILES_HINT": ""
},
"PAST": {
"A_DAY": "",
"A_MINUTE": "",
"A_MONTH": "",
"A_YEAR": "",
"AN_HOUR": "",
"DAYS": "",
"FEW_SECONDS": "",
"HOURS": "",
"MINUTES": "",
"MONTHS": "",
"YEARS": ""
},
"POMODORO": {
"BREAK_DURATION": "Duration of short breaks",
@ -2034,9 +2104,9 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Show banner X before the actual reminder",
"DEFAULT_TASK_REMIND_OPTION": "Default remind option selected when creating tasks",
"DISABLE_REMINDERS": "Disable all reminders",
"IS_COUNTDOWN_BANNER_ENABLED": "Show countdown banner before reminders are due",
"TITLE": "Reminders",
"DISABLE_REMINDERS": "Disable all reminders"
"TITLE": "Reminders"
},
"SCHEDULE": {
"HELP": "The schedule feature should provide you with a quick overview over how your planned tasks play out over time. You can find it in the left hand menu under <a href='#/schedule'>'Schedule'</a>. ",
@ -2097,6 +2167,9 @@
"TITLE": "Time Tracking"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (copy)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"AN_HOUR": "in an hour",
@ -2145,9 +2218,6 @@
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} pressed, but open bookmarks shortcut is only available when in project context.",
"UNPLANNED_TODAY_TASKS": "Unplanned all tasks from Today"
},
"GLOBAL": {
"COPY_SUFFIX": " (copy)"
},
"GPB": {
"ASSETS": "Loading assets...",
"DBX_DOWNLOAD": "Dropbox: Download file...",
@ -2167,11 +2237,11 @@
"BOARDS": "Boards",
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
"CREATE_PROJECT": "Create Project",
"DUPLICATE_PROJECT": "Duplicate",
"CREATE_TAG": "Create Tag",
"DELETE_PROJECT": "Delete Project",
"DELETE_TAG": "Delete Tag",
"DONATE": "Support us",
"DUPLICATE_PROJECT": "Duplicate",
"ENTER_FOCUS_MODE": "Enter Focus Mode",
"GO_TO_TASK_LIST": "Go to task list",
"HELP": "Help",
@ -2186,6 +2256,7 @@
"SYNC": "Howto: Configure Sync"
},
"METRICS": "Metrics",
"NO_TASKS_TO_TRACK": "Add a task first to start tracking time",
"NOTES": "Notes",
"NOTES_PANEL_INFO": "Notes can only be shown from the schedule and the regular task list views.",
"NO_PROJECT_INFO": "No projects available. You can create a new project by clicking the \"Create Project\" button.",
@ -2230,6 +2301,7 @@
"PLURAL": "{{count}} done parent tasks have been archived",
"SINGULAR": "{{count}} done parent task has been archived"
},
"BACK": "",
"BREAK_LABEL": "Breaks (nr / time)",
"CELEBRATE": "Take a moment to <i>celebrate!</i>",
"CLEAR_ALL_CONTINUE": "Clear all done and continue",

View file

@ -217,9 +217,12 @@
"ADD_TIME_MINUTE": "Añadir 1 minuto",
"B": {
"BREAK_RUNNING": "El descanso está en curso",
"PAUSE": "Pausa",
"POMODORO_BREAK_RUNNING": "El descanso #{{cycleNr}} está en curso",
"POMODORO_SESSION_RUNNING": "La sesión Pomodoro #{{cycleNr}} está en curso",
"RESUME": "Currículum",
"SESSION_RUNNING": "La sesión de enfoque está en curso",
"START": "INICIO",
"TO_FOCUS_OVERLAY": "A superposición de enfoque"
},
"BACK_TO_PLANNING": "Volver a la planificación",
@ -848,10 +851,10 @@
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"TITLE": "¿Duplicar Proyecto?",
"CANCEL": "Cancelar",
"MSG": "Este proyecto es bastante grande y podría tardar un tiempo en duplicarse. ¿Quieres continuar?",
"OK": "Duplicar de todos modos",
"CANCEL": "Cancelar"
"TITLE": "¿Duplicar Proyecto?"
},
"D_CREATE": {
"CREATE": "Crear Proyecto",
@ -969,6 +972,40 @@
"S_ACTIVE_TASK_DUE": "¡La tarea en la que estás trabajando ha vencido!<br/> ({{title}})",
"S_REMINDER_ERR": "Error de la interfaz de recordatorios"
},
"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)"
},
"SCHEDULE": {
"CONTINUED": "continuado",
"D_INITIAL": {
@ -1162,42 +1199,15 @@
"SUCCESS_VIA_BUTTON": "Datos sincronizados exitosamente",
"UNKNOWN_ERROR": "Error de Sincronización Desconocido: {{err}}",
"UPLOAD_ERROR": "Error de Subida Desconocido (¿Configuración correcta?): {{err}}"
},
"SAFETY_BACKUP": {
"BTN_CLEAR_ALL": "Limpiar todo",
"BTN_DELETE": "Eliminar",
"BTN_REFRESH": "Actualizar",
"BTN_RESTORE": "Restaurar",
"LOADING": "Cargando..."
}
},
"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",
@ -1536,6 +1546,7 @@
"FILTER_BY": "Filtrar Por",
"FILTER_DEFAULT": "Sin Filtro",
"FILTER_ESTIMATED_TIME": "Tiempo Estimado",
"FILTER_NOT_SPECIFIED": "No especificado",
"FILTER_PROJECT": "Proyecto",
"FILTER_SCHEDULED_DATE": "Fecha Programada",
"FILTER_TAG": "Etiqueta",
@ -1562,7 +1573,6 @@
"TIME_2HOUR": "> 2 Horas",
"TIME_10MIN": "> 10 Minutos",
"TIME_30MIN": "> 30 Minutos",
"FILTER_NOT_SPECIFIED": "No especificado",
"TIME_SPENT": "Tiempo Dedicado",
"TITLE": "Personalizar Vista de Tareas"
}
@ -1696,6 +1706,7 @@
"DISMISS": "Descartar",
"DO_IT": "¡Hazlo!",
"DONT_SHOW_AGAIN": "No mostrar de nuevo",
"DUPLICATE": "Duplicar",
"DURATION_DESCRIPTION": "p. ej. \"5h 23m\" que resulta en 5 horas y 23 minutos",
"EDIT": "Editar",
"ENABLED": "Habilitado",
@ -1728,6 +1739,22 @@
"YESTERDAY": "Ayer"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Tableros",
"DONATE_PAGE": "Página de donaciones",
"FOCUS_MODE": "Modo concentración",
"HELP": "Habilita o deshabilita funciones específicas de la aplicación en la interfaz.",
"ISSUES_PANEL": "Panel de incidencias",
"PLANNER": "Planificador",
"PROJECT_NOTES": "Notas del proyecto",
"SCHEDULE": "Horario",
"SCHEDULE_DAY_PANEL": "Panel de horario diario",
"SYNC_BUTTON": "Botón de sincronización",
"TIME_TRACKING": "Seguimiento de tiempo (Cronómetro)",
"TITLE": "Funciones de la aplicación",
"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)."
},
"AUTO_BACKUPS": {
"HELP": "Guarda automáticamente todos los datos en la carpeta de tu aplicación para tenerlos listos en caso de que algo salga mal.",
"LABEL_IS_ENABLED": "Habilitar copias de seguridad automáticas",
@ -1842,26 +1869,26 @@
"PT_BR": "Portugués (Brasil)",
"RU": "Ruso",
"SK": "Eslovaco",
"TIME_LOCALE": "Configuración regional de formato de fecha y hora",
"TIME_LOCALE_AUTO": "Predeterminado del sistema",
"TIME_LOCALE_DE_DE": "Alemán: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_DESCRIPTION": "NOTA: ahora estas opciones no pueden cambiar el formato de entrada de hora (12/24 horas) porque está controlado por tu SO",
"TIME_LOCALE_EN_GB": "Inglés (Reino Unido): 24 horas, DD/MM/AAAA",
"TIME_LOCALE_EN_US": "Inglés (EE.UU.): 12 horas AM/PM, MM/DD/AAAA",
"TIME_LOCALE_ES_ES": "Español: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_FR_FR": "Francés: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_IT_IT": "Italiano: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_JA_JP": "Japonés: 24 horas, AAAA/MM/DD",
"TIME_LOCALE_KO_KR": "Coreano: 12 horas AM/PM, AAAA. MM. DD",
"TIME_LOCALE_PT_BR": "Portugués (Brasil): 24 horas, DD/MM/AAAA",
"TIME_LOCALE_RU_RU": "Ruso: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_TR_TR": "Turco: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_ZH_CN": "Chino (Simplificado): 24 horas, AAAA/MM/DD",
"TITLE": "Localización",
"TR": "Turco",
"UK": "Ucraniano",
"ZH": "Chino (Simplificado)",
"ZH_TW": "Chino (Tradicional)",
"TIME_LOCALE": "Configuración regional de formato de fecha y hora",
"TIME_LOCALE_DESCRIPTION": "NOTA: ahora estas opciones no pueden cambiar el formato de entrada de hora (12/24 horas) porque está controlado por tu SO",
"TIME_LOCALE_AUTO": "Predeterminado del sistema",
"TIME_LOCALE_EN_US": "Inglés (EE.UU.): 12 horas AM/PM, MM/DD/AAAA",
"TIME_LOCALE_EN_GB": "Inglés (Reino Unido): 24 horas, DD/MM/AAAA",
"TIME_LOCALE_TR_TR": "Turco: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_DE_DE": "Alemán: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_FR_FR": "Francés: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_ES_ES": "Español: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_IT_IT": "Italiano: 24 horas, DD/MM/AAAA",
"TIME_LOCALE_PT_BR": "Portugués (Brasil): 24 horas, DD/MM/AAAA",
"TIME_LOCALE_RU_RU": "Ruso: 24 horas, DD.MM.AAAA",
"TIME_LOCALE_ZH_CN": "Chino (Simplificado): 24 horas, AAAA/MM/DD",
"TIME_LOCALE_JA_JP": "Japonés: 24 horas, AAAA/MM/DD",
"TIME_LOCALE_KO_KR": "Coreano: 12 horas AM/PM, AAAA. MM. DD"
"ZH_TW": "Chino (Tradicional)"
},
"MISC": {
"DARK_MODE": "Modo Oscuro",
@ -1897,6 +1924,19 @@
"THEME_SELECT_LABEL": "Seleccionar Tema",
"TITLE": "Ajustes Varios"
},
"PAST": {
"A_DAY": "hace un día",
"A_MINUTE": "hace un minuto",
"A_MONTH": "hace un mes",
"A_YEAR": "hace un año",
"AN_HOUR": "hace una hora",
"DAYS": "hace {{count}} días",
"FEW_SECONDS": "hace unos segundos",
"HOURS": "hace {{count}} horas",
"MINUTES": "hace {{count}} minutos",
"MONTHS": "hace {{count}} meses",
"YEARS": "hace {{count}} años"
},
"POMODORO": {
"BREAK_DURATION": "Duración de descansos cortos",
"CYCLES_BEFORE_LONGER_BREAK": "Iniciar descanso más largo después de X sesiones de trabajo",
@ -1906,9 +1946,9 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Mostrar banner X antes del recordatorio real",
"DEFAULT_TASK_REMIND_OPTION": "Opción de recordatorio predeterminada seleccionada al crear tareas",
"DISABLE_REMINDERS": "Deshabilitar todos los recordatorios",
"IS_COUNTDOWN_BANNER_ENABLED": "Mostrar banner de cuenta atrás antes del vencimiento de los recordatorios",
"TITLE": "Recordatorios",
"DISABLE_REMINDERS": "Deshabilitar todos los recordatorios"
"TITLE": "Recordatorios"
},
"SCHEDULE": {
"HELP": "La característica de línea de tiempo debería proporcionarte una visión general rápida sobre cómo se desarrollan tus tareas planificadas a lo largo del tiempo. Puedes encontrarla en el menú de la izquierda bajo <a href='#/schedule'>'Cronograma'</a>. ",
@ -1954,21 +1994,8 @@
"SNOOZE_TIME": "Tiempo de posponer cuando se pide tomar 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)."
"TIME_TRACKING": {
"TITLE": "Seguimiento del tiempo"
},
"TIMELINE": {
"CAL_PROVIDERS": "Proveedores de calendario (experimental y opcional)",
@ -1981,13 +2008,31 @@
"L_WORK_START": "Inicio del Día de Trabajo",
"TITLE": "Línea de tiempo",
"WORK_START_END_DESCRIPTION": "p. ej. 17:00"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (copia)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "En un día",
"A_MINUTE": "en un minuto",
"A_MONTH": "En un mes",
"A_YEAR": "en un año",
"AN_HOUR": "en una hora",
"DAYS": "en {{count}} días",
"FEW_SECONDS": "en unos segundos",
"HOURS": "en {{count}} horas",
"MINUTES": "en {{count}} minutos",
"MONTHS": "en {{count}} meses",
"YEARS": "en {{count}} años"
},
"PAST": {
"A_DAY": "hace un día",
"A_MINUTE": "hace un minuto",
"A_MONTH": "hace un mes",
"A_YEAR": "hace un año",
"AN_HOUR": "hace una hora",
"A_DAY": "Hace un día",
"A_MINUTE": "Hace un minuto",
"A_MONTH": "Hace un mes",
"A_YEAR": "Hace un año",
"AN_HOUR": "Hace una hora",
"DAYS": "hace {{count}} días",
"FEW_SECONDS": "hace unos segundos",
"HOURS": "hace {{count}} horas",
@ -2015,9 +2060,6 @@
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} presionado, pero el atajo de abrir marcadores solo está disponible cuando se está en contexto de proyecto.",
"UNPLANNED_TODAY_TASKS": "Desplanificadas todas las tareas de Hoy"
},
"GLOBAL": {
"COPY_SUFFIX": " (copia)"
},
"GPB": {
"ASSETS": "Cargando activos...",
"DBX_DOWNLOAD": "Dropbox: Descargar archivo...",
@ -2037,11 +2079,11 @@
"BOARDS": "Tableros",
"COPY_TASK_LIST_MARKDOWN": "Copiar al Portapapeles",
"CREATE_PROJECT": "Crear Proyecto",
"DUPLICATE_PROJECT": "Duplicar",
"CREATE_TAG": "Crear Etiqueta",
"DELETE_PROJECT": "Eliminar Proyecto",
"DELETE_TAG": "Eliminar Etiqueta",
"DONATE": "Apóyanos",
"DUPLICATE_PROJECT": "Duplicar",
"ENTER_FOCUS_MODE": "Entrar en Modo Enfoque",
"GO_TO_TASK_LIST": "Ir a la lista de tareas",
"HELP": "Ayuda",
@ -2104,10 +2146,10 @@
"MSG": "¿Salir de la aplicación?",
"OK": "Salir"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Puedes usar este espacio para escribir tus propios rituales de fin de día de los que quieras que se te recuerde.",
"ESTIMATE_TOTAL": "Estimación total:",
"EVALUATE_DAY": "Evaluar Día",
"EXPORT_TASK_LIST": "Exportar Lista de Tareas",
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Puedes usar este espacio para escribir tus propios rituales de fin de día de los que quieras que se te recuerde.",
"FOCUS_SUMMARY": "Sesiones de Enfoque",
"NO_TASKS": "No hay tareas para este día",
"PLAN_TOMORROW": "Planificar",

View file

@ -1540,6 +1540,7 @@
"FILTER_BY": "فیلتر بر اساس",
"FILTER_DEFAULT": "بدون فیلتر",
"FILTER_ESTIMATED_TIME": "زمان تخمینی",
"FILTER_NOT_SPECIFIED": "مشخص نشده",
"FILTER_PROJECT": "پروژه",
"FILTER_SCHEDULED_DATE": "تاریخ برنامه ریزی شده",
"FILTER_TAG": "برچسب",
@ -1567,7 +1568,6 @@
"TIME_2HOUR": "> 2 ساعت",
"TIME_10MIN": "> 10 دقیقه",
"TIME_30MIN": "> 30 دقیقه",
"FILTER_NOT_SPECIFIED": "مشخص نشده",
"TIME_SPENT": "زمان صرف شده",
"TITLE": "سفارشی کردن Task View"
}
@ -1883,8 +1883,6 @@
"IS_DARK_MODE": "حالت تاریک",
"IS_DISABLE_ANIMATIONS": "غیرفعال کردن تمام پویانمایی‌ها",
"IS_DISABLE_CELEBRATION": "غیرفعال کردن جشن‌ها در خلاصه روزانه",
"USER_PROFILES": "فعال سازی پروفایل های کاربری (نسخه بتا)",
"USER_PROFILES_HINT": "به شما اجازه می دهد پروفایل های کاربری مختلف بسازید و بین آن ها جابجا شوید، هرکدام با تنظیمات، وظایف و پیکربندی های همگام سازی جداگانه. دکمه مدیریت پروفایل هنگام فعال بودن در گوشه بالا سمت راست ظاهر می شود. توجه: غیرفعال کردن این ویژگی رابط کاربری را مخفی می کند اما داده های پروفایل شما را حفظ می کند (ویژگی بتا، تضمینی نیست. حتما یک نسخه پشتیبان داشته باشید).",
"IS_HIDE_NAV": "Hide navigation until main header is hovered (desktop only)",
"IS_MINIMIZE_TO_TRAY": "کوچک کردن به سینی (فقط دسک تاپ)",
"IS_OVERLAY_INDICATOR_ENABLED": "فعال کردن پنجره شاخص پوششی (لینوکس دسکتاپ/گنوم)",
@ -1899,7 +1897,9 @@
"THEME": "تم",
"THEME_EXPERIMENTAL": "تم (آزمایشی)",
"THEME_SELECT_LABEL": "انتخاب تم",
"TITLE": "Misc Settings"
"TITLE": "Misc Settings",
"USER_PROFILES": "فعال سازی پروفایل های کاربری (نسخه بتا)",
"USER_PROFILES_HINT": "به شما اجازه می دهد پروفایل های کاربری مختلف بسازید و بین آن ها جابجا شوید، هرکدام با تنظیمات، وظایف و پیکربندی های همگام سازی جداگانه. دکمه مدیریت پروفایل هنگام فعال بودن در گوشه بالا سمت راست ظاهر می شود. توجه: غیرفعال کردن این ویژگی رابط کاربری را مخفی می کند اما داده های پروفایل شما را حفظ می کند (ویژگی بتا، تضمینی نیست. حتما یک نسخه پشتیبان داشته باشید)."
},
"POMODORO": {
"BREAK_DURATION": "Duration of short breaks",

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "esim. luonto, vuoret, abstrakti",
"TITLE": "Valitse taustakuva Unsplashista"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Lahjoita GitHub Sponsorsin kautta",
"INTRO_1": "Super Productivity on täysin yhteisön rahoittama. Ei seurantaa, ei mainoksia eikä tiedonkeruuta — tehtäväsi pysyvät laitteellasi.",
"INTRO_2": "Jos arvostat tätä lähestymistapaa ja haluat pitää projektin terveenä ja kehittyvänä, vapaaehtoinen lahjoitus on erittäin arvostettu."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -157,6 +162,7 @@
"BANNER": {
"ADD_AS_TASK": "Lisää tehtävänä",
"FOCUS_TASK": "Keskitä tehtävään",
"SHOW_TASK": "Näytä tehtävä",
"TXT": "<strong>{{title}}</strong> alkaa klo <strong>{{start}}</strong>!",
"TXT_MULTIPLE": "<strong>{{title}}</strong> alkaa klo <strong>{{start}}</strong>!<br> (ja {{nrOfOtherBanners}} muuta tapahtumaa on meneillään)",
"TXT_PAST": "<strong>{{title}}</strong> alkoi klo <strong>{{start}}</strong>!",
@ -209,23 +215,33 @@
}
},
"FOCUS_MODE": {
"ADD_TIME_MINUTE": "Lisää 1 minuutti",
"B": {
"BREAK_RUNNING": "Tauko on käynnissä",
"END_BREAK": "Lopeta tauko",
"END_SESSION": "Lopeta istunto",
"PAUSE": "Tauko",
"POMODORO_BREAK_RUNNING": "Tauko #{{cycleNr}} on käynnissä",
"POMODORO_SESSION_RUNNING": "Pomodorotunti #{{cycleNr}} on käynnissä",
"RESUME": "Jatka",
"SESSION_RUNNING": "Keskittymististunti on käynnissä",
"START": "Aloita",
"TO_FOCUS_OVERLAY": "Keskittymistilan ikkunaan"
},
"BACK_TO_PLANNING": "Takaisin suunnitteluun",
"BREAK_RELAX_MSG": "Ota hetki rentoutumiseen",
"CLICK_TO_EDIT_DURATION": "Klikkaa muokataksesi kestoa",
"COMPLETE_FOCUS_SESSION": "Täytä keskittymisistunto",
"COMPLETE_SESSION": "Viimeistele istunto",
"CONGRATS": "Onnittelut istunnon suorittamisesta!",
"CONTINUE_SESSION": "Jatka istuntoa",
"CONTINUE_TO_NEXT_SESSION": "Jatka seuraavaan istuntoon",
"COUNTDOWN": "Laskuri",
"COUNTDOWN_HINT": "Keskity, kunnes kello näyttää nollaa",
"CURRENT_SESSION_TIME_TOOLTIP": "Nykyisen istunnon aika",
"FINISH_TASK_AND_SELECT_NEXT": "Viimeistele tehtävä ja valitse seuraava",
"FLOWTIME": "Flowtime",
"FLOWTIME_HINT": "Joustava tahti ilman tiukkoja ajastimia",
"FOCUS_TIME_TOOLTIP": "Keskittymisaika",
"FOR_TASK": "tehtävälle",
"GET_READY": "Valmistaudu keskittymistilaan!",
@ -236,21 +252,32 @@
"NEXT": "Seuraava",
"ON": "päällä",
"OPEN_ISSUE_IN_BROWSER": "Avaa ongelma selaimessa",
"PAUSE_SESSION": "Tauota istunto",
"PAUSE_TRACKING": "Tauota seuranta",
"PAUSE_TRACKING_FOR_CURRENT_TASK": "Tauota seuranta nykyisestä tehtävästä",
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Rakenteelliset sprintit suunnitelluilla tauoilla",
"POMODORO_SESSION_COMPLETED": "Pomodorotunti suoritettu!",
"POMODORO_SETTINGS": "Pomodoro-asetukset",
"PREP_GET_MENTALLY_READY": "Valmistaudu henkisesti olemaan keskittynyt ja tuottelias",
"PREP_SIT_UPRIGHT": "Istu (tai seiso) selkä suorana",
"PREP_STRETCH": "Tee kevyttä venyttelyä",
"REMOVE_TIME_MINUTE": "Poista 1 minuutti",
"RESUME_SESSION": "Jatka istuntoa",
"SELECT_ANOTHER_TASK": "Valitse toinen tehtävä",
"SELECT_MODE": "Valitse keskittymismoodi",
"SELECT_TASK": "Valitse tehtävä, johon keskittyä",
"SELECT_TASK_TO_FOCUS": "Valitse keskityttävä tehtävä",
"SESSION_COMPLETED": "Keskittymissessio suoritettu!",
"SET_FOCUS_SESSION_DURATION": "Aseta keskittymissession kesto",
"SHORT_BREAK": "Lyhyt tauko",
"SHORT_BREAK_TITLE": "Lyhyt tauko - Kierros {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Näytä/piilota tehtävän muistiinpanot ja liitteet",
"SKIP_BREAK": "Ohita tauko",
"START_BREAK": "Aloita tauko",
"START_FOCUS_SESSION": "Käynnistä Keskittymissessio",
"START_NEXT_FOCUS_SESSION": "Käynnistä seuraava Keskittymissessio",
"SWITCH_TASK": "Vaihda tehtävää",
"WORKED_FOR": "Työskentelit"
},
"GITEA": {
@ -429,6 +456,11 @@
"POLLING_CHANGES": "{{issueProviderName}}: Haetaan muutoksia {{issuesStr}}"
}
},
"ISSUE_PANEL": {
"CALENDAR_AGENDA": {
"OFFLINE_BANNER_MSG": "Näytetään välimuistissa olevia kalenterituloksia. Tiedot saattavat olla vanhentuneita."
}
},
"JIRA": {
"BANNER": {
"BLOCK_ACCESS_MSG": "Jira: Estääkseen pääsyn API:sta, Super Productivity on estänyt pääsyn. Sinun pitäisi todennäköisesti tarkistaa Jira-asetuksesi!",
@ -572,6 +604,7 @@
"CHECK": "Tein sen!"
},
"CMP": {
"ACTIVITY_HEATMAP": "Toiminnan lämpökartta",
"AVG_BREAKS_PER_DAY": "Keskim. taukoja päivässä",
"AVG_TASKS_PER_DAY_WORKED": "Keskim. tehtäviä työpäivässä",
"AVG_TIME_SPENT_ON_BREAKS": "Keskim. tauoille käytetty aika",
@ -583,22 +616,33 @@
"GLOBAL_METRICS": "Globaalit mittarit",
"MOOD_PRODUCTIVITY_OVER_TIME": "Mieliala ja tuottavuus ajan kuluessa",
"NO_ADDITIONAL_DATA_YET": "Ei vielä kerättyjä lisätietoja. Käytä lomaketta päivittäisen yhteenvedon \"Arviointi\"-paneelissa tehdäksesi niin.",
"PRODUCTIVITY_BREAKDOWN_OVER_TIME": "Tuottavuus ja kestävyys ajan myötä",
"SIMPLE_CLICK_COUNTERS_OVER_TIME": "Klikkauslaskurit ajan kuluessa",
"SIMPLE_COUNTERS": "Yksinkertaiset laskurit & tapaseuranta",
"SIMPLE_STOPWATCH_COUNTERS_OVER_TIME": "Sekuntikellolaskurit ajan kuluessa",
"TASKS_DONE_CREATED": "Tehtävät (valmiit/luodut)",
"TIME_ESTIMATED": "Arvioitu aika",
"TIME_FRAME_1_MONTH": "1 kuukausi",
"TIME_FRAME_2_WEEKS": "2 viikkoa",
"TIME_FRAME_LABEL": "Aikajakso",
"TIME_FRAME_MAX": "Maxi",
"TIME_SPENT": "Käytetty aika"
},
"EVAL_FORM": {
"ADD_NOTE_FOR_TOMORROW": "Lisää muistiinpano huomiselle",
"DAILY_STATE": "Päivittäinen tila",
"DAILY_STATE_TOOLTIP": "Neljännesjärjestelmä molempien pisteiden perusteella (\u0002250 on korkea): Syvä virtaus (korkea/korkea), Ylikierrokset (korkea/matala), Palautuminen (matala/korkea), Vaeltelu (matala/matala)",
"DISABLE_REPEAT_EVERY_DAY": "Poista joka päivä toisto käytöstä",
"ENABLE_REPEAT_EVERY_DAY": "Toista joka päivä",
"ENERGY_LEVEL": "Miten energiasi on?",
"ENERGY_LEVEL_HINT": "\u001f62b Uupunut \u001f610 OK \u001f60a Hyvä",
"FOCUS_WORK_TIME": "Keskittynyt työaika",
"HELP_H1": "Miksi minun pitäisi välitä?",
"HELP_LINK_TXT": "Siirry mittariosioon",
"HELP_P1": "Aika pieneen itsearviointiin! Vastauksesi tallennetaan ja tarjoavat sinulle hieman tilastoja työskentelystäsi mittariosiossa. Lisäksi huomisen ehdotukset näkyvät tehtävälistasi yläpuolella seuraavana päivänä.",
"HELP_P2": "Tämän tarkoituksena on olla vähemmän tarkkojen mittareiden laskemisesta tai koneen kaltaisen tehokkaaksi tulemisesta kaikessa mitä teet, kuin siitä, että parantaisit työskentelystäsi tuntemiasi tunteita. Se voi olla hyödyllistä arvioida kipukohtia päivittäisessä rutiinissasi, samoin kuin löytää tekijöitä, jotka auttavat sinua. Siitä, että olet vain hieman systemaattinen, toivottavasti auttaa saamaan paremman otteen näistä ja parantamaan mitä pystyt.",
"IMPACT_OF_WORK": "Miten arvioisit työsi vaikutusta tänään?",
"IMPACT_OF_WORK_HINT": "1: Ei merkittävää edistystä 4: Merkittävä vaikutus",
"IMPROVEMENTS": "Mikä paransi tuottavuuttasi?",
"IMPROVEMENTS_TOMORROW": "Mitä voisit tehdä parantaaksesi huomista?",
"MOOD": "Miltä sinusta tuntuu?",
@ -606,13 +650,54 @@
"NOTES": "Muistiinpanot huomiselle",
"OBSTRUCTIONS": "Mikä haittasi tuottavuuttasi?",
"PRODUCTIVITY": "Kuinka tehokkaasti työskentelit?",
"PRODUCTIVITY_HINT": "1: En ole edes aloittanut 10: Valtavan tehokas"
"PRODUCTIVITY_HINT": "1: En ole edes aloittanut 10: Valtavan tehokas",
"PRODUCTIVITY_SCORE": "Tuottavuuspisteet",
"PRODUCTIVITY_SCORE_TOOLTIP": "65 % vaikutus (14 asteikko)  30 % keskittyminen 4 tunnin tavoitteeseen  5 % kokonais työajasta (rajoitettu 10 tuntiin)",
"SCORE_BREAKDOWN_TITLE_PRODUCTIVITY": "7 päivän tuottavuusjako",
"SCORE_BREAKDOWN_TITLE_SUSTAINABILITY": "7 päivän kestävyysjako",
"STATE_DEEP_FLOW_HEADLINE": "Syvä virtaus: Erinomainen keskittyminen ja kestävä tahti.",
"STATE_DEEP_FLOW_HINT": "Olet parhaassa kohdassa pohdi, mikä teki tämän yhdistelmän mahdolliseksi.",
"STATE_DRIFT_HEADLINE": "Vaeltelu: Vähäinen keskittyminen ja energia.",
"STATE_DRIFT_HINT": "Se on ihan ok. Pohdi, mikä häiritsi sinua, ja säädä varovasti jokainen uudelleenkäynnistys on mahdollisuus oppia.",
"STATE_IMPACT_MISMATCH_HEADLINE": "Vähäinen vaikutus: Paljon keskittymistä, vähän tulosta.",
"STATE_IMPACT_MISMATCH_HINT": "Panostit vahvasti keskittymiseen suuntaa se nyt korkeamman vaikutuksen työhön, jotta tunnet eron.",
"STATE_OVERDRIVE_HEADLINE": "Ylikuormitus: Tuottava, mutta kustannuksella.",
"STATE_OVERDRIVE_HINT": "Sait paljon aikaan varmista nyt, että lataudut ennen kuin uupumus iskee.",
"STATE_RECOVERY_HEADLINE": "Toipuminen: Lepo ja latautuminen.",
"STATE_RECOVERY_HINT": "Annet itsellesi tilaa palautua. Huomenna sinulla on energiaa sukeltaa syvemmälle uudelleen.",
"STATE_STEADY_HEADLINE": "Tasainen rytmi: Johdonmukaista, kestävää edistystä.",
"STATE_STEADY_HINT": "Mukava tasapaino. Säilytät vauhdin ilman liiallista venymistä — jatka sen ravitsemista, mikä saa tämän tahdin toimimaan.",
"SUSTAINABILITY_SCORE": "Kestävyysarvosana",
"SUSTAINABILITY_SCORE_TOOLTIP": "45 % Energiataso  40 % Kohtuulliset työtunnit (0 kohdalla 10h)  15 % Keskittymisen tasapaino (optimaalinen 4h)",
"TOTAL_WORK_TIME": "Kokonais työaika tänään"
},
"FOCUS_SESSION_DIALOG": {
"ADD_BTN": "Lisää",
"ADD_SESSION": "Lisää istunto",
"CHART_LABEL": "Keskittymisaika (min)",
"NEW_SESSION_DURATION": "Kesto",
"NO_SESSIONS": "Tälle päivälle ei keskittymisen istuntoja",
"SESSIONS_LIST": "Istunnot",
"TITLE": "Keskittymisen istunnot",
"TOTAL_SESSIONS": "Kokonaisistunnot",
"TOTAL_TIME": "Kokonaisaika"
},
"REFLECTION": {
"HISTORY_BTN": "Näytä aiemmat pohdinnat",
"HISTORY_EMPTY": "Ei vielä tallennettuja pohdintoja. Tallenna tämän päivän muistiinpano aloittaaksesi sarjan.",
"HISTORY_TITLE": "Pohdintojen historia",
"PLACEHOLDER_1": "Mikä toimi hyvin tänään? Mitä pitäisi muuttaa huomenna?",
"PLACEHOLDER_2": "Pieni parannus huomiseksi?",
"PLACEHOLDER_3": "Missä keskittyminen lipsui - ja miten voit suojella sitä?",
"PLACEHOLDER_4": "Mikä tehtävä antoi sinulle energiaa? Mikä vei sitä?",
"PLACEHOLDER_5": "Yksi asia toistettavaksi. Yksi asia muutettavaksi.",
"REMIND_LABEL": "Muistuta minua tästä huomenna",
"REMINDER_CREATED": "Pohdintojen muistutus ajastettu huomiselle",
"REMINDER_ERROR": "Muistutuksen ajastaminen epäonnistui",
"REMINDER_NEEDS_TEXT": "Kirjoita pohdinta ennen muistutuksen pyytämistä",
"REMINDER_TASK_TITLE": "Palaa pohdintamuistiinpanoon",
"TITLE": "Pohdintamuistiinpano"
},
"S": {
"SAVE_METRIC": "Mittari tallennettu onnistuneesti"
}
@ -783,6 +868,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Peruuta",
"MSG": "Tämä projekti on melko suuri ja sen kopioiminen saattaa kestää hetken. Haluatko jatkaa?",
"OK": "Kopioi silti",
"TITLE": "Kopioi projekti?"
},
"D_CREATE": {
"CREATE": "Luo projekti",
"EDIT": "Muokkaa projektia",
@ -886,7 +977,8 @@
}
},
"REFLECTION_NOTE": {
"ACTION_DISMISS": "Hylkää"
"ACTION_DISMISS": "Hylkää",
"MSG": "Pohdintamuistio: ({{date}}): {{content}}"
},
"REMINDER": {
"COUNTDOWN_BANNER": {
@ -905,12 +997,14 @@
"TITLE": "Aikataulu"
},
"END": "Työpäivän loppu",
"INSERT_BEFORE": "Ennen",
"LUNCH_BREAK": "Lounastauko",
"MONTH": "Kuukausi",
"NO_TASKS": "Tällä hetkellä ei ole tehtäviä. Lisää joitakin tehtäviä yläpalkin +-painikkeella.",
"NOW": "Nyt",
"PLAN_END_DAY": "Suunnittele päivän lopussa",
"PLAN_START_DAY": "Suunnittele päivän alussa",
"SHIFT_KEY_INFO": "Pidä Shift-näppäintä painettuna vaihtaaksesi päivän suunnittelutilaan",
"START": "Työpäivän alku",
"TASK_PROJECTION_INFO": "Aikataulutetun toistuvan tehtävän tuleva projektio",
"WEEK": "Viikko"
@ -1063,11 +1157,16 @@
"TITLE": "Synkronointi",
"WEB_DAV": {
"CORS_INFO": "<strong>Saadaksesi sen toimimaan selaimessa:</strong> Jotta tämä toimisi selaimessa, sinun on sallittava Super Productivity CORS-pyynnöille webdav-palvelimellasi. Tällä voi olla negatiivisia tietoturvavaikutuksia! Nextcloudille katso <a href='https://github.com/nextcloud/server/issues/3131'>tämä ketju lisätietoja varten</a>. Yksi tapa saada tämä toimimaan mobiilissa on sallia \"https://app.super-productivity.com\" nextcloud-sovelluksen <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a> kautta. Käytä omalla vastuullasi!</p>",
"D_SYNC_FOLDER_PATH": "Polku suhteessa WebDAV-palvelimen juureen, johon synkronointitiedostot tallennetaan (esim. '\\/super-productivity' tai '\\/'). Tämä EI ole palvelimesi sisäinen hakemistopolku.",
"INFO": "WebDAV-toteutukset valitettavasti eroavat toisistaan paljon. Super Productivity tiedetään toimivan hyvin Nextcloudin kanssa, <strong>mutta se ei välttämättä toimi tarjoajasi kanssa</strong>.",
"L_BASE_URL": "Perus-URL",
"L_PASSWORD": "Salasana",
"L_SYNC_FOLDER_PATH": "Synkronointikansion polku",
"L_USER_NAME": "Käyttäjänimi"
"L_TEST_CONNECTION": "Testaa yhteys",
"L_USER_NAME": "Käyttäjänimi",
"S_FILL_ALL_FIELDS": "Täytä ensin kaikki WebDAV-kentät",
"S_TEST_FAIL": "Yhteyden testaus epäonnistui: {{error}} - Kohde-URL: {{url}}",
"S_TEST_SUCCESS": "Yhteyden testaus onnistui! Kohde-URL: {{url}}"
}
},
"S": {
@ -1080,6 +1179,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Tietojen tuonnissa tapahtui virhe. Palataan paikalliseen varmuuskopioon.",
"ERROR_INVALID_DATA": "Virhe synkronoinnissa. Virheelliset tiedot",
"ERROR_NO_REV": "Ei kelvollista versiota etätiedostolle",
"ERROR_PERMISSION": "Tiedoston käyttö estetty. Tarkista tiedostojärjestelmän käyttöoikeudet.",
"ERROR_PERMISSION_FLATPAK": "Tiedoston käyttö estetty. Myönnä tiedostojärjestelmän käyttöoikeus Flatsealilla tai käytä polkua, joka sijaitsee ~\\/var\\/app\\/ sisällä.",
"ERROR_PERMISSION_SNAP": "Tiedoston käyttö estetty. Suorita 'snap connect super-productivity:home' tai käytä polkua, joka sijaitsee ~\\/snap\\/super-productivity\\/common\\/ sisällä.",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Virhe synkronoinnissa. Etätietojen lukeminen ei onnistu. Ehkä otit salauksen käyttöön ja paikallinen salasanasi ei vastaa etätietojen salaamiseen käytettyä salasanaa?",
"IMPORTING": "Tuodaan tietoja",
"INCOMPLETE_CFG": "Synkronoinnin todennus epäonnistui. Tarkista asetuksesi!",
@ -1311,7 +1413,8 @@
},
"D_SELECT_DATE_AND_TIME": {
"DATE": "Päivämäärä",
"TIME": "Aika"
"TIME": "Aika",
"TITLE": "Valitse päivämäärä ja aika"
},
"D_TIME": {
"ADD_FOR_OTHER_DAY": "Lisää käytetty aika toiselle päivälle",
@ -1340,6 +1443,7 @@
"FOUND_MOVE_FROM_BACKLOG": "Siirrettiin tehtävä <strong>{{title}}</strong> taakkaputkesta tämän päivän tehtävälistaan",
"FOUND_MOVE_FROM_OTHER_LIST": "Lisättiin tehtävä <strong>{{title}}</strong> kohteesta <strong>{{contextTitle}}</strong> nykyiseen listaan",
"FOUND_RESTORE_FROM_ARCHIVE": "Palautettiin tehtävä <strong>{{title}}</strong> liittyen ongelmaan arkistosta",
"GO_TO_TASK": "Siirry tehtävään",
"LAST_TAG_DELETION_WARNING": "Yrität poistaa viimeisen tunnisteen ei-projektitehtävältä. Tämä ei ole sallittua!",
"MOVED_TO_ARCHIVE": "Siirrettiin {{nr}} tehtävää arkistoon",
"MOVED_TO_PROJECT": "Siirrettiin tehtävä \"{{taskTitle}}\" projektiin \"{{projectTitle}}\"",
@ -1437,7 +1541,12 @@
"REMOVE_INSTANCE": "Poista tänään",
"REPEAT_CYCLE": "Toistosykli",
"REPEAT_EVERY": "Toista joka",
"REPEAT_FROM_COMPLETION_DATE": "Toista, kun valmis",
"REPEAT_FROM_COMPLETION_DATE_DESCRIPTION": "Seuraava tehtävä luodaan valmistumispäiväsi perusteella, ei aloituspäivästä. (esim. 'Joka 7. päivä' = 7 päivää valmistumisen jälkeen)",
"SATURDAY": "Lauantai",
"SCHEDULE_TYPE_FIXED": "Kiinteä aikataulu (esim. joka maanantai, kuukauden 1. päivä)",
"SCHEDULE_TYPE_FLEXIBLE": "Valmistumisen jälkeen (esim. 7 päivää valmistumisen jälkeen)",
"SCHEDULE_TYPE_LABEL": "Aikataulun tyyppi",
"START_DATE": "Aloituspäivämäärä",
"START_TIME": "Aikataulutettu aloitusaika",
"START_TIME_DESCRIPTION": "Esim. 15:00. Jätä tyhjäksi koko päivän tehtävälle",
@ -1456,6 +1565,7 @@
"FILTER_BY": "Suodata",
"FILTER_DEFAULT": "Ei suodatinta",
"FILTER_ESTIMATED_TIME": "Arvioitu aika",
"FILTER_NOT_SPECIFIED": "Non spécifié",
"FILTER_PROJECT": "Projekti",
"FILTER_SCHEDULED_DATE": "Aikataulutettu päivämäärä",
"FILTER_TAG": "Tunniste",
@ -1476,12 +1586,13 @@
"SORT_CREATION_DATE": "Luomispäivämäärä",
"SORT_DEFAULT": "Oletus",
"SORT_NAME": "Nimi",
"SORT_PERMANENT": "Lajittele (pysyvä)",
"SORT_SCHEDULED_DATE": "Aikataulutettu päivämäärä",
"SORT_TEMPORARY": "Lajittele (väliaikainen)",
"TIME_1HOUR": "> 1 Tunti",
"TIME_2HOUR": "> 2 Tuntia",
"TIME_10MIN": "> 10 Minuuttia",
"TIME_30MIN": "> 30 Minuuttia",
"FILTER_NOT_SPECIFIED": "Non spécifié",
"TIME_SPENT": "Käytetty aika",
"TITLE": "Mukauta tehtävänäkymää"
}
@ -1510,7 +1621,9 @@
"SKIP": "Ohita",
"SPLIT_TIME": "Jaa aika useisiin tehtäviin ja taukoihin",
"TASK": "Tehtävä",
"TRACK_TO": "Seuraa kohteeseen"
"TRACK_TO": "Seuraa kohteeseen",
"WARN_SIMPLE_COUNTER": "Aikaa lasketaan aktivoiduilla yksinkertaisilla laskuripainikkeilla.",
"WARN_SIMPLE_COUNTER_BREAK": "Aikaa lasketaan SILTI aktivoiduilla yksinkertaisilla laskuripainikkeilla."
},
"D_TRACKING_REMINDER": {
"CREATE_AND_TRACK": "<em>Luo</em> ja seuraa kohteeseen",
@ -1613,6 +1726,7 @@
"DISMISS": "Hylkää",
"DO_IT": "Tee se!",
"DONT_SHOW_AGAIN": "Älä näytä uudelleen",
"DUPLICATE": "Kopioi",
"DURATION_DESCRIPTION": "esim. \"5h 23min\" joka tulos 5 tuntia ja 23 minuuttia",
"EDIT": "Muokkaa",
"ENABLED": "Käytössä",
@ -1645,6 +1759,22 @@
"YESTERDAY": "Eilen"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Laudat",
"DONATE_PAGE": "Lahjoitussivu",
"FOCUS_MODE": "Keskittymismoodi",
"HELP": "Ota käyttöön tai poista käytöstä tiettyjä sovelluksen ominaisuuksia käyttöliittymässä.",
"ISSUES_PANEL": "Ongelmapaneeli",
"PLANNER": "Suunnittelija",
"PROJECT_NOTES": "Projektimuistiinpanot",
"SCHEDULE": "Aikataulu",
"SCHEDULE_DAY_PANEL": "Päivän aikataulupaneeli",
"SYNC_BUTTON": "Synkronointipainike",
"TIME_TRACKING": "Sekuntikellon aikaseuranta",
"TITLE": "Sovelluksen ominaisuudet",
"USER_PROFILES": "Käyttäjäprofiilit (Kokeellinen)",
"USER_PROFILES_HINT": "Mahdollistaa erilaisten käyttäjäprofiilien luomisen ja vaihtamisen, joilla on omat asetukset, tehtävät ja synkronointiasetukset. Profiilin hallintapainike näkyy oikeassa yläkulmassa, kun ominaisuus on käytössä. Huom: Ominaisuuden poistaminen käytöstä piilottaa käyttöliittymän, mutta säilyttää profiilitiedot (Kokeellinen ominaisuus, ei takuita. Varmista varmuuskopiointi)."
},
"AUTO_BACKUPS": {
"HELP": "Tallenna automaattisesti kaikki tiedot sovelluskansioosi, jotta ne ovat valmiina, jos jotain menee pieleen.",
"LABEL_IS_ENABLED": "Ota automaattiset varmuuskopiot käyttöön",
@ -1667,7 +1797,12 @@
"FOCUS_MODE": {
"HELP": "Keskittymistila avaa häiriöttömän näytön auttaaksesi sinua keskittymään nykyiseen tehtävääsi.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Avaa aina keskittymistila, kun seuraat",
"L_IS_PLAY_TICK": "Toista tikitystä keskittymisen aikana",
"L_MANUAL_BREAK_START": "Aloita tauot manuaalisesti (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Keskeytä tehtävien seuranta taukojen aikana",
"L_SKIP_PREPARATION_SCREEN": "Ohita valmistelunäyttö (venyttely jne.)",
"L_START_IN_BACKGROUND": "Aloita keskittymisseanssit vain bannerilla (ei peittoa)",
"L_SYNC_SESSION_WITH_TRACKING": "Synkronoi keskittymisseanssi aikaseurannan kanssa",
"TITLE": "Keskittymistila"
},
"IDLE": {
@ -1756,6 +1891,7 @@
"NL": "Hollanti",
"PL": "Puola",
"PT": "Portugali",
"PT_BR": "Portugali (Brasilia)",
"RU": "Venäjä",
"SK": "Slovakki",
"TIME_LOCALE": "Aikamuodon maa-asetus",
@ -1786,6 +1922,7 @@
"DARK_MODE_LIGHT": "Vaalea",
"DARK_MODE_SYSTEM": "Järjestelmä",
"DEFAULT_PROJECT": "Oletusprojekti tehtäville, jos mitään ei ole määritetty",
"DEFAULT_START_PAGE": "Oletus aloitussivu",
"FIRST_DAY_OF_WEEK": "Viikon ensimmäinen päivä",
"HELP": "<p><strong>Etkö näe työpöytäilmoituksia?</strong> Windowsissa saatat haluta tarkistaa Järjestelmä > Ilmoitukset ja toiminnot ja tarkistaa, onko tarvittavat ilmoitukset otettu käyttöön.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Lisää automaattisesti tänään-tunniste työskennellyille tehtäville",
@ -1795,8 +1932,6 @@
"IS_DARK_MODE": "Tumma tila",
"IS_DISABLE_ANIMATIONS": "Poista kaikki animaatiot käytöstä",
"IS_DISABLE_CELEBRATION": "Poista juhlinta käytöstä päivän yhteenvedossa",
"USER_PROFILES": "Ota käyttäjäprofiilit käyttöön (Beta)",
"USER_PROFILES_HINT": "Mahdollistaa erilaisten käyttäjäprofiilien luomisen ja niiden välillä vaihtamisen, jokainen omilla asetuksillaan, tehtävillään ja synkronointimäärityksillään. Profiilien hallintapainike ilmestyy oikeaan yläkulmaan, kun toiminto on käytössä. Huomautus: Tämän ominaisuuden poistaminen käytöstä piilottaa käyttöliittymän, mutta säilyttää profiilitietosi (Beta-ominaisuus, ei takeita. Varmista, että sinulla on varmuuskopio).",
"IS_HIDE_NAV": "Piilota navigointi, kunnes päänimikettä hoveroidaan (vain työpöytä)",
"IS_MINIMIZE_TO_TRAY": "Pienennä tehtäväpalkkiin (vain työpöytä)",
"IS_OVERLAY_INDICATOR_ENABLED": "Ota päällekkäisyysindikaattori-ikkuna käyttöön (työpöytä linux/gnome)",
@ -1804,13 +1939,17 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Näytä nykyinen laskuri tehtäväpalkissa / tilavalikossa (vain työpöytä mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Näytä nykyinen tehtävä tehtäväpalkissa / tilavalikossa (vain työpöytä mac/windows)",
"IS_TURN_OFF_MARKDOWN": "Poista markdown-jäsennys käytöstä tehtävien muistiinpanoissa",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Käytä mukautettua otsikkopalkkia (vain Windows/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Vaatii uudelleenkäynnistyksen voimaan tullakseen",
"START_OF_NEXT_DAY": "Seuraavan päivän alkamisaika",
"START_OF_NEXT_DAY_HINT": "mistä tunnista alkaen haluat laskea seuraavan päivän alkaneen. oletus on keskiyö, joka on 0.",
"TASK_NOTES_TPL": "Tehtävän kuvauksen mallipohja",
"THEME": "Teema",
"THEME_EXPERIMENTAL": "Teema (kokeellinen)",
"THEME_SELECT_LABEL": "Valitse teema",
"TITLE": "Sekalaista"
"TITLE": "Sekalaista",
"USER_PROFILES": "Ota käyttäjäprofiilit käyttöön (Beta)",
"USER_PROFILES_HINT": "Mahdollistaa erilaisten käyttäjäprofiilien luomisen ja niiden välillä vaihtamisen, jokainen omilla asetuksillaan, tehtävillään ja synkronointimäärityksillään. Profiilien hallintapainike ilmestyy oikeaan yläkulmaan, kun toiminto on käytössä. Huomautus: Tämän ominaisuuden poistaminen käytöstä piilottaa käyttöliittymän, mutta säilyttää profiilitietosi (Beta-ominaisuus, ei takeita. Varmista, että sinulla on varmuuskopio)."
},
"POMODORO": {
"BREAK_DURATION": "Lyhyiden taukojen kesto",
@ -1820,6 +1959,8 @@
},
"REMINDER": {
"COUNTDOWN_DURATION": "Näytä banneri X ennen varsinaista muistutusta",
"DEFAULT_TASK_REMIND_OPTION": "Oletusmuistutusvaihtoehto valittu tehtäviä luotaessa",
"DISABLE_REMINDERS": "Poista kaikki muistutukset käytöstä",
"IS_COUNTDOWN_BANNER_ENABLED": "Näytä laskuribanneri ennen kuin muistutukset erääntyvät",
"TITLE": "Muistutukset"
},
@ -1882,6 +2023,9 @@
"TITLE": "Ajanseuranta"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (kopio)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "päivän päästä",
@ -1912,14 +2056,22 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Kopioitu leikepöydälle",
"DUPLICATE_PROJECT_ERROR": "Projektia ei voitu kopioida",
"DUPLICATE_PROJECT_SUCCESS": "Projekti kopioitiin onnistuneesti",
"ERR_COMPRESSION": "Virhe pakkausrajapinnassa",
"FILE_DOWNLOADED": "{{fileName}} ladattu",
"FILE_DOWNLOADED_BTN": "Avaa kansio",
"NAVIGATE_TO_TASK_ERR": "Tehtävään keskittyminen ei onnistunut. Poistitko sen?",
"NO_TASKS_TO_COPY": "Ei kopioitavia tehtäviä",
"NO_TASKS_TO_UNPLAN": "Ei suunnittelemattomia tehtäviä",
"PERSISTENCE_DISALLOWED": "Tietoja ei tallenneta pysyvästi. Ole tietoinen, että tämä voi johtaa tietojen menetykseen!!",
"PERSISTENCE_ERROR": "Virhe pyydettäessä tietojen pysyvää tallennusta: {{err}}",
"RUNNING_X": "Suoritetaan \"{{str}}\".",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} painettu, mutta avaa kirjanmerkit -pikanäppäin on saatavilla vain projektikontekstissa."
"SHARE_FAILED": "Jakaminen epäonnistui. Kopioi manuaalisesti.",
"SHARE_FAILED_FALLBACK": "Jakaminen epäonnistui. Kopioitu leikepöydälle sen sijaan.",
"SHARE_UNAVAILABLE_FALLBACK": "Kopioitu leikepöydälle.",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} painettu, mutta avaa kirjanmerkit -pikanäppäin on saatavilla vain projektikontekstissa.",
"UNPLANNED_TODAY_TASKS": "Poistettu kaikki tämän päivän tehtävät suunnitelmasta"
},
"GPB": {
"ASSETS": "Ladataan resursseja...",
@ -1938,10 +2090,13 @@
"ADD_NEW_TASK": "Lisää uusi tehtävä",
"ALL_PLANNED_LIST": "Suunniteltu / Toista",
"BOARDS": "Taulut",
"COPY_TASK_LIST_MARKDOWN": "Kopioi leikepöydälle",
"CREATE_PROJECT": "Luo projekti",
"CREATE_TAG": "Luo tunniste",
"DELETE_PROJECT": "Poista projekti",
"DELETE_TAG": "Poista tunniste",
"DONATE": "Tue meitä",
"DUPLICATE_PROJECT": "Kopioi",
"ENTER_FOCUS_MODE": "Siirry keskittymistilaan",
"GO_TO_TASK_LIST": "Siirry tehtävälistaan",
"HELP": "Ohje",
@ -1958,6 +2113,7 @@
"METRICS": "Mittarit",
"NO_PROJECT_INFO": "Ei projekteja saatavilla. Voit luoda uuden projektin klikkaamalla \"Luo projekti\" -painiketta.",
"NO_TAG_INFO": "Tällä hetkellä ei ole tunnisteita. Voit lisätä tunnisteita kirjoittamalla `#tunnisteesiNimi` lisätessäsi tai muokatessasi tehtäviä.",
"NO_TASKS_TO_TRACK": "Lisää ensin tehtävä aloittaaksesi ajan seurannan",
"NOTES": "Muistiinpanot",
"NOTES_PANEL_INFO": "Muistiinpanoja voi näyttää vain aikataulusta ja tavallisesta tehtävälistanäkymästä.",
"PLANNER": "Suunnittelija",
@ -1969,6 +2125,7 @@
"SCHEDULE": "Aikataulu",
"SEARCH": "Haku",
"SETTINGS": "Asetukset",
"SHARE_TASK_LIST_MARKDOWN": "Jaa tehtävälista",
"SHOW_SEARCH_BAR": "Näytä hakupalkki",
"SIDE_PANEL_MENU": "Sivupaneelin valikko",
"TAGS": "Tunnisteet",
@ -1979,6 +2136,7 @@
"TOGGLE_SHOW_NOTES": "Näytä/Piilota projektin muistiinpanot",
"TOGGLE_TRACK_TIME": "Käynnistä/Pysäytä ajan seuranta",
"TRIGGER_SYNC": "Synkronoi!",
"UNPLAN_ALL_TASKS": "Poista kaikki tehtävät suunnitelmasta",
"WORKLOG": "Työloki"
},
"MIGRATE": {
@ -1990,6 +2148,10 @@
},
"PDS": {
"ADD_TASKS_FROM_TODAY": "Lisää tehtäviä tänään",
"ARCHIVED_TASKS": {
"PLURAL": "{{count}} suoritettua päätehtävää on arkistoitu",
"SINGULAR": "{{count}} suoritettu päätehtävä on arkistoitu"
},
"BREAK_LABEL": "Tauot (määrä / aika)",
"CELEBRATE": "Ota hetki <i>juhlia!</i>",
"CLEAR_ALL_CONTINUE": "Tyhjennä kaikki valmiit ja jatka",
@ -1998,6 +2160,7 @@
"MSG": "Poistu sovelluksesta?",
"OK": "Poistu"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Voit käyttää tätä tilaa kirjoittaaksesi omat päivän lopun rituaalisi, joista haluat muistutuksen.",
"ESTIMATE_TOTAL": "Arvio yhteensä:",
"EVALUATE_DAY": "Arvioi",
"EXPORT_TASK_LIST": "Vie tehtävälista",

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "ex., nature, montagnes, abstrait",
"TITLE": "Sélectionner une image de fond depuis Unsplash"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Faire un don via GitHub Sponsors",
"INTRO_1": "Super Productivity est entièrement financé par la communauté. Il n'y a pas de suivi, pas de publicités, et aucune collecte de données — vos tâches restent sur votre appareil.",
"INTRO_2": "Si vous appréciez cette approche et souhaitez maintenir le projet en bonne santé et en évolution, un don volontaire est grandement apprécié."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -213,9 +218,14 @@
"ADD_TIME_MINUTE": "Ajouter 1 minute",
"B": {
"BREAK_RUNNING": "Pause en cours",
"END_BREAK": "Fin de la pause",
"END_SESSION": "Fin de la session",
"PAUSE": "Pause",
"POMODORO_BREAK_RUNNING": "Pause #{{cycleNr}} en cours",
"POMODORO_SESSION_RUNNING": "Session Pomodoro #{{cycleNr}} en cours",
"RESUME": "Résumé",
"SESSION_RUNNING": "La session de concentration est en cours",
"START": "Début",
"TO_FOCUS_OVERLAY": "Pour superposition de concentration"
},
"BACK_TO_PLANNING": "Retour à la planification",
@ -249,6 +259,7 @@
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Sprints structurés avec pauses planifiées",
"POMODORO_SESSION_COMPLETED": "Session Pomodoro terminée !",
"POMODORO_SETTINGS": "Paramètres Pomodoro",
"PREP_GET_MENTALLY_READY": "Préparez-vous mentalement à être concentré et productif",
"PREP_SIT_UPRIGHT": "Asseyez-vous (ou tenez-vous debout) droit",
"PREP_STRETCH": "Faire quelques étirements légers",
@ -264,6 +275,7 @@
"SHORT_BREAK_TITLE": "Pause courte - Cycle {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afficher/masquer les notes et pièces jointes de la tâche",
"SKIP_BREAK": "Passer la pause",
"START_BREAK": "Commencer la pause",
"START_FOCUS_SESSION": "Débuter la session de concentration",
"START_NEXT_FOCUS_SESSION": "Débuter la prochaine session de concentration",
"SWITCH_TASK": "Changer de tâche",
@ -857,6 +869,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Annuler",
"MSG": "Ce projet est assez volumineux et peut prendre un certain temps à dupliquer. Voulez-vous continuer ?",
"OK": "Dupliquer quand même",
"TITLE": "Dupliquer le projet ?"
},
"D_CREATE": {
"CREATE": "Créer un projet",
"EDIT": "Modifier le projet",
@ -1140,11 +1158,16 @@
"TITLE": "Sync",
"WEB_DAV": {
"CORS_INFO": "<strong>Expérimental !!</strong> Pour que cela fonctionne, vous devez désactiver ou limiter CORS pour votre instance Nextcloud, ce qui peut avoir des implications négatives sur la sécurité! Veuillez <a href='https://github.com/nextcloud/server/issues/3131'>consulter ce fil de discussion</a> pour plus d'informations. À utiliser à vos risques et périls!",
"D_SYNC_FOLDER_PATH": "Chemin relatif à la racine du serveur WebDAV où les fichiers de synchronisation seront stockés (par exemple, ' /super-productivity' ou ' /'). Ce n'est PAS le chemin interne de votre serveur.",
"INFO": "Les implémentations WebDAV varient malheureusement beaucoup. Super Productivity fonctionne bien avec Nextcloud, <strong>mais il pourrait ne pas fonctionner avec votre fournisseur</strong>.",
"L_BASE_URL": "URL de base",
"L_PASSWORD": "Mot de passe",
"L_SYNC_FOLDER_PATH": "Synchroniser le chemin du dossier",
"L_USER_NAME": "Nom d'utilisateur"
"L_TEST_CONNECTION": "Tester la connexion",
"L_USER_NAME": "Nom d'utilisateur",
"S_FILL_ALL_FIELDS": "Veuillez d'abord remplir tous les champs WebDAV",
"S_TEST_FAIL": "Test de connexion échoué : {{error}} - URL cible : {{url}}",
"S_TEST_SUCCESS": "Test de connexion réussi ! URL cible : {{url}}"
}
},
"S": {
@ -1157,6 +1180,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Quelque chose s'est mal passé lors de l'importation des données. Retour à la sauvegarde locale.",
"ERROR_INVALID_DATA": "Erreur lors de la synchronisation. Données invalides",
"ERROR_NO_REV": "Pas de révision valide pour le fichier distant",
"ERROR_PERMISSION": "Accès au fichier refusé. Veuillez vérifier les permissions de votre système de fichiers.",
"ERROR_PERMISSION_FLATPAK": "Accès au fichier refusé. Accordez la permission du système de fichiers via Flatseal ou utilisez un chemin à l'intérieur de ~ / .var / app /",
"ERROR_PERMISSION_SNAP": "Accès au fichier refusé. Exécutez 'snap connect super-productivity:home' ou utilisez un chemin à l'intérieur de ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Erreur lors de la synchronisation. Impossible de lire les données distantes. Peut-être avez-vous activé le chiffrement et votre mot de passe local ne correspond pas à celui utilisé pour chiffrer les données distantes ?",
"IMPORTING": "Importer des données",
"INCOMPLETE_CFG": "L'authentification pour la synchronisation a échoué. Veuillez vérifier votre configuration!",
@ -1540,6 +1566,7 @@
"FILTER_BY": "Filtrer par",
"FILTER_DEFAULT": "Pas de filtre",
"FILTER_ESTIMATED_TIME": "Temps estimé",
"FILTER_NOT_SPECIFIED": "Toute date",
"FILTER_PROJECT": "Projet",
"FILTER_SCHEDULED_DATE": "Date prévue",
"FILTER_TAG": "Étiquette",
@ -1567,7 +1594,6 @@
"TIME_2HOUR": "> 2 Heures",
"TIME_10MIN": "> 10 Minutes",
"TIME_30MIN": "> 30 Minutes",
"FILTER_NOT_SPECIFIED": "Toute date",
"TIME_SPENT": "Temps passé",
"TITLE": "Personnaliser la vue des tâches"
}
@ -1701,6 +1727,7 @@
"DISMISS": "Rejeter",
"DO_IT": "Fais le !",
"DONT_SHOW_AGAIN": "Ne plus afficher",
"DUPLICATE": "Dupliquer",
"DURATION_DESCRIPTION": "ex: \"5h 23m\" ce qui équivaut à 5 heures et 23 minutes",
"EDIT": "modifier",
"ENABLED": "Activée",
@ -1733,6 +1760,22 @@
"YESTERDAY": "Hier"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Tableaux",
"DONATE_PAGE": "Page de don",
"FOCUS_MODE": "Mode Concentration",
"HELP": "Activer ou désactiver des fonctionnalités spécifiques de l'application dans l'interface utilisateur.",
"ISSUES_PANEL": "Panneau des problèmes",
"PLANNER": "Planificateur",
"PROJECT_NOTES": "Notes de projet",
"SCHEDULE": "Emploi du temps",
"SCHEDULE_DAY_PANEL": "Panneau de la journée planifiée",
"SYNC_BUTTON": "Bouton de synchronisation",
"TIME_TRACKING": "Suivi du temps avec chronomètre",
"TITLE": "Fonctionnalités de l'application",
"USER_PROFILES": "Profils utilisateur (Expérimental)",
"USER_PROFILES_HINT": "Vous permet de créer et de basculer entre différents profils utilisateur, chacun avec des paramètres, des tâches et des configurations de synchronisation séparés. Le bouton de gestion des profils apparaîtra dans le coin supérieur droit lorsqu'il est activé. Remarque : Désactiver cette fonctionnalité masquera l'interface utilisateur mais préservera vos données de profil (fonctionnalité expérimentale, sans garanties. Assurez-vous d'avoir une sauvegarde)."
},
"AUTO_BACKUPS": {
"HELP": "Sauvegardez automatiquement toutes les données dans votre dossier d'applications afin de les retrouver en cas de problème.",
"LABEL_IS_ENABLED": "Activer les sauvegardes automatiques",
@ -1755,7 +1798,12 @@
"FOCUS_MODE": {
"HELP": "Le Mode Concentration ouvre un écran dépourvu de toute distraction pour vous aider à vous focaliser sur la tâche courante",
"L_ALWAYS_OPEN_FOCUS_MODE": "Toujours ouvrir le Mode Concentration, quand le suivi est actif",
"L_IS_PLAY_TICK": "Jouer un son de tic-tac pendant les sessions de concentration",
"L_MANUAL_BREAK_START": "Démarrer manuellement les pauses (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Mettre en pause le suivi des tâches pendant les pauses",
"L_SKIP_PREPARATION_SCREEN": "Passer l'écran de préparation (étirement, etc.)",
"L_START_IN_BACKGROUND": "Démarrer les sessions de concentration avec seulement une bannière (pas de superposition)",
"L_SYNC_SESSION_WITH_TRACKING": "Synchroniser la session de concentration avec le suivi du temps",
"TITLE": "Mode Concentration"
},
"IDLE": {
@ -1844,6 +1892,7 @@
"NL": "Pays-Bas",
"PL": "Polonais",
"PT": "Português",
"PT_BR": "Portugais (Brésil)",
"RU": "Russie",
"SK": "Slovaque",
"TIME_LOCALE": "Format de l'heure locale",
@ -1874,6 +1923,7 @@
"DARK_MODE_LIGHT": "Lumière",
"DARK_MODE_SYSTEM": "Système",
"DEFAULT_PROJECT": "Projet par défaut à utiliser pour les tâches si aucun n'est spécifié",
"DEFAULT_START_PAGE": "Page de démarrage par défaut",
"FIRST_DAY_OF_WEEK": "Premier jour de la semaine",
"HELP": "<p><strong>Vous ne voyez pas les notifications de bureau ?</strong> Pour Windows, vous pouvez vérifier Système> Notifications et actions et vérifier si les notifications requises ont été activées.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Ajouter automatiquement la balise d'aujourd'hui aux tâches travaillées",
@ -1883,8 +1933,6 @@
"IS_DARK_MODE": "Mode sombre",
"IS_DISABLE_ANIMATIONS": "Désactiver toutes les animations",
"IS_DISABLE_CELEBRATION": "Désactiver la célébration dans le résumé quotidien",
"USER_PROFILES": "Activer les profils utilisateur (Bêta)",
"USER_PROFILES_HINT": "Permet de créer et de basculer entre différents profils utilisateur, chacun avec des paramètres, des tâches et des configurations de synchronisation distincts. Le bouton de gestion des profils apparaîtra dans le coin supérieur droit lorsqu'il est activé. Remarque : la désactivation de cette fonctionnalité masquera l'interface utilisateur mais conservera vos données de profil (fonctionnalité bêta, aucune garantie. Assurez-vous d'avoir une sauvegarde).",
"IS_HIDE_NAV": "Masquer la navigation jusqu'à ce que l'en-tête principal soit survolé (bureau uniquement)",
"IS_MINIMIZE_TO_TRAY": "Réduire dans le bac (bureau uniquement)",
"IS_OVERLAY_INDICATOR_ENABLED": "Activer la fenêtre d'indicateur en superposition (Linux/desktop GNOME)",
@ -1892,6 +1940,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Afficher le compte à rebours actuel dans la barre d'état / menu (mac de bureau uniquement)",
"IS_TRAY_SHOW_CURRENT_TASK": "Afficher la tâche en cours dans la barre d'état / menu d'état (bureau uniquement)",
"IS_TURN_OFF_MARKDOWN": "Désactiver l'analyse de démarquage pour les notes",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Utiliser une barre de titre personnalisée (Windows\\/Linux uniquement)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Nécessite un redémarrage pour prendre effet",
"IS_USE_MINIMAL_SIDE_NAV": "Utiliser la barre minimale de navigation (affichage des icônes uniquement)",
"START_OF_NEXT_DAY": "Heure de début le jour suivant",
"START_OF_NEXT_DAY_HINT": "À partir de quelle heure souhaitez-vous considérer que le jour suivant commence. Par défaut, minuit correspond à 0 heure.",
@ -1899,7 +1949,9 @@
"THEME": "Theme",
"THEME_EXPERIMENTAL": "Thème (expérimental)",
"THEME_SELECT_LABEL": "Sélectionner le thème",
"TITLE": "Réglages divers"
"TITLE": "Réglages divers",
"USER_PROFILES": "Activer les profils utilisateur (Bêta)",
"USER_PROFILES_HINT": "Permet de créer et de basculer entre différents profils utilisateur, chacun avec des paramètres, des tâches et des configurations de synchronisation distincts. Le bouton de gestion des profils apparaîtra dans le coin supérieur droit lorsqu'il est activé. Remarque : la désactivation de cette fonctionnalité masquera l'interface utilisateur mais conservera vos données de profil (fonctionnalité bêta, aucune garantie. Assurez-vous d'avoir une sauvegarde)."
},
"POMODORO": {
"BREAK_DURATION": "Durée des pauses courtes",
@ -1910,6 +1962,7 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Afficher la bannière \"X temps\" avant le rappel réel",
"DEFAULT_TASK_REMIND_OPTION": "Option de rappel par défaut sélectionnée lors de la création des tâches",
"DISABLE_REMINDERS": "Désactiver tous les rappels",
"IS_COUNTDOWN_BANNER_ENABLED": "Afficher une bannière de compte à rebours avant l'échéance des rappels",
"TITLE": "Rappels"
},
@ -1972,6 +2025,9 @@
"TITLE": "Suivi du Temps"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (copie)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "dans un jour",
@ -2002,6 +2058,8 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Copié dans le presse-papier",
"DUPLICATE_PROJECT_ERROR": "Le projet n'a pas pu être dupliqué",
"DUPLICATE_PROJECT_SUCCESS": "Projet dupliqué avec succès",
"ERR_COMPRESSION": "Erreur pour l'interface de compression",
"FILE_DOWNLOADED": "{{fileName}} téléchargé",
"FILE_DOWNLOADED_BTN": "Dossier ouvert",
@ -2039,6 +2097,8 @@
"CREATE_TAG": "Créer une balise",
"DELETE_PROJECT": "Supprimer le projet",
"DELETE_TAG": "Supprimer la balise",
"DONATE": "Soutenez-nous",
"DUPLICATE_PROJECT": "Dupliquer",
"ENTER_FOCUS_MODE": "Entrer en mode concentration",
"GO_TO_TASK_LIST": "Aller à la liste des tâches",
"HELP": "Aide",
@ -2055,6 +2115,7 @@
"METRICS": "Métriques",
"NO_PROJECT_INFO": "Aucun projet disponible. Vous pouvez créer un nouveau projet en cliquant sur le bouton \"Créer un projet\".",
"NO_TAG_INFO": "Il n'y a actuellement aucune balise. Vous pouvez ajouter des balises en saisissant \"#maBalise\" lors de l'ajout ou de la modification de tâches.",
"NO_TASKS_TO_TRACK": "Ajoutez d'abord une tâche pour commencer à suivre le temps",
"NOTES": "Remarques",
"NOTES_PANEL_INFO": "Les notes ne peuvent être affichées que depuis les vues de programmation et de listes de tâches.",
"PLANNER": "Planificateur",
@ -2101,6 +2162,7 @@
"MSG": "Quitter l'application ?",
"OK": "Quitter"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Vous pouvez utiliser cet espace pour écrire vos propres rituels de fin de journée, dont vous souhaitez être rappelé.",
"ESTIMATE_TOTAL": "Estimation totale :",
"EVALUATE_DAY": "Évaluer",
"EXPORT_TASK_LIST": "Exporter la liste des tâches",

View file

@ -1281,6 +1281,7 @@
"FILTER_BY": "Filtre pa",
"FILTER_DEFAULT": "Pa gen filtre",
"FILTER_ESTIMATED_TIME": "Evalyasyon tan",
"FILTER_NOT_SPECIFIED": "Nije navedeno",
"FILTER_PROJECT": "Pwojè",
"FILTER_SCHEDULED_DATE": "Dat pwograme",
"FILTER_TAG": "Tag",
@ -1306,7 +1307,6 @@
"TIME_2HOUR": "> 2 èdtan",
"TIME_10MIN": "> 10 minit",
"TIME_30MIN": "> 30 minit",
"FILTER_NOT_SPECIFIED": "Nije navedeno",
"TIME_SPENT": "Tan pase",
"TITLE": "Customize Travay View"
}

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "misalnya, alam, gunung, abstrak",
"TITLE": "Pilih Gambar Latar dari Unsplash"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Donasi melalui GitHub Sponsors",
"INTRO_1": "Super Productivity sepenuhnya didanai oleh komunitas. Tidak ada pelacakan, iklan, atau pengumpulan data — tugas Anda tetap di perangkat Anda.",
"INTRO_2": "Jika Anda menghargai pendekatan ini dan ingin menjaga proyek tetap sehat dan berkembang, donasi sukarela sangat dihargai."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -213,9 +218,14 @@
"ADD_TIME_MINUTE": "Tambah 1 menit",
"B": {
"BREAK_RUNNING": "Istirahat sedang berjalan",
"END_BREAK": "Akhiri istirahat",
"END_SESSION": "Akhiri sesi",
"PAUSE": "Jeda",
"POMODORO_BREAK_RUNNING": "Istirahat #{{cycleNr}} sedang berjalan",
"POMODORO_SESSION_RUNNING": "Sesi Pomodoro #{{cycleNr}} sedang berjalan",
"RESUME": "Lanjutkan",
"SESSION_RUNNING": "Sesi Fokus sedang berjalan",
"START": "Mulai",
"TO_FOCUS_OVERLAY": "Untuk Overlay Fokus"
},
"BACK_TO_PLANNING": "Kembali ke Perencanaan",
@ -249,6 +259,7 @@
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Sprint terstruktur dengan istirahat terencana",
"POMODORO_SESSION_COMPLETED": "Sesi Pomodoro Selesai!",
"POMODORO_SETTINGS": "Pengaturan Pomodoro",
"PREP_GET_MENTALLY_READY": "Bersiaplah secara mental untuk fokus dan produktif",
"PREP_SIT_UPRIGHT": "Duduk (atau berdiri) dengan tegak",
"PREP_STRETCH": "Lakukan peregangan ringan",
@ -264,6 +275,7 @@
"SHORT_BREAK_TITLE": "Istirahat Singkat - Siklus {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Menampilkan/menyembunyikan catatan tugas dan lampiran",
"SKIP_BREAK": "Lewati Istirahat",
"START_BREAK": "Mulai Istirahat",
"START_FOCUS_SESSION": "Mulai Sesi Fokus",
"START_NEXT_FOCUS_SESSION": "Mulai Sesi Fokus berikutnya",
"SWITCH_TASK": "Ganti tugas",
@ -857,6 +869,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Batal",
"MSG": "Proyek ini cukup besar dan mungkin memerlukan waktu untuk diduplikasi. Apakah Anda ingin melanjutkan?",
"OK": "Duplikasi Saja",
"TITLE": "Duplikasi Proyek?"
},
"D_CREATE": {
"CREATE": "Buat Proyek",
"EDIT": "Ubah Proyek",
@ -1140,11 +1158,16 @@
"TITLE": "Sinkronisasi",
"WEB_DAV": {
"CORS_INFO": "<strong>Membuatnya berfungsi di browser:</strong> Agar ini berfungsi di browser, Anda perlu memasukkan Super Productivity untuk permintaan CORS ke whitelist untuk instans Nextcloud Anda. Ini dapat memiliki implikasi keamanan negatif! Silahkan <a href='https://github.com/nextcloud/server/issues/3131'>lihat utas ini untuk informasi lebih lanjut</a>. Salah satu pendekatan untuk membuat ini berfungsi di seluler adalah memasukkannya ke whitelist \"https://app.super-productivity.com\" lewat nextcloud app <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Gunakan dengan risiko Anda sendiri!</p>",
"D_SYNC_FOLDER_PATH": "Jalur relatif terhadap root server WebDAV tempat file sinkronisasi akan disimpan (misalnya, ' /super-productivity' atau '/'). Ini BUKAN jalur direktori internal server Anda.",
"INFO": "Implementasi WebDAV sangat bervariasi sayangnya. Super Productivity dikenal bekerja dengan baik dengan Nextcloud, <strong>tetapi mungkin tidak bekerja dengan penyedia Anda</strong>.",
"L_BASE_URL": "Url Dasar",
"L_PASSWORD": "Kata Sandi",
"L_SYNC_FOLDER_PATH": "Jalur folder sinkronisasi",
"L_USER_NAME": "Nama Pengguna"
"L_TEST_CONNECTION": "Uji koneksi",
"L_USER_NAME": "Nama Pengguna",
"S_FILL_ALL_FIELDS": "Harap isi semua kolom WebDAV terlebih dahulu",
"S_TEST_FAIL": "Uji koneksi gagal: {{error}} - URL target: {{url}}",
"S_TEST_SUCCESS": "Uji koneksi berhasil! URL target: {{url}}"
}
},
"S": {
@ -1157,6 +1180,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Terjadi masalah saat mengimpor data. Kembali ke cadangan lokal.",
"ERROR_INVALID_DATA": "Kesalahan saat menyinkronkan. Data tidak valid",
"ERROR_NO_REV": "Tidak ada rev yang valid untuk file jarak jauh",
"ERROR_PERMISSION": "Akses file ditolak. Harap periksa izin sistem file Anda.",
"ERROR_PERMISSION_FLATPAK": "Akses file ditolak. Berikan izin sistem file melalui Flatseal atau gunakan jalur di dalam ~/ .var/app/",
"ERROR_PERMISSION_SNAP": "Akses file ditolak. Jalankan 'snap connect super-productivity:home' atau gunakan jalur di dalam ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Kesalahan saat menyinkronkan. Tidak dapat membaca data jarak jauh. Mungkin Anda mengaktifkan enkripsi dan kata sandi lokal Anda tidak cocok dengan kata sandi yang digunakan untuk mengenkripsi data jarak jauh?",
"IMPORTING": "Mengimpor data",
"INCOMPLETE_CFG": "Otentikasi untuk sinkronisasi gagal. Silakan periksa Konfigurasi Anda!",
@ -1540,6 +1566,7 @@
"FILTER_BY": "Saring Berdasarkan",
"FILTER_DEFAULT": "Tidak Ada Filter",
"FILTER_ESTIMATED_TIME": "Waktu Perkiraan",
"FILTER_NOT_SPECIFIED": "Tidak ditentukan",
"FILTER_PROJECT": "Proyek",
"FILTER_SCHEDULED_DATE": "Tanggal Terjadwal",
"FILTER_TAG": "Tag",
@ -1567,7 +1594,6 @@
"TIME_2HOUR": "> 2 Jam",
"TIME_10MIN": "> 10 Menit",
"TIME_30MIN": "> 30 Menit",
"FILTER_NOT_SPECIFIED": "Tidak ditentukan",
"TIME_SPENT": "Waktu yang dihabiskan",
"TITLE": "Sesuaikan Tampilan Tugas"
}
@ -1701,6 +1727,7 @@
"DISMISS": "Abaikan",
"DO_IT": "Lakukan!",
"DONT_SHOW_AGAIN": "Jangan tampilkan lagi",
"DUPLICATE": "Duplikat",
"DURATION_DESCRIPTION": "contoh. \"5h 23m\" yang menghasilkan 5 hours 23 minutes (satuan dalam bahasa Inggris)",
"EDIT": "Ubah",
"ENABLED": "Aktifkan",
@ -1733,6 +1760,22 @@
"YESTERDAY": "Kemarin"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Papan",
"DONATE_PAGE": "Halaman Donasi",
"FOCUS_MODE": "Mode Fokus",
"HELP": "Aktifkan atau nonaktifkan fitur aplikasi tertentu di seluruh UI.",
"ISSUES_PANEL": "Panel Masalah",
"PLANNER": "Perencana",
"PROJECT_NOTES": "Catatan Proyek",
"SCHEDULE": "Jadwal",
"SCHEDULE_DAY_PANEL": "Panel Hari Jadwal",
"SYNC_BUTTON": "Tombol Sinkronisasi",
"TIME_TRACKING": "Pelacakan Waktu Stopwatch",
"TITLE": "Fitur Aplikasi",
"USER_PROFILES": "Profil pengguna (Eksperimental)",
"USER_PROFILES_HINT": "Memungkinkan Anda membuat dan beralih antara profil pengguna yang berbeda, masing-masing dengan pengaturan, tugas, dan konfigurasi sinkronisasi terpisah. Tombol manajemen profil akan muncul di sudut kanan atas saat diaktifkan. Catatan: Menonaktifkan fitur ini akan menyembunyikan UI tetapi mempertahankan data profil Anda (Fitur eksperimental, tidak ada jaminan. Pastikan untuk memiliki cadangan)."
},
"AUTO_BACKUPS": {
"HELP": "Simpan otomatis semua data ke folder aplikasi Anda agar siap jika terjadi kesalahan.",
"LABEL_IS_ENABLED": "Aktifkan pencadangan otomatis",
@ -1755,7 +1798,12 @@
"FOCUS_MODE": {
"HELP": "Mode Fokus membuka layar bebas gangguan untuk membantu Anda fokus pada tugas Anda saat ini.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Selalu buka mode fokus, ketika melacak",
"L_IS_PLAY_TICK": "Putar suara detak selama sesi fokus",
"L_MANUAL_BREAK_START": "Mulai istirahat secara manual (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Jeda pelacakan tugas selama istirahat",
"L_SKIP_PREPARATION_SCREEN": "Lewati layar persiapan (peregangan, dll.)",
"L_START_IN_BACKGROUND": "Mulai sesi fokus hanya dengan banner (tanpa overlay)",
"L_SYNC_SESSION_WITH_TRACKING": "Sinkronkan sesi fokus dengan pelacakan waktu",
"TITLE": "Mode Fokus"
},
"IDLE": {
@ -1844,6 +1892,7 @@
"NL": "Belanda",
"PL": "Polandia",
"PT": "Português",
"PT_BR": "Portugis (Brasil)",
"RU": "Rusia",
"SK": "Slowakia",
"TIME_LOCALE": "Format waktu lokal",
@ -1874,6 +1923,7 @@
"DARK_MODE_LIGHT": "Terang",
"DARK_MODE_SYSTEM": "Sistem",
"DEFAULT_PROJECT": "Proyek default untuk digunakan untuk tugas jika tidak ada yang ditentukan",
"DEFAULT_START_PAGE": "Halaman mulai default",
"FIRST_DAY_OF_WEEK": "Hari pertama dalam seminggu",
"HELP": "<p><strong>Tidak melihat Pemberitahuan Desktop?</strong> Untuk windows, Anda mungkin ingin memeriksa Sistem > Pemberitahuan & tindakan dan memeriksa apakah pemberitahuan yang diperlukan telah diaktifkan.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Tambahkan tag hari ini secara otomatis untuk mengerjakan tugas",
@ -1883,8 +1933,6 @@
"IS_DARK_MODE": "Mode Gelap",
"IS_DISABLE_ANIMATIONS": "Nonaktifkan semua animasi",
"IS_DISABLE_CELEBRATION": "Nonaktifkan perayaan di ringkasan harian",
"USER_PROFILES": "Aktifkan profil pengguna (Beta)",
"USER_PROFILES_HINT": "Memungkinkan Anda membuat dan beralih antara profil pengguna yang berbeda, masing-masing dengan pengaturan, tugas, dan konfigurasi sinkronisasi terpisah. Tombol manajemen profil akan muncul di sudut kanan atas saat diaktifkan. Catatan: Menonaktifkan fitur ini akan menyembunyikan UI tetapi mempertahankan data profil Anda (fitur Beta, tidak ada jaminan. Pastikan untuk memiliki cadangan).",
"IS_HIDE_NAV": "Sembunyikan navigasi hingga header utama diarahkan (khusus desktop)",
"IS_MINIMIZE_TO_TRAY": "Minimalkan ke baki (hanya desktop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Aktifkan jendela indikator overlay (desktop linux/gnome)",
@ -1892,6 +1940,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Tampilkan hitung mundur saat ini di tray / menu status (desktop mac saja)",
"IS_TRAY_SHOW_CURRENT_TASK": "Tampilkan tugas saat ini di menu baki / status (khusus desktop mac/windows)",
"IS_TURN_OFF_MARKDOWN": "Matikan penguraian penurunan harga untuk catatan tugas",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Gunakan bilah judul khusus (hanya Windows\\/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Memerlukan restart untuk berlaku",
"IS_USE_MINIMAL_SIDE_NAV": "Gunakan bilah navigasi minimal (hanya menampilkan ikon)",
"START_OF_NEXT_DAY": "Waktu mulai hari berikutnya",
"START_OF_NEXT_DAY_HINT": "dari kapan (dalam jam) Anda ingin menghitung hari berikutnya telah dimulai. defaultnya adalah tengah malam yaitu 0.",
@ -1899,7 +1949,9 @@
"THEME": "Tema",
"THEME_EXPERIMENTAL": "Tema (eksperimental)",
"THEME_SELECT_LABEL": "Pilih Tema",
"TITLE": "Pengaturan Lain-lain"
"TITLE": "Pengaturan Lain-lain",
"USER_PROFILES": "Aktifkan profil pengguna (Beta)",
"USER_PROFILES_HINT": "Memungkinkan Anda membuat dan beralih antara profil pengguna yang berbeda, masing-masing dengan pengaturan, tugas, dan konfigurasi sinkronisasi terpisah. Tombol manajemen profil akan muncul di sudut kanan atas saat diaktifkan. Catatan: Menonaktifkan fitur ini akan menyembunyikan UI tetapi mempertahankan data profil Anda (fitur Beta, tidak ada jaminan. Pastikan untuk memiliki cadangan)."
},
"POMODORO": {
"BREAK_DURATION": "Durasi istirahat pendek",
@ -1910,6 +1962,7 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Tampilkan spanduk X sebelum pengingat yang sebenarnya",
"DEFAULT_TASK_REMIND_OPTION": "Opsi pengingat default dipilih saat membuat tugas",
"DISABLE_REMINDERS": "Nonaktifkan semua pengingat",
"IS_COUNTDOWN_BANNER_ENABLED": "Tampilkan spanduk hitung mundur sebelum pengingat jatuh tempo",
"TITLE": "Pengingat"
},
@ -1972,6 +2025,9 @@
"TITLE": "Pelacakan waktu"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (salin)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "dalam sehari",
@ -2002,6 +2058,8 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Disalin ke papan klip",
"DUPLICATE_PROJECT_ERROR": "Proyek tidak dapat diduplikasi",
"DUPLICATE_PROJECT_SUCCESS": "Proyek berhasil diduplikasi",
"ERR_COMPRESSION": "Kesalahan untuk antarmuka kompresi",
"FILE_DOWNLOADED": "{{fileName}} diunduh",
"FILE_DOWNLOADED_BTN": "Buka folder",
@ -2039,6 +2097,8 @@
"CREATE_TAG": "Buat Tag",
"DELETE_PROJECT": "Menghapus Proyek",
"DELETE_TAG": "Hapus Tag",
"DONATE": "Dukung kami",
"DUPLICATE_PROJECT": "Duplikat",
"ENTER_FOCUS_MODE": "Masukkan Mode Fokus",
"GO_TO_TASK_LIST": "Buka daftar tugas",
"HELP": "Bantuan",
@ -2055,6 +2115,7 @@
"METRICS": "Metrik",
"NO_PROJECT_INFO": "Tidak ada proyek yang tersedia. Anda dapat membuat proyek baru dengan mengklik tombol \"Buat Proyek\".",
"NO_TAG_INFO": "Saat ini tidak ada tag. Anda dapat menambahkan tag dengan memasukkan `#namaTag Anda` saat menambahkan atau mengedit tugas.",
"NO_TASKS_TO_TRACK": "Tambahkan tugas terlebih dahulu untuk mulai melacak waktu",
"NOTES": "Catatan",
"NOTES_PANEL_INFO": "Catatan hanya dapat ditampilkan dari garis waktu dan tampilan daftar tugas biasa.",
"PLANNER": "Perencana",
@ -2101,6 +2162,7 @@
"MSG": "Keluar dari aplikasi?",
"OK": "Keluar"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Anda dapat menggunakan ruang ini untuk menuliskan ritual akhir hari Anda sendiri, yang ingin Anda ingatkan.",
"ESTIMATE_TOTAL": "Total perkiraan:",
"EVALUATE_DAY": "Mengevaluasi",
"EXPORT_TASK_LIST": "Ekspor Daftar Tugas",

View file

@ -27,6 +27,11 @@
"BL": {
"NO_TASKS": "Non ci sono attività arretrate"
},
"BN": {
"SHOW_ISSUE_PANEL": "Mostra pannello problemi",
"SHOW_NOTES": "Mostra note del progetto",
"SHOW_TASK_VIEW_CUSTOMIZER_PANEL": "Mostra pannello filtro/gruppo/ordinamento"
},
"CONFIRM": {
"AUTO_FIX": "I tuoi dati sembrano essere danneggiati. Vuoi provare a risolvere il problema automaticamente? Ciò potrebbe causare una parziale perdita dei dati.",
"RELOAD_AFTER_IDB_ERROR": "Impossibile accedere al database: (Le possibili cause sono un aggiornamento dell'app in background o spazio su disco insufficiente. Premi OK per ricaricare l'app (potrebbe richiedere il riavvio manuale dell'app su alcune piattaforme).",
@ -48,6 +53,11 @@
"SEARCH_PLACEHOLDER": "es. natura, montagne, astratto",
"TITLE": "Seleziona immagine di sfondo da Unsplash"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Dona tramite GitHub Sponsors",
"INTRO_1": "Super Productivity è finanziato interamente dalla comunità. Non ci sono tracciamenti, pubblicità o raccolta dati — le tue attività restano sul tuo dispositivo.",
"INTRO_2": "Se apprezzi questo approccio e vuoi mantenere il progetto sano e in evoluzione, una donazione volontaria è molto apprezzata."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -152,6 +162,7 @@
"BANNER": {
"ADD_AS_TASK": "Aggiungi come attività",
"FOCUS_TASK": "Attività Concentrazione",
"SHOW_TASK": "Mostra attività",
"TXT": "<strong>{{title}}</strong> parte alle <strong>{{start}}</strong>!",
"TXT_MULTIPLE": "<strong>{{title}}</strong> parte alle <strong>{{start}}</strong>!<br> (e {{nrOfOtherBanners}} altri eventi sono scaduti)",
"TXT_PAST": "<strong>{{title}}</strong> avviato alle <strong>{{start}}</strong>!",
@ -204,33 +215,70 @@
}
},
"FOCUS_MODE": {
"ADD_TIME_MINUTE": "Aggiungi 1 minuto",
"B": {
"BREAK_RUNNING": "Pausa in corso",
"END_BREAK": "Termina pausa",
"END_SESSION": "Termina sessione",
"PAUSE": "Pausa",
"POMODORO_BREAK_RUNNING": "Pausa #{{cycleNr}} in corso",
"POMODORO_SESSION_RUNNING": "Sessione Pomodoro #{{cycleNr}} in corso",
"RESUME": "Curriculum Vitae",
"SESSION_RUNNING": "Concentrazione in corso",
"START": "Inizia",
"TO_FOCUS_OVERLAY": "Vai in modalità concentrazione"
},
"BACK_TO_PLANNING": "Torna alla pianificazione",
"BREAK_RELAX_MSG": "Prenditi un momento per rilassarti",
"CLICK_TO_EDIT_DURATION": "Clicca per modificare la durata",
"COMPLETE_FOCUS_SESSION": "Completa sessione di concentrazione",
"COMPLETE_SESSION": "Completa sessione",
"CONGRATS": "Congratulazione per avere completato questa sessione!",
"CONTINUE_FOCUS_SESSION": "Continua Sessione Concentrazione",
"CONTINUE_SESSION": "Continua sessione",
"CONTINUE_TO_NEXT_SESSION": "Continua alla sessione successiva",
"COUNTDOWN": "Countdown",
"COUNTDOWN_HINT": "Concentrati finché il timer non arriva a zero",
"CURRENT_SESSION_TIME_TOOLTIP": "Tempo della sessione corrente",
"FINISH_TASK_AND_SELECT_NEXT": "Termina attività e seleziona la prossima",
"FLOWTIME": "Tempo di flusso",
"FLOWTIME_HINT": "Ritmo flessibile senza timer rigidi",
"FOCUS_TIME_TOOLTIP": "Tempo di concentrazione",
"FOR_TASK": "Per l'attività",
"GET_READY": "Preparati per la tua prossima sessione!",
"GO_TO_PROCRASTINATION": "Ottieni aiuto quando stai procastinando",
"GOGOGO": "Vai, vai, vai!",
"LONG_BREAK": "Pausa lunga",
"LONG_BREAK_TITLE": "Pausa lunga - Ciclo {{cycle}}",
"NEXT": "Prossimo",
"ON": "su",
"OPEN_ISSUE_IN_BROWSER": "Apri issue nel Browser",
"PAUSE_SESSION": "Metti in pausa la sessione",
"PAUSE_TRACKING": "Metti in pausa il tracciamento",
"PAUSE_TRACKING_FOR_CURRENT_TASK": "Metti in pausa il tracciamento per l'attività corrente",
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Sprint strutturati con pause pianificate",
"POMODORO_SESSION_COMPLETED": "Sessione Pomodoro completata!",
"POMODORO_SETTINGS": "Impostazioni Pomodoro",
"PREP_GET_MENTALLY_READY": "Preparati mentalmente per essere concentrato e produttivo",
"PREP_SIT_UPRIGHT": "Siediti o alzati in posizione eretta",
"PREP_STRETCH": "Fai un po' di stretching",
"REMOVE_TIME_MINUTE": "Rimuovi 1 minuto",
"RESUME_SESSION": "Riprendi sessione",
"SELECT_ANOTHER_TASK": "Seleziona un'altra attività",
"SELECT_MODE": "Scegli la modalità di concentrazione",
"SELECT_TASK": "Seleziona l'attività su cui concentrarti",
"SELECT_TASK_TO_FOCUS": "Seleziona attività su cui concentrarti",
"SESSION_COMPLETED": "Sessione di concentrazione completata!",
"SET_FOCUS_SESSION_DURATION": "Imposta la durata della Sessione Concentrazione",
"SHORT_BREAK": "Pausa breve",
"SHORT_BREAK_TITLE": "Pausa breve - Ciclo {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostra/nascondi note e allegati dell'attività",
"SKIP_BREAK": "Salta pausa",
"START_BREAK": "Inizia pausa",
"START_FOCUS_SESSION": "Avvia Sessione Concentrazione",
"START_NEXT_FOCUS_SESSION": "Avvia la prossima Sessione Concentrazione",
"SWITCH_TASK": "Cambia attività",
"WORKED_FOR": "Hai lavorato per"
},
"GITEA": {
@ -409,6 +457,11 @@
"POLLING_CHANGES": "{{issueProviderName}}: Modifiche al sondaggio per {{issuesStr}}"
}
},
"ISSUE_PANEL": {
"CALENDAR_AGENDA": {
"OFFLINE_BANNER_MSG": "Visualizzazione dei risultati del calendario memorizzati nella cache. I dati potrebbero essere obsoleti."
}
},
"JIRA": {
"BANNER": {
"BLOCK_ACCESS_MSG": "Jira: Per prevenire il blocco dalle api, l'accesso è stato bloccato da Super Productivity. Dovresti controllare le tue impostazioni di jira!",
@ -552,6 +605,7 @@
"CHECK": "Ce l'ho fatta!"
},
"CMP": {
"ACTIVITY_HEATMAP": "Mappa di calore delle attività",
"AVG_BREAKS_PER_DAY": "Media delle pause per giorno",
"AVG_TASKS_PER_DAY_WORKED": "Media delle attività lavorate al giorno",
"AVG_TIME_SPENT_ON_BREAKS": "Tempo medio impiegato nelle pause",
@ -559,26 +613,37 @@
"AVG_TIME_SPENT_PER_TASK": "Tempo medio impiegato per attività",
"COUNTING_SUBTASKS": "(conteggio delle sotto-attività)",
"DAYS_WORKED": "Giorni lavorati",
"FOCUS_SESSION_TRENDS": "Sessioni di concentrazione nel tempo",
"GLOBAL_METRICS": "Metriche globali",
"MOOD_PRODUCTIVITY_OVER_TIME": "Umore e produttività nel tempo",
"NO_ADDITIONAL_DATA_YET": "Nessun dato aggiuntivo raccolto. Usa il form nel pannello riepilogo giornaliero \"Valutazione\" per aggiungerlo.",
"PRODUCTIVITY_BREAKDOWN_OVER_TIME": "Produttività e sostenibilità nel tempo",
"SIMPLE_CLICK_COUNTERS_OVER_TIME": "Fare clic su Contatori nel tempo",
"SIMPLE_COUNTERS": "Contatori semplici & Tracciamento abitudini",
"SIMPLE_STOPWATCH_COUNTERS_OVER_TIME": "Contatori cronometro nel tempo",
"TASKS_DONE_CREATED": "Attività (completate/create)",
"TIME_ESTIMATED": "Tempo stimato",
"TIME_FRAME_1_MONTH": "1 mese",
"TIME_FRAME_2_WEEKS": "2 settimane",
"TIME_FRAME_LABEL": "Intervallo di tempo",
"TIME_FRAME_MAX": "Max",
"TIME_SPENT": "Tempo impiegato"
},
"EVAL_FORM": {
"ADD_NOTE_FOR_TOMORROW": "Aggiungi una nota per domani",
"DAILY_STATE": "Stato giornaliero",
"DAILY_STATE_TOOLTIP": "Sistema a quadranti basato su entrambi i punteggi (\u001e550 è alto): Flusso profondo (alto/alto), Sovraccarico (alto/basso), Recupero (basso/alto), Deriva (basso/basso)",
"DISABLE_REPEAT_EVERY_DAY": "Disabilita ripetizione ogni giorno",
"ENABLE_REPEAT_EVERY_DAY": "Ripeti ogni giorno",
"ENERGY_LEVEL": "Come va la tua energia?",
"ENERGY_LEVEL_HINT": "\u001f62b Esausto \u001f610 OK \u001f60a Bene",
"FOCUS_WORK_TIME": "Tempo di lavoro concentrato",
"HELP_H1": "Perché dovrebbe importarmi?",
"HELP_LINK_TXT": "Vai alla sezione metriche",
"HELP_P1": "Tempo di fare una piccola autovalutazione! Le tue risposte verranno salvate e ti forniranno alcune statistiche su come lavori, nella sezione metriche. Inoltre i suggerimenti per domani appariranno sopra alla tua lista delle attività il giorno seguente.",
"HELP_P2": "Questo intende essere meno riguardante il calcolo delle metriche o diventare efficiente come una macchina in quello che fai, piuttosto riguarda il migliorare come ti senti quando lavori. Può essere utile valutare i punti dolenti della tua routine quotidiana, così come lo è trovare fattori che possono aiutarti. Essere giusto un po' sistematici sperabilmente ti aiuterà ad avere un controllo migliore e migliorare quello che riesci.",
"IMPACT_OF_WORK": "Come valuti l'impatto del tuo lavoro oggi?",
"IMPACT_OF_WORK_HINT": "1: Nessun progresso significativo \u001f4: Impatto significativo",
"IMPROVEMENTS": "Cosa ha migliorato la tua produttività?",
"IMPROVEMENTS_TOMORROW": "Cosa potresti fare per migliorare domani?",
"MOOD": "Come ti senti?",
@ -586,12 +651,53 @@
"NOTES": "Note per domani",
"OBSTRUCTIONS": "Cosa ha ostacolato la tua produttività?",
"PRODUCTIVITY": "Quanto efficentemente hai lavorato?",
"PRODUCTIVITY_HINT": "1: Non ho neanche iniziato 10: Molto efficentemente"
"PRODUCTIVITY_HINT": "1: Non ho neanche iniziato 10: Molto efficentemente",
"PRODUCTIVITY_SCORE": "Punteggio di produttivit\u0000e0",
"PRODUCTIVITY_SCORE_TOOLTIP": "65% Impatto (scala 1-4) \u001f30 30% Concentrazione verso l'obiettivo di 4h \u001f30 5% lavoro totale (limitato a 10h)",
"SCORE_BREAKDOWN_TITLE_PRODUCTIVITY": "Ripartizione della produttivit\u0000e0 a 7 giorni",
"SCORE_BREAKDOWN_TITLE_SUSTAINABILITY": "Ripartizione della sostenibilit\u0000e0 a 7 giorni",
"STATE_DEEP_FLOW_HEADLINE": "Flusso profondo: Ottima concentrazione e ritmo sostenibile.",
"STATE_DEEP_FLOW_HINT": "Sei nel punto ideale \u001f4ac rifletti su cosa ha reso possibile questa combinazione.",
"STATE_DRIFT_HEADLINE": "Deriva: Bassa concentrazione e bassa energia.",
"STATE_DRIFT_HINT": "Va bene cos\u0000ec. Rifletti su cosa ti ha distratto e aggiusta delicatamente \u001f4a1 ogni reset \u0000e8 un'opportunit\u0000e0 per imparare.",
"STATE_IMPACT_MISMATCH_HEADLINE": "Basso impatto: Molta concentrazione, poco risultato.",
"STATE_IMPACT_MISMATCH_HINT": "Hai investito una concentrazione solida — ora indirizzala verso un lavoro a maggiore impatto per sentire la differenza.",
"STATE_OVERDRIVE_HEADLINE": "Sovraccarico: produttivo, ma a un costo.",
"STATE_OVERDRIVE_HINT": "Hai fatto molto — ora assicurati di ricaricarti prima che il burnout si insinui.",
"STATE_RECOVERY_HEADLINE": "Recupero: riposo e ricarica.",
"STATE_RECOVERY_HINT": "Stai dando alla tua mente lo spazio per recuperare. Domani avrai l'energia per immergerti di nuovo più a fondo.",
"STATE_STEADY_HEADLINE": "Ritmo costante: progresso coerente e sostenibile.",
"STATE_STEADY_HINT": "Bel equilibrio. Stai mantenendo lo slancio senza esagerare — continua a nutrire ciò che fa funzionare questo ritmo.",
"SUSTAINABILITY_SCORE": "Punteggio di sostenibilità",
"SUSTAINABILITY_SCORE_TOOLTIP": "45% Livello di energia  40% Ore di lavoro ragionevoli (0 a 10h)  15% Equilibrio di concentrazione (ottimale a 4h)",
"TOTAL_WORK_TIME": "Tempo totale di lavoro oggi"
},
"FOCUS_SESSION_DIALOG": {
"ADD_BTN": "Aggiungi",
"ADD_SESSION": "Aggiungi sessione",
"CHART_LABEL": "Tempo di concentrazione (min)",
"NEW_SESSION_DURATION": "Durata",
"SESSIONS_LIST": "Sessioni"
"NO_SESSIONS": "Nessuna sessione di concentrazione per questo giorno",
"SESSIONS_LIST": "Sessioni",
"TITLE": "Sessioni di concentrazione",
"TOTAL_SESSIONS": "Sessioni totali",
"TOTAL_TIME": "Tempo totale"
},
"REFLECTION": {
"HISTORY_BTN": "Visualizza riflessioni passate",
"HISTORY_EMPTY": "Nessuna riflessione salvata ancora. Cattura la nota di oggi per iniziare una serie.",
"HISTORY_TITLE": "Cronologia delle riflessioni",
"PLACEHOLDER_1": "Cosa ha funzionato bene oggi? Cosa dovrebbe cambiare domani?",
"PLACEHOLDER_2": "Piccolo miglioramento per domani?",
"PLACEHOLDER_3": "Dove è scivolata la concentrazione - e come puoi proteggerla?",
"PLACEHOLDER_4": "Quale compito ti ha dato energia? Quale l'ha prosciugata?",
"PLACEHOLDER_5": "Una cosa da ripetere. Una cosa da cambiare.",
"REMIND_LABEL": "Ricordami domani di questo",
"REMINDER_CREATED": "Promemoria di riflessione programmato per domani",
"REMINDER_ERROR": "Impossibile programmare il promemoria",
"REMINDER_NEEDS_TEXT": "Scrivi una riflessione prima di chiedere un promemoria",
"REMINDER_TASK_TITLE": "Rivedi la nota di riflessione",
"TITLE": "Nota di riflessione"
},
"S": {
"SAVE_METRIC": "Metriche salvate con successo"
@ -763,6 +869,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Annulla",
"MSG": "Questo progetto è piuttosto grande e potrebbe richiedere un po' di tempo per duplicarlo. Vuoi procedere?",
"OK": "Duplica comunque",
"TITLE": "Duplicare il progetto?"
},
"D_CREATE": {
"CREATE": "Crea progetto",
"EDIT": "Modifica progetto",
@ -813,14 +925,23 @@
}
},
"PROJECT_FOLDER": {
"CONFIRM_DELETE": "Sei sicuro di voler eliminare la cartella \"{{title}}\"? Tutti i progetti in questa cartella saranno spostati al livello principale.",
"DIALOG": {
"CREATE_TITLE": "Crea cartella",
"EDIT_TITLE": "Modifica cartella",
"NAME_LABEL": "Nome cartella"
"NAME_LABEL": "Nome cartella",
"NAME_PLACEHOLDER": "Inserisci il nome della cartella",
"NAME_REQUIRED": "Il nome della cartella è obbligatorio",
"NO_PARENT": "Nessun genitore (livello principale)",
"PARENT_LABEL": "Cartella genitore"
},
"SELECT": {
"LABEL": "Cartella"
}
"LABEL": "Cartella",
"NO_PARENT": "Nessuna cartella (livello principale)",
"PLACEHOLDER": "Seleziona cartella"
},
"TOOLTIP_CREATE": "Crea cartella progetto",
"TOOLTIP_VISIBILITY": "Mostra/nascondi progetti"
},
"QUICK_HISTORY": {
"NO_DATA": "Nessun dato per l'anno in corso",
@ -857,7 +978,8 @@
}
},
"REFLECTION_NOTE": {
"ACTION_DISMISS": "Chiudi"
"ACTION_DISMISS": "Chiudi",
"MSG": "Nota di riflessione: ({{date}}): {{content}}"
},
"REMINDER": {
"COUNTDOWN_BANNER": {
@ -883,6 +1005,7 @@
"NOW": "Adesso",
"PLAN_END_DAY": "Pianifica alla fine del giorno",
"PLAN_START_DAY": "Pianifica all'inizio del giorno",
"SHIFT_KEY_INFO": "Tieni premuto Shift per attivare/disattivare la modalità pianificazione giornaliera",
"START": "Inizio del lavoro",
"TASK_PROJECTION_INFO": "Proiezione futura di un'attività ripetibile pianificata",
"WEEK": "Settimana"
@ -1035,11 +1158,16 @@
"TITLE": "Sincronizzazione",
"WEB_DAV": {
"CORS_INFO": "<strong>Per farlo funzionare nel browser:</strong> Per far funzionare questo nel browser devi inserire Super Productivity nella whitelist per le richieste CORS per la tua istanza Nextcloud. Questo può avere implicazioni negative per la sicurezza! <a href='https://github.com/nextcloud/server/issues/3131'>Fai riferimento a questo thread per maggiori informazioni</a>. Un approccio per farlo funzionare su mobile è inserire nella whitelist \"https://app.super-productivity.com\" tramite l'app nextcloud <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Usa a tuo rischio e pericolo!</p>",
"D_SYNC_FOLDER_PATH": "Percorso relativo alla radice del server WebDAV dove verranno archiviati i file di sincronizzazione (es. ' /super-productivity' o ' /'). Questo NON è il percorso della directory interna del tuo server.",
"INFO": "Le implementazioni WebDAV differiscono notevolmente purtroppo. Super Productivity funziona bene con Nextcloud, ma potrebbe non funzionare con il tuo provider.",
"L_BASE_URL": "URL di base",
"L_PASSWORD": "Password",
"L_SYNC_FOLDER_PATH": "Sincronizza percorso cartella",
"L_USER_NAME": "Nome utente"
"L_TEST_CONNECTION": "Test connessione",
"L_USER_NAME": "Nome utente",
"S_FILL_ALL_FIELDS": "Per favore, compila prima tutti i campi WebDAV",
"S_TEST_FAIL": "Test di connessione fallito: {{error}} - URL di destinazione: {{url}}",
"S_TEST_SUCCESS": "Test di connessione riuscito! URL di destinazione: {{url}}"
}
},
"S": {
@ -1052,6 +1180,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Qualcosa è andato storto durante l'importazione dei dati. Tornando al backup locale.",
"ERROR_INVALID_DATA": "Errore durante la sincronizzazione. Dati non validi",
"ERROR_NO_REV": "Nessuna revisione valida del file remoto",
"ERROR_PERMISSION": "Accesso al file negato. Controlla i permessi del filesystem.",
"ERROR_PERMISSION_FLATPAK": "Accesso al file negato. Concedi il permesso del filesystem tramite Flatseal o usa un percorso all'interno di ~\\/var\\/app\\/",
"ERROR_PERMISSION_SNAP": "Accesso al file negato. Esegui 'snap connect super-productivity:home' o usa un percorso all'interno di ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Errore durante la sincronizzazione. Impossibile leggere i dati remoti. Forse hai abilitato la cifratura e la tua password locale non corrisponde con quella usata per i dati remoti?",
"IMPORTING": "Importazione di dati",
"INCOMPLETE_CFG": "Autenticazione per la sincronizzazione non riuscita. Per favore controlla la tua configurazione!",
@ -1124,14 +1255,22 @@
}
},
"TAG_FOLDER": {
"CONFIRM_DELETE": "Sei sicuro di voler eliminare la cartella \"{{title}}\"? Tutti i tag in questa cartella saranno spostati al livello radice.",
"DIALOG": {
"CREATE_TITLE": "Crea cartella",
"EDIT_TITLE": "Modifica cartella",
"NAME_LABEL": "Nome cartella"
"NAME_LABEL": "Nome cartella",
"NAME_PLACEHOLDER": "Inserisci il nome della cartella",
"NAME_REQUIRED": "Il nome della cartella è obbligatorio",
"NO_PARENT": "Nessun genitore (livello principale)",
"PARENT_LABEL": "Cartella genitore"
},
"SELECT": {
"LABEL": "Cartella"
}
"LABEL": "Cartella",
"NO_PARENT": "Nessuna cartella (livello principale)",
"PLACEHOLDER": "Seleziona cartella"
},
"TOOLTIP_CREATE": "Crea cartella tag"
},
"TASK": {
"ADD_TASK_BAR": {
@ -1275,7 +1414,8 @@
},
"D_SELECT_DATE_AND_TIME": {
"DATE": "Data",
"TIME": "Tempo"
"TIME": "Tempo",
"TITLE": "Seleziona data e ora"
},
"D_TIME": {
"ADD_FOR_OTHER_DAY": "Aggiungi il tempo impiegato per un'altro giorno",
@ -1304,6 +1444,7 @@
"FOUND_MOVE_FROM_BACKLOG": "Attività <strong>{{title}}</strong> spostata dagli arretrati alla lista delle attività di oggi",
"FOUND_MOVE_FROM_OTHER_LIST": "Attività <strong>{{title}}</strong> aggiunta da <strong>{{contextTitle}}</strong> alla lista attuale",
"FOUND_RESTORE_FROM_ARCHIVE": "Ripristinata attività <strong>{{title}}</strong> relativa all'issue dall'archivio",
"GO_TO_TASK": "Vai al compito",
"LAST_TAG_DELETION_WARNING": "Stai cercando di rimuovere l'ultimo tag di un'attività non di progetto. Questo non è permesso!",
"MOVED_TO_ARCHIVE": "Spostato {{nr}} attività nell'archivio",
"MOVED_TO_PROJECT": "Spostata attività \"{{taskTitle}}\" al progetto \"{{projectTitle}}\"",
@ -1358,6 +1499,10 @@
"MSG": "Ci sono {{tasksNr}} istanze create per questa attività ripetibile. Vuoi aggiornarle tutte con le nuove impostazioni predefinite o solo quelle future?",
"OK": "Aggiorna tutte"
},
"D_DELETE_INSTANCE": {
"MSG": "Rimuovere l'istanza del compito ripetuto del {{date}}? Questo impedirà che il compito venga creato solo in questa data.",
"OK": "Rimuovi istanza"
},
"D_EDIT": {
"ADD": "Aggiungi configurazione di ripetizione attività",
"ADVANCED_CFG": "Configurazione avanzata",
@ -1374,7 +1519,11 @@
"C_WEEK": "Settimana",
"C_YEAR": "Anno",
"DEFAULT_ESTIMATE": "Stima predefinita",
"DISABLE_AUTO_UPDATE_SUBTASKS": "Disabilita l'aggiornamento automatico dei sotto-compiti",
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Non aggiornare automaticamente i sotto-compiti ereditati quando cambia l'istanza più recente",
"FRIDAY": "Venerdì",
"INHERIT_SUBTASKS": "Eredita sotto-compiti",
"INHERIT_SUBTASKS_DESCRIPTION": "Quando abilitato, i sotto-compiti dell'istanza di compito più recente saranno ricreati con il compito ripetuto",
"IS_ADD_TO_BOTTOM": "Sposta l'attività in fondo all'elenco",
"MONDAY": "Lunedì",
"NOTES": "Note predefinite",
@ -1389,9 +1538,15 @@
"QUICK_SETTING": "Configura Ripetizione",
"REMIND_AT": "Ricorda a",
"REMIND_AT_PLACEHOLDER": "Seleziona quando ricordare",
"REMOVE_FOR_DATE": "Rimuovi per {{date}}",
"REMOVE_INSTANCE": "Rimuovi oggi",
"REPEAT_CYCLE": "Ripeti ciclo",
"REPEAT_EVERY": "Ripeti ogni",
"REPEAT_FROM_COMPLETION_DATE": "Ripeti, quando completato",
"REPEAT_FROM_COMPLETION_DATE_DESCRIPTION": "Il compito successivo viene creato dalla data di completamento, non dalla data di inizio. (es. \"Ogni 7 giorni\" = 7 giorni dopo il completamento)",
"SATURDAY": "Sabato",
"SCHEDULE_TYPE_FIXED": "Programma fisso (es. ogni lunedì, 1° del mese)",
"SCHEDULE_TYPE_FLEXIBLE": "Dopo il completamento (es. 7 giorni dopo che ho finito)",
"SCHEDULE_TYPE_LABEL": "Tipo di programma",
"START_DATE": "Data di inizio",
"START_TIME": "Orario di inizio programmato",
@ -1411,6 +1566,7 @@
"FILTER_BY": "Filtra per",
"FILTER_DEFAULT": "Nessun filtro",
"FILTER_ESTIMATED_TIME": "Tempo stimato",
"FILTER_NOT_SPECIFIED": "Non specificato",
"FILTER_PROJECT": "Progetto",
"FILTER_SCHEDULED_DATE": "Data programmata",
"FILTER_TAG": "Etichetta",
@ -1431,12 +1587,13 @@
"SORT_CREATION_DATE": "Data di creazione",
"SORT_DEFAULT": "Predefinito",
"SORT_NAME": "Nome",
"SORT_PERMANENT": "Ordina (permanente)",
"SORT_SCHEDULED_DATE": "Data programmata",
"SORT_TEMPORARY": "Ordina (temporaneo)",
"TIME_1HOUR": "> 1 ora",
"TIME_2HOUR": "> 2 ore",
"TIME_10MIN": "> 10 minuti",
"TIME_30MIN": "> 30 minuti",
"FILTER_NOT_SPECIFIED": "Non specificato",
"TIME_SPENT": "Tempo impiegato",
"TITLE": "Personalizza vista attività"
}
@ -1465,7 +1622,9 @@
"SKIP": "Salta",
"SPLIT_TIME": "Dividi il tempo in attività e pause",
"TASK": "Attività",
"TRACK_TO": "Traccia su:"
"TRACK_TO": "Traccia su:",
"WARN_SIMPLE_COUNTER": "Il tempo sarà conteggiato verso i pulsanti del contatore semplice attivati.",
"WARN_SIMPLE_COUNTER_BREAK": "Il tempo SARÀ ANCORA conteggiato sui pulsanti del contatore semplice attivati."
},
"D_TRACKING_REMINDER": {
"CREATE_AND_TRACK": "<em>Crea</em> e monitora in",
@ -1528,6 +1687,7 @@
},
"WEEK": {
"EXPORT": "Esporta dati della settimana",
"FOCUS_SUMMARY": "Sessioni di concentrazione (nr / tempo)",
"NO_DATA": "Ancora nessuna attività questa settimana.",
"TITLE": "Titolo"
}
@ -1566,6 +1726,8 @@
"DELETE": "Cancella",
"DISMISS": "Rimuovi",
"DO_IT": "Fallo!",
"DONT_SHOW_AGAIN": "Non mostrare più",
"DUPLICATE": "Duplicato",
"DURATION_DESCRIPTION": "es. \"5h 23m\" diventerà 5 ore e 23 minuti",
"EDIT": "Modifica",
"ENABLED": "Abilitato",
@ -1598,6 +1760,22 @@
"YESTERDAY": "Ieri"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Bacheche",
"DONATE_PAGE": "Pagina delle donazioni",
"FOCUS_MODE": "Modalità di concentrazione",
"HELP": "Abilita o disabilita funzionalità specifiche dell'app in tutta l'interfaccia utente.",
"ISSUES_PANEL": "Pannello delle problematiche",
"PLANNER": "Pianificatore",
"PROJECT_NOTES": "Note del progetto",
"SCHEDULE": "Programma",
"SCHEDULE_DAY_PANEL": "Pannello del giorno programmato",
"SYNC_BUTTON": "Pulsante di sincronizzazione",
"TIME_TRACKING": "Cronometro per il tracciamento del tempo",
"TITLE": "Funzionalità dell'app",
"USER_PROFILES": "Profili utente (Sperimentale)",
"USER_PROFILES_HINT": "Ti consente di creare e passare tra diversi profili utente, ciascuno con impostazioni, attività e configurazioni di sincronizzazione separate. Il pulsante di gestione del profilo apparirà nell'angolo in alto a destra quando abilitato. Nota: Disabilitare questa funzione nasconderà l'interfaccia utente ma preserva i dati del profilo (funzionalità sperimentale, nessuna garanzia. Assicurati di avere un backup)."
},
"AUTO_BACKUPS": {
"HELP": "Salva automaticamente tutti i dati sulla cartella dell'applicazione in modo da averli pronti nel caso succedesse qualcosa.",
"LABEL_IS_ENABLED": "Abilita backup automatici",
@ -1620,7 +1798,12 @@
"FOCUS_MODE": {
"HELP": "La Modalità Concentrazione are una schermata senza distrazioni per aiutarti a concentrarti sul'attività corrente.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Apri sempre la modalità concentrazione mentre tracci",
"L_IS_PLAY_TICK": "Riproduci suono di ticchettio durante le sessioni di concentrazione",
"L_MANUAL_BREAK_START": "Avvia manualmente le pause (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Metti in pausa il tracciamento delle attività durante le pause",
"L_SKIP_PREPARATION_SCREEN": "Salta la schermata di preparazione (stretching ecc.)",
"L_START_IN_BACKGROUND": "Avvia le sessioni di concentrazione solo con banner (senza sovrapposizione)",
"L_SYNC_SESSION_WITH_TRACKING": "Sincronizza la sessione di concentrazione con il tracciamento del tempo",
"TITLE": "Modalità Concentrazione"
},
"IDLE": {
@ -1636,6 +1819,7 @@
},
"KEYBOARD": {
"ADD_NEW_NOTE": "Aggiungi nuova nota",
"ADD_NEW_PROJECT": "Aggiungi nuovo progetto",
"ADD_NEW_TASK": "Aggiungi nuova attività",
"APP_WIDE_SHORTCUTS": "Scorciatoie globali (a tutta l'applicazione)",
"COLLAPSE_SUB_TASKS": "Collassa le sotto-attività",
@ -1708,6 +1892,7 @@
"NL": "Nederlands",
"PL": "Polish",
"PT": "Português",
"PT_BR": "Portoghese (Brasile)",
"RU": "Русский",
"SK": "Slovak",
"TIME_LOCALE": "Formato orario locale",
@ -1723,6 +1908,7 @@
"TIME_LOCALE_KO_KR": "Coreano - 12 ore AM/PM",
"TIME_LOCALE_PT_BR": "Portoghese (Brasile) - 24 ore",
"TIME_LOCALE_RU_RU": "Russo - 24 ore",
"TIME_LOCALE_TR_TR": "Turco: 24 ore, GG.MM.AAAA",
"TIME_LOCALE_ZH_CN": "Cinese (Semplificato) - 24 ore",
"TITLE": "Lingua",
"TR": "Türkçe",
@ -1737,6 +1923,7 @@
"DARK_MODE_LIGHT": "Chiaro",
"DARK_MODE_SYSTEM": "Sistema",
"DEFAULT_PROJECT": "Progetto predefinito da utilizzare per le attività se non ne viene specificato nessuno",
"DEFAULT_START_PAGE": "Pagina iniziale predefinita",
"FIRST_DAY_OF_WEEK": "Primo giorno della settimana",
"HELP": "<p><strong>Non vedi le notifiche desktop?</strong>Per Windows controlla Sistema > Notifiche & azioni e controlla se le notifiche richieste sono abilitate.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Aggiungi automaticamente il tag oggi alle attività lavorate",
@ -1746,8 +1933,6 @@
"IS_DARK_MODE": "Tema scuro",
"IS_DISABLE_ANIMATIONS": "Disabilita tutte le animazioni",
"IS_DISABLE_CELEBRATION": "Disattiva la celebrazione nel riepilogo giornaliero",
"USER_PROFILES": "Abilita profili utente (Beta)",
"USER_PROFILES_HINT": "Consente di creare e passare tra diversi profili utente, ciascuno con impostazioni, attività e configurazioni di sincronizzazione separate. Il pulsante di gestione del profilo apparirà nell'angolo in alto a destra quando abilitato. Nota: la disabilitazione di questa funzione nasconderà l'interfaccia utente ma conserverà i dati del profilo (funzione Beta, nessuna garanzia. Assicurati di avere un backup).",
"IS_HIDE_NAV": "Nascondi la navigazione mentre il cursore è sull'header principale (solo Desktop)",
"IS_MINIMIZE_TO_TRAY": "Riduci a icona nel vassoio (solo desktop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Abilita finestra indicatore overlay (desktop linux/gnome)",
@ -1755,6 +1940,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Mostra countdown corrente nella barra delle applicazioni / menu di stato (solo desktop Mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Mostra l'attività corrente nella barra delle applicazioni / menu di stato (solo desktop)",
"IS_TURN_OFF_MARKDOWN": "Disattiva markdown per le note",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Usa barra del titolo personalizzata (solo Windows/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Richiede il riavvio per avere effetto",
"IS_USE_MINIMAL_SIDE_NAV": "Usa barra di navigazione minima (mostra solo icone)",
"START_OF_NEXT_DAY": "Ora di inizio del prossimo giorno",
"START_OF_NEXT_DAY_HINT": "da quando (in ora) vuoi contare che il prossimo giorno è iniziato. Il predefinito è mezzanotte cioè 0.",
@ -1762,7 +1949,9 @@
"THEME": "Tema",
"THEME_EXPERIMENTAL": "Tema (sperimentale)",
"THEME_SELECT_LABEL": "Seleziona tema",
"TITLE": "Impostazioni varie"
"TITLE": "Impostazioni varie",
"USER_PROFILES": "Abilita profili utente (Beta)",
"USER_PROFILES_HINT": "Consente di creare e passare tra diversi profili utente, ciascuno con impostazioni, attività e configurazioni di sincronizzazione separate. Il pulsante di gestione del profilo apparirà nell'angolo in alto a destra quando abilitato. Nota: la disabilitazione di questa funzione nasconderà l'interfaccia utente ma conserverà i dati del profilo (funzione Beta, nessuna garanzia. Assicurati di avere un backup)."
},
"POMODORO": {
"BREAK_DURATION": "Durata delle pause brevi",
@ -1772,6 +1961,8 @@
},
"REMINDER": {
"COUNTDOWN_DURATION": "Mostra banner X tempo prima del promemoria attuale",
"DEFAULT_TASK_REMIND_OPTION": "Opzione di promemoria predefinita selezionata durante la creazione delle attività",
"DISABLE_REMINDERS": "Disabilita tutti i promemoria",
"IS_COUNTDOWN_BANNER_ENABLED": "Mostra banner con il conto alla rovescia perima che i promemoria siano in scadenza",
"TITLE": "Promemoria"
},
@ -1834,6 +2025,9 @@
"TITLE": "Monitoraggio del tempo"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (copia)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "tra un giorno",
@ -1864,14 +2058,22 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Copiato negli appunti",
"DUPLICATE_PROJECT_ERROR": "Impossibile duplicare il progetto",
"DUPLICATE_PROJECT_SUCCESS": "Progetto duplicato con successo",
"ERR_COMPRESSION": "Errore per l'interfaccia di compressione",
"FILE_DOWNLOADED": "{{fileName}} scaricato",
"FILE_DOWNLOADED_BTN": "Cartella Aperta",
"NAVIGATE_TO_TASK_ERR": "Impossibile concentrarsi sull'attività. L'hai cancellata?",
"NO_TASKS_TO_COPY": "Nessuna attività da copiare",
"NO_TASKS_TO_UNPLAN": "Nessuna attività da ripianificare",
"PERSISTENCE_DISALLOWED": "I dati non saranno salvati permanentemente. Fai attenzione che questo può portare a perdita dei dati!!",
"PERSISTENCE_ERROR": "Errore durante la richiesta di dati persistenti: {{err}}",
"RUNNING_X": "Esecuzione di \"{{str}} in corso\".",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} premuto, ma la scorciatoia Apri segnalibri è disponibile solo nel contesto del progetto."
"SHARE_FAILED": "Condivisione fallita. Si prega di copiare manualmente.",
"SHARE_FAILED_FALLBACK": "Condivisione fallita. Copiato negli appunti invece.",
"SHARE_UNAVAILABLE_FALLBACK": "Copiato negli appunti.",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} premuto, ma la scorciatoia Apri segnalibri è disponibile solo nel contesto del progetto.",
"UNPLANNED_TODAY_TASKS": "Tutte le attività di Oggi sono state ripianificate"
},
"GPB": {
"ASSETS": "Caricamento risorse in corso ...",
@ -1895,6 +2097,8 @@
"CREATE_TAG": "Crea etichetta",
"DELETE_PROJECT": "Elimina progetto",
"DELETE_TAG": "Elimina etichetta",
"DONATE": "Supportaci",
"DUPLICATE_PROJECT": "Duplicato",
"ENTER_FOCUS_MODE": "Entra in modalità concentrazione",
"GO_TO_TASK_LIST": "Vai alla lista delle attività",
"HELP": "Aiuto",
@ -1911,6 +2115,7 @@
"METRICS": "Metriche",
"NO_PROJECT_INFO": "Nessun progetto disponibile. Puoi creare un nuovo progetto cliccando su \"Crea Progetto\".",
"NO_TAG_INFO": "Non ci sono tag. Puoi aggiungere tag inserendo `#nomeTag` mentre aggiungi o modifichi le attività.",
"NO_TASKS_TO_TRACK": "Aggiungi prima un'attività per iniziare a tracciare il tempo",
"NOTES": "Note",
"NOTES_PANEL_INFO": "Le note possono solo essere visualizzate dalla vista di pianificazione e dalla lista attività.",
"PLANNER": "Pianificatore",
@ -1922,6 +2127,7 @@
"SCHEDULE": "Pianifica",
"SEARCH": "Cerca",
"SETTINGS": "Impostazioni",
"SHARE_TASK_LIST_MARKDOWN": "Condividi lista attività",
"SHOW_SEARCH_BAR": "Mostra barra di ricerca",
"SIDE_PANEL_MENU": "Menu pannello laterale",
"TAGS": "Etichette",
@ -1932,6 +2138,7 @@
"TOGGLE_SHOW_NOTES": "Mostra/nascondi note di progetto",
"TOGGLE_TRACK_TIME": "Inizia/smetti di tracciare il tempo",
"TRIGGER_SYNC": "Attiva la sincronizzazione manualmente",
"UNPLAN_ALL_TASKS": "Ripianifica tutte le attività",
"WORKLOG": "Registro di lavoro"
},
"MIGRATE": {
@ -1943,6 +2150,10 @@
},
"PDS": {
"ADD_TASKS_FROM_TODAY": "Aggiungi attività da oggi",
"ARCHIVED_TASKS": {
"PLURAL": "{{count}} attività genitore completate sono state archiviate",
"SINGULAR": "{{count}} attività genitore completata è stata archiviata"
},
"BREAK_LABEL": "Pause (numero / tempo)",
"CELEBRATE": "Prenditi un attimo per <i>celebrare!</i>",
"CLEAR_ALL_CONTINUE": "Cancella tutte le attività fatte e continua",
@ -1951,9 +2162,11 @@
"MSG": "Uscire dall'applicazione?",
"OK": "Esci"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Puoi usare questo spazio per annotare i tuoi rituali di fine giornata, di cui vuoi essere ricordato.",
"ESTIMATE_TOTAL": "Stima totale:",
"EVALUATE_DAY": "Valutazione",
"EXPORT_TASK_LIST": "Esporta lista delle attività",
"FOCUS_SUMMARY": "Sessioni di concentrazione",
"NO_TASKS": "Non ci sono attività in questa giornata",
"PLAN_TOMORROW": "Pianifica",
"REVIEW_TASKS": "Rivedi",

View file

@ -1540,6 +1540,7 @@
"FILTER_BY": "フィルター",
"FILTER_DEFAULT": "フィルターなし",
"FILTER_ESTIMATED_TIME": "推定時間",
"FILTER_NOT_SPECIFIED": "指定されていない",
"FILTER_PROJECT": "プロジェクト",
"FILTER_SCHEDULED_DATE": "開催予定日",
"FILTER_TAG": "タグ",
@ -1567,7 +1568,6 @@
"TIME_2HOUR": "> 2時間",
"TIME_10MIN": "> 10分間",
"TIME_30MIN": ">30分",
"FILTER_NOT_SPECIFIED": "指定されていない",
"TIME_SPENT": "滞在時間",
"TITLE": "タスクビューのカスタマイズ"
}
@ -1883,8 +1883,6 @@
"IS_DARK_MODE": "ダークモード",
"IS_DISABLE_ANIMATIONS": "すべてのアニメーションを無効にする",
"IS_DISABLE_CELEBRATION": "日次サマリーの祝賀を無効にする",
"USER_PROFILES": "ユーザープロファイルを有効にする(ベータ版)",
"USER_PROFILES_HINT": "異なる設定、タスク、同期設定を持つ複数のユーザープロファイルを作成して切り替えることができます。有効にすると、プロファイル管理ボタンが右上隅に表示されます。注意この機能を無効にするとUIは非表示になりますが、プロファイルデータは保持されますベータ機能、保証なし。必ずバックアップを取ってください。",
"IS_HIDE_NAV": "メインヘッダーが表示されるまでナビゲーションを非表示にする(デスクトップのみ)",
"IS_MINIMIZE_TO_TRAY": "トレイに最小化(デスクトップのみ)",
"IS_OVERLAY_INDICATOR_ENABLED": "オーバーレイインジケーターウィンドウを有効にする(デスクトップLinux/gnome)",
@ -1899,7 +1897,9 @@
"THEME": "テーマ",
"THEME_EXPERIMENTAL": "テーマ(実験的)",
"THEME_SELECT_LABEL": "セレクト・テーマ",
"TITLE": "その他の設定"
"TITLE": "その他の設定",
"USER_PROFILES": "ユーザープロファイルを有効にする(ベータ版)",
"USER_PROFILES_HINT": "異なる設定、タスク、同期設定を持つ複数のユーザープロファイルを作成して切り替えることができます。有効にすると、プロファイル管理ボタンが右上隅に表示されます。注意この機能を無効にするとUIは非表示になりますが、プロファイルデータは保持されますベータ機能、保証なし。必ずバックアップを取ってください。"
},
"POMODORO": {
"BREAK_DURATION": "短い休憩時間",

View file

@ -1302,6 +1302,7 @@
"FILTER_BY": "필터링 기준",
"FILTER_DEFAULT": "필터 없음",
"FILTER_ESTIMATED_TIME": "예상 시간",
"FILTER_NOT_SPECIFIED": "지정되지 않음",
"FILTER_PROJECT": "프로젝트",
"FILTER_SCHEDULED_DATE": "예정일",
"FILTER_TAG": "태그",
@ -1327,7 +1328,6 @@
"TIME_2HOUR": "> 2 시간",
"TIME_10MIN": "> 10분",
"TIME_30MIN": "> 30분",
"FILTER_NOT_SPECIFIED": "지정되지 않음",
"TIME_SPENT": "소요 시간",
"TITLE": "작업 보기 사용자 지정"
}
@ -1617,8 +1617,6 @@
"IS_DARK_MODE": "다크 모드",
"IS_DISABLE_ANIMATIONS": "모든 애니메이션 비활성화",
"IS_DISABLE_CELEBRATION": "일일 요약에서 축하 비활성화",
"USER_PROFILES": "사용자 프로필 활성화 (베타)",
"USER_PROFILES_HINT": "각각 별도의 설정, 작업 및 동기화 구성을 가진 다른 사용자 프로필을 생성하고 전환할 수 있습니다. 활성화되면 프로필 관리 버튼이 오른쪽 상단에 나타납니다. 참고: 이 기능을 비활성화하면 UI가 숨겨지지만 프로필 데이터는 유지됩니다 (베타 기능, 보증 없음. 백업이 있는지 확인하십시오).",
"IS_HIDE_NAV": "기본 헤더가 올라갈 때까지 탐색을 숨 깁니다 (데스크톱 만 해당).",
"IS_MINIMIZE_TO_TRAY": "트레이로 최소화 (데스크탑만 해당)",
"IS_SHOW_TIP_LONGER": "앱 시작 시 생산성 팁 표시좀 더 오래",
@ -1629,7 +1627,9 @@
"START_OF_NEXT_DAY": "다음 날 시작 시간",
"START_OF_NEXT_DAY_HINT": "다음 날을 계산하려는 시점(시간 단위)부터 시작됩니다. 기본값은 0인 자정입니다.",
"TASK_NOTES_TPL": "작업 설명 템플릿",
"TITLE": "기타 설정"
"TITLE": "기타 설정",
"USER_PROFILES": "사용자 프로필 활성화 (베타)",
"USER_PROFILES_HINT": "각각 별도의 설정, 작업 및 동기화 구성을 가진 다른 사용자 프로필을 생성하고 전환할 수 있습니다. 활성화되면 프로필 관리 버튼이 오른쪽 상단에 나타납니다. 참고: 이 기능을 비활성화하면 UI가 숨겨지지만 프로필 데이터는 유지됩니다 (베타 기능, 보증 없음. 백업이 있는지 확인하십시오)."
},
"POMODORO": {
"BREAK_DURATION": "짧은 휴식 시간",

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "f.eks., natur, fjell, abstrakt",
"TITLE": "Velg bakgrunnsbilde fra Unsplash"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Doner via GitHub Sponsors",
"INTRO_1": "Super Productivity finansieres helt av fellesskapet. Det er ingen sporing, ingen annonser, og ingen datainnsamling — oppgavene dine forblir på enheten din.",
"INTRO_2": "Hvis du verdsetter denne tilnærmingen og ønsker å holde prosjektet sunt og i utvikling, er en frivillig donasjon svært verdsatt."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -213,9 +218,14 @@
"ADD_TIME_MINUTE": "Legg til 1 minutt",
"B": {
"BREAK_RUNNING": "Pause pågår",
"END_BREAK": "Avslutt pause",
"END_SESSION": "Avslutt økt",
"PAUSE": "Pause",
"POMODORO_BREAK_RUNNING": "Pause #{{cycleNr}} pågår",
"POMODORO_SESSION_RUNNING": "Pomodoro-økt #{{cycleNr}} pågår",
"RESUME": "CV",
"SESSION_RUNNING": "Fokusøkt pågår",
"START": "Start",
"TO_FOCUS_OVERLAY": "Til fokusoverlegg"
},
"BACK_TO_PLANNING": "Tilbake til Planlegging",
@ -249,6 +259,7 @@
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Strukturerte sprint med planlagte pauser",
"POMODORO_SESSION_COMPLETED": "Pomodoro-økt fullført!",
"POMODORO_SETTINGS": "Pomodoro-innstillinger",
"PREP_GET_MENTALLY_READY": "Gjør deg mentalt klar til å være fokusert og produktiv",
"PREP_SIT_UPRIGHT": "Sitt (eller stå) oppreist",
"PREP_STRETCH": "Gjør noen milde uttøyninger",
@ -264,6 +275,7 @@
"SHORT_BREAK_TITLE": "Kort pause - syklus {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Vis/skjul oppgavenotater og vedlegg",
"SKIP_BREAK": "Hopp over pause",
"START_BREAK": "Start pause",
"START_FOCUS_SESSION": "Start fokusøkten",
"START_NEXT_FOCUS_SESSION": "Start neste fokusøkt",
"SWITCH_TASK": "Bytt oppgave",
@ -857,6 +869,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Avbryt",
"MSG": "Dette prosjektet er ganske stort og kan ta en stund å duplisere. Vil du fortsette?",
"OK": "Dupliser uansett",
"TITLE": "Dupliser prosjekt?"
},
"D_CREATE": {
"CREATE": "Lag prosjekt",
"EDIT": "Rediger prosjekt",
@ -1140,11 +1158,16 @@
"TITLE": "Synkroniser",
"WEB_DAV": {
"CORS_INFO": "<strong>Få det til å fungere i nettleseren:<\\/strong> For å få dette til å fungere i nettleseren må du hviteliste Super Productivity for CORS-forespørselene til din Nextcloud-instans. Dette kan ha negative sikkerhetsimplikasjoner! Vennligst <a href='https:\\/\\/github.com\\/nextcloud\\/server\\/issues\\/3131'>se denne tråden for mer informasjon<\\/a>. En tilnærming for å få dette til å fungere på mobil er å hviteliste \"https:\\/\\/app.super-productivity.com\" via Nextcloud-appen <a href='https:\\/\\/apps.nextcloud.com\\/apps\\/webapppassword'>webapppassword<a>. Bruk på egen risiko!<\\/p>",
"D_SYNC_FOLDER_PATH": "Sti relativ til WebDAV-serverens rot hvor synkroniseringsfiler vil bli lagret (f.eks. '/super-productivity' eller '/'). Dette er IKKE serverens interne katalogsti.",
"INFO": "WebDAV-implementeringer varierer dessverre mye. Super Productivity er kjent for å fungere godt med Nextcloud, <strong>men det kan hende det ikke fungerer med din leverandør</strong>.",
"L_BASE_URL": "Base Url",
"L_PASSWORD": "Passord",
"L_SYNC_FOLDER_PATH": "Synkroniseringsmappebane",
"L_USER_NAME": "Brukernavn"
"L_TEST_CONNECTION": "Test tilkobling",
"L_USER_NAME": "Brukernavn",
"S_FILL_ALL_FIELDS": "Vennligst fyll ut alle WebDAV-feltene først",
"S_TEST_FAIL": "Tilkoblingstest mislyktes: {{error}} - Mål-URL: {{url}}",
"S_TEST_SUCCESS": "Tilkoblingstest vellykket! Mål-URL: {{url}}"
}
},
"S": {
@ -1157,6 +1180,9 @@
"ERROR_FALLBACK_TO_BACKUP": "Noe gikk galt under importen av dataene. Faller tilbake til lokal sikkerhetskopi.",
"ERROR_INVALID_DATA": "Feil under synkronisering. Ugyldige data",
"ERROR_NO_REV": "Ingen gyldig rev for ekstern fil",
"ERROR_PERMISSION": "Filtilgang nektet. Vennligst sjekk filsystemtillatelsene dine.",
"ERROR_PERMISSION_FLATPAK": "Filtilgang nektet. Gi filsystemtillatelse via Flatseal eller bruk en sti innenfor ~/.var/app/",
"ERROR_PERMISSION_SNAP": "Filtilgang nektet. Kjør 'snap connect super-productivity:home' eller bruk en sti innenfor ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Feil under synkronisering. Kan ikke lese eksterne data. Kanskje du har aktivert kryptering, og det lokale passordet ditt ikke samsvarer med det som brukes til å kryptere de eksterne dataene?",
"IMPORTING": "Importerer data",
"INCOMPLETE_CFG": "Autentisering for synkronisering mislyktes. Vennligst sjekk konfigurasjonen din!",
@ -1540,6 +1566,7 @@
"FILTER_BY": "Filtrer etter",
"FILTER_DEFAULT": "Ingen filter",
"FILTER_ESTIMATED_TIME": "Estimert tid",
"FILTER_NOT_SPECIFIED": "Ikke spesifisert",
"FILTER_PROJECT": "Prosjekt",
"FILTER_SCHEDULED_DATE": "Planlagt dato",
"FILTER_TAG": "Tag",
@ -1567,7 +1594,6 @@
"TIME_2HOUR": "> 2 timer",
"TIME_10MIN": "> 10 minutter",
"TIME_30MIN": "> 30 minutter",
"FILTER_NOT_SPECIFIED": "Ikke spesifisert",
"TIME_SPENT": "Brukt tid",
"TITLE": "Tilpass oppgavevisning"
}
@ -1701,6 +1727,7 @@
"DISMISS": "Avvis",
"DO_IT": "Gjør det!",
"DONT_SHOW_AGAIN": "Ikke vis igjen",
"DUPLICATE": "Duplikat",
"DURATION_DESCRIPTION": "f.eks. \"5h 23m\", som resulterer i 5 timer og 23 minutter",
"EDIT": "Redigere",
"ENABLED": "Aktivert",
@ -1733,6 +1760,22 @@
"YESTERDAY": "I går"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Brett",
"DONATE_PAGE": "Donasjonsside",
"FOCUS_MODE": "Fokusmodus",
"HELP": "Aktiver eller deaktiver spesifikke app-funksjoner i hele brukergrensesnittet.",
"ISSUES_PANEL": "Problempanel",
"PLANNER": "Planlegger",
"PROJECT_NOTES": "Prosjektnotater",
"SCHEDULE": "Planlegge",
"SCHEDULE_DAY_PANEL": "Planlegg dag-panel",
"SYNC_BUTTON": "Synkroniseringsknapp",
"TIME_TRACKING": "Stoppeklokketidsregistrering",
"TITLE": "App-funksjoner",
"USER_PROFILES": "Brukerprofiler (Eksperimentell)",
"USER_PROFILES_HINT": "Lar deg opprette og bytte mellom forskjellige brukerprofiler, hver med separate innstillinger, oppgaver og synkroniseringskonfigurasjoner. Profilhåndteringsknappen vises øverst til høyre når aktivert. Merk: Deaktivering av denne funksjonen skjuler brukergrensesnittet, men bevarer profildataene dine (Eksperimentell funksjon, ingen garantier. Sørg for å ha en sikkerhetskopi)."
},
"AUTO_BACKUPS": {
"HELP": "Lagre alle data automatisk i appmappen din for å ha den klar i tilfelle noe går galt.",
"LABEL_IS_ENABLED": "Aktiver automatiske sikkerhetskopier",
@ -1755,7 +1798,12 @@
"FOCUS_MODE": {
"HELP": "Fokusmodus åpner en skjerm uten distraksjoner for å hjelpe deg med å fokusere på den aktuelle oppgaven.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Alltid åpen fokusmodus ved sporing",
"L_IS_PLAY_TICK": "Spill av tikkelyd under fokusøkter",
"L_MANUAL_BREAK_START": "Start pauser manuelt (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Pause oppgaveoppfølging under pauser",
"L_SKIP_PREPARATION_SCREEN": "Hopp over forberedelsesskjermen (strekking osv.)",
"L_START_IN_BACKGROUND": "Start fokusøkter med kun banner (ingen overlegg)",
"L_SYNC_SESSION_WITH_TRACKING": "Synkroniser fokusøkt med tidsregistrering",
"TITLE": "Fokusmodus"
},
"IDLE": {
@ -1844,6 +1892,7 @@
"NL": "Nederland",
"PL": "Polsk",
"PT": "Português",
"PT_BR": "Portugisisk (Brasil)",
"RU": "Russisk",
"SK": "Slovakisk",
"TIME_LOCALE": "Tidsformat lokalitet",
@ -1874,6 +1923,7 @@
"DARK_MODE_LIGHT": "Lys",
"DARK_MODE_SYSTEM": "System",
"DEFAULT_PROJECT": "Standard prosjekt som skal brukes til oppgaver hvis ingen er spesifisert",
"DEFAULT_START_PAGE": "Standard startside",
"FIRST_DAY_OF_WEEK": "Første ukedag",
"HELP": "<p><strong>Ser du ikke skrivebordsvarsler?</strong> For Windows vil du kanskje sjekke System&gt; Varsler og handlinger og sjekke om de nødvendige varslene er aktivert.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Legg til dagens tag automatisk for å jobbe med oppgaver",
@ -1883,8 +1933,6 @@
"IS_DARK_MODE": "Mørk modus",
"IS_DISABLE_ANIMATIONS": "Deaktiver alle animasjoner",
"IS_DISABLE_CELEBRATION": "Deaktiver feiring i daglig oppsummering",
"USER_PROFILES": "Aktiver brukerprofiler (Beta)",
"USER_PROFILES_HINT": "Lar deg opprette og bytte mellom forskjellige brukerprofiler, hver med separate innstillinger, oppgaver og synkroniseringskonfigurasjoner. Profilhåndteringsknappen vises øverst til høyre når aktivert. Merk: Deaktivering av denne funksjonen skjuler brukergrensesnittet, men bevarer profildataene dine (Beta-funksjon, ingen garantier. Sørg for å ha en sikkerhetskopi).",
"IS_HIDE_NAV": "Skjul navigasjonen til hovedoverskriften er svevet (kun på skrivebordet)",
"IS_MINIMIZE_TO_TRAY": "Minimere til skuffen (kun på skrivebordet)",
"IS_OVERLAY_INDICATOR_ENABLED": "Aktiver overleggindikatorvindu (desktop linux/gnome)",
@ -1892,6 +1940,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Vis nåværende nedtelling i systemstatusmenyen (kun desktop mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Vis gjeldende oppgave i skuffen / statusmenyen (kun desktop mac/windows)",
"IS_TURN_OFF_MARKDOWN": "Slå av Markdown-parsering for notater",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Bruk egendefinert tittellinje (kun Windows/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Krever omstart for å tre i kraft",
"IS_USE_MINIMAL_SIDE_NAV": "Bruk minimal navigasjonslinje (vis bare ikoner)",
"START_OF_NEXT_DAY": "Starttidspunkt neste dag",
"START_OF_NEXT_DAY_HINT": "fra når (i timer) du ønsker å telle neste dag har startet. standard er midnatt, som er 0.",
@ -1899,7 +1949,9 @@
"THEME": "Tema",
"THEME_EXPERIMENTAL": "Tema (eksperimentell)",
"THEME_SELECT_LABEL": "Velg tema",
"TITLE": "Diverse innstillinger"
"TITLE": "Diverse innstillinger",
"USER_PROFILES": "Aktiver brukerprofiler (Beta)",
"USER_PROFILES_HINT": "Lar deg opprette og bytte mellom forskjellige brukerprofiler, hver med separate innstillinger, oppgaver og synkroniseringskonfigurasjoner. Profilhåndteringsknappen vises øverst til høyre når aktivert. Merk: Deaktivering av denne funksjonen skjuler brukergrensesnittet, men bevarer profildataene dine (Beta-funksjon, ingen garantier. Sørg for å ha en sikkerhetskopi)."
},
"POMODORO": {
"BREAK_DURATION": "Varighet av korte pauser",
@ -1910,6 +1962,7 @@
"REMINDER": {
"COUNTDOWN_DURATION": "Vis banner X før den faktiske påminnelsen",
"DEFAULT_TASK_REMIND_OPTION": "Standard påminnelsesvalg valgt ved oppretting av oppgaver",
"DISABLE_REMINDERS": "Deaktiver alle påminnelser",
"IS_COUNTDOWN_BANNER_ENABLED": "Vis nedtellingsbanner før påminnelser forfaller",
"TITLE": "Påminnelser"
},
@ -1972,6 +2025,9 @@
"TITLE": "Tidsregistrering"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (kopi)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "om en dag",
@ -2002,6 +2058,8 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Kopiert til utklippstavlen",
"DUPLICATE_PROJECT_ERROR": "Prosjektet kunne ikke dupliseres",
"DUPLICATE_PROJECT_SUCCESS": "Prosjekt duplisert vellykket",
"ERR_COMPRESSION": "Feil for kompresjonsgrensesnitt",
"FILE_DOWNLOADED": "{{fileName}} lastet ned",
"FILE_DOWNLOADED_BTN": "Åpne mappe",
@ -2039,6 +2097,8 @@
"CREATE_TAG": "Opprett tag",
"DELETE_PROJECT": "Slett prosjekt",
"DELETE_TAG": "Slett tag",
"DONATE": "Støtt oss",
"DUPLICATE_PROJECT": "Duplikat",
"ENTER_FOCUS_MODE": "Gå inn i fokusmodus",
"GO_TO_TASK_LIST": "Gå til oppgavelisten",
"HELP": "Hjelp",
@ -2055,6 +2115,7 @@
"METRICS": "Beregninger",
"NO_PROJECT_INFO": "Ingen prosjekter tilgjengelig. Du kan opprette et nytt prosjekt ved å klikke på \"Opprett prosjekt\"-knappen.",
"NO_TAG_INFO": "Det finnes for øyeblikket ingen tagger. Du kan legge til tagger ved å skrive inn `#yourTagName` når du legger til eller redigerer oppgaver.",
"NO_TASKS_TO_TRACK": "Legg til en oppgave først for å begynne å spore tid",
"NOTES": "Merknader",
"NOTES_PANEL_INFO": "Notater kan bare vises fra tidsplan- og oppgavelistevisningene.",
"PLANNER": "Planlegger",
@ -2101,6 +2162,7 @@
"MSG": "Avslutte applikasjonen?",
"OK": "Avslutt"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Du kan bruke dette området til å skrive ned dine egne avslutningsritualer for dagen, som du vil bli minnet på.",
"ESTIMATE_TOTAL": "Totalt estimat:",
"EVALUATE_DAY": "Evaluer",
"EXPORT_TASK_LIST": "Eksporter oppgaveliste",

View file

@ -53,6 +53,11 @@
"SEARCH_PLACEHOLDER": "bijv. natuur, bergen, abstract",
"TITLE": "Selecteer achtergrondafbeelding van Unsplash"
},
"DONATE_PAGE": {
"BUTTON_TEXT": "Doneer via GitHub Sponsors",
"INTRO_1": "Super Productivity wordt volledig gefinancierd door de gemeenschap. Er is geen tracking, geen advertenties en geen gegevensverzameling — je taken blijven op je apparaat.",
"INTRO_2": "Als je deze aanpak waardeert en het project gezond en in ontwikkeling wilt houden, wordt een vrijwillige donatie zeer op prijs gesteld."
},
"F": {
"ATTACHMENT": {
"DIALOG_EDIT": {
@ -213,9 +218,14 @@
"ADD_TIME_MINUTE": "1 minuut toevoegen",
"B": {
"BREAK_RUNNING": "Pauze is bezig",
"END_BREAK": "Pauze beëindigen",
"END_SESSION": "Sessie beëindigen",
"PAUSE": "Pauzeren",
"POMODORO_BREAK_RUNNING": "Pauze #{{cycleNr}} is bezig",
"POMODORO_SESSION_RUNNING": "Pomodoro-sessie #{{cycleNr}} is bezig",
"RESUME": "CV",
"SESSION_RUNNING": "Focussessie is aan het draaien",
"START": "Start",
"TO_FOCUS_OVERLAY": "Naar Focus Overlay"
},
"BACK_TO_PLANNING": "Terug naar Planning",
@ -249,6 +259,7 @@
"POMODORO": "Pomodoro ",
"POMODORO_HINT": "Gestructureerde sprints met geplande pauzes",
"POMODORO_SESSION_COMPLETED": "Pomodoro-sessie voltooid!",
"POMODORO_SETTINGS": "Pomodoro-instellingen",
"PREP_GET_MENTALLY_READY": "Bereid je mentaal voor om gefocust en productief te zijn",
"PREP_SIT_UPRIGHT": "Zit (of sta) rechtop",
"PREP_STRETCH": "Doe wat lichte stretches",
@ -264,6 +275,7 @@
"SHORT_BREAK_TITLE": "Korte pauze - Cyclus {{cycle}}",
"SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Taaknotities en bijlagen tonen/verbergen",
"SKIP_BREAK": "Pauze overslaan",
"START_BREAK": "Pauze starten",
"START_FOCUS_SESSION": "Start Focus Sessie",
"START_NEXT_FOCUS_SESSION": "Start volgende Focus Sessie",
"SWITCH_TASK": "Taak wisselen",
@ -857,6 +869,12 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Annuleren",
"MSG": "Dit project is vrij groot en kan even duren om te dupliceren. Wil je doorgaan?",
"OK": "Toch dupliceren",
"TITLE": "Project dupliceren?"
},
"D_CREATE": {
"CREATE": "Project maken",
"EDIT": "Project bewerken",
@ -1140,11 +1158,16 @@
"TITLE": "Synchroniseren",
"WEB_DAV": {
"CORS_INFO": "<strong>Experimenteel !!</strong> Om dit te laten werken, moet u CORS uitschakelen of beperken voor uw Nextcloud-instantie, wat negatieve gevolgen voor de veiligheid kan hebben! Raadpleeg <a href='https://github.com/nextcloud/server/issues/3131'>deze thread</a> voor meer informatie. Gebruik op eigen risico!",
"D_SYNC_FOLDER_PATH": "Pad relatief aan de root van de WebDAV-server waar synchronisatiebestanden worden opgeslagen (bijv. '/super-productivity' of '/'). Dit is NIET het interne directorypad van je server.",
"INFO": "WebDAV-implementaties verschillen helaas sterk. Super Productivity werkt goed met Nextcloud, <strong>maar het werkt mogelijk niet met jouw provider</strong>.",
"L_BASE_URL": "Basis-URL",
"L_PASSWORD": "Wachtwoord",
"L_SYNC_FOLDER_PATH": "Map pad synchroniseren",
"L_USER_NAME": "Gebruikersnaam"
"L_TEST_CONNECTION": "Verbinding testen",
"L_USER_NAME": "Gebruikersnaam",
"S_FILL_ALL_FIELDS": "Vul eerst alle WebDAV-velden in",
"S_TEST_FAIL": "Verbindingstest mislukt: {{error}} - Doel-URL: {{url}}",
"S_TEST_SUCCESS": "Verbindingstest geslaagd! Doel-URL: {{url}}"
}
},
"S": {
@ -1152,10 +1175,14 @@
"ALREADY_IN_SYNC_NO_LOCAL_CHANGES": "Geen locale wijzigingen - Al gesynchroniseerd",
"BTN_CONFIGURE": "Configureren",
"BTN_FORCE_OVERWRITE": "Forceer overschrijven",
"ERROR_CORS": "WebDAV-synchronisatiefout: Netwerkverzoek mislukt.\n\nDit kan een CORS-probleem zijn. Zorg ervoor dat:\n Je WebDAV-server Cross-Origin-verzoeken toestaat\n De server-URL correct en toegankelijk is\n Je een werkende internetverbinding hebt",
"ERROR_DATA_IS_CURRENTLY_WRITTEN": "Remote Data wordt momenteel geschreven",
"ERROR_FALLBACK_TO_BACKUP": "Er is iets misgegaan tijdens het importeren van de gegevens. Terugvallen op lokale back-up.",
"ERROR_INVALID_DATA": "Fout tijdens het synchroniseren. Onjuiste data",
"ERROR_NO_REV": "Geen geldige rev voor bestand op afstand",
"ERROR_PERMISSION": "Bestandstoegang geweigerd. Controleer uw bestandsysteemrechten.",
"ERROR_PERMISSION_FLATPAK": "Bestandstoegang geweigerd. Verleen bestandsysteemrechten via Flatseal of gebruik een pad binnen ~\\/var\\/app\\/",
"ERROR_PERMISSION_SNAP": "Bestandstoegang geweigerd. Voer 'snap connect super-productivity:home' uit of gebruik een pad binnen ~\\/snap\\/super-productivity\\/common\\/",
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Fout tijdens synchroniseren. Kan gegevens op afstand niet lezen. Misschien heb je versleuteling ingeschakeld en komt je lokale wachtwoord niet overeen met het wachtwoord dat is gebruikt om de gegevens op afstand te versleutelen?",
"IMPORTING": "Gegevens importeren",
"INCOMPLETE_CFG": "Verificatie voor synchronisatie is mislukt. Controleer uw configuratie!",
@ -1167,11 +1194,38 @@
"UPLOAD_ERROR": "Onbekende uploadfout (instellingen correct?): {{err}}"
},
"SAFETY_BACKUP": {
"BACKUP_NOT_FOUND": "Back-up met ID {{backupId}} niet gevonden",
"BTN_CLEAR_ALL": "Alles wissen",
"BTN_CREATE_MANUAL": "Handmatige back-up maken",
"BTN_DELETE": "Verwijderen",
"BTN_REFRESH": "Vernieuwen",
"BTN_RESTORE": "Herstellen",
"LOADING": "Laden..."
"CLEAR_FAILED": "Kon back-ups niet wissen",
"CLEARED_SUCCESS": "Alle back-ups succesvol gewist",
"CREATE_FAILED": "Kon back-up niet maken",
"CREATED_SUCCESS": "Handmatige back-up succesvol gemaakt",
"DELETE_FAILED": "Kon back-up niet verwijderen",
"DELETED_SUCCESS": "Back-up succesvol verwijderd",
"DESCRIPTION": "Automatische back-ups worden gemaakt voordat gegevens van de remote worden gedownload tijdens synchronisatie. Back-ups worden georganiseerd in 4 slimme slots: 2 meest recente, 1 eerste van vandaag, en 1 eerste van een dag voor vandaag.",
"INVALID_ID_ERROR": "Ongeldige back-up ID gegenereerd",
"LAST_CHANGE_PREFIX": "Laatste wijziging:",
"LOADING": "Laden...",
"NO_BACKUPS": "Nog geen veiligheidsback-ups beschikbaar. Back-ups worden automatisch gemaakt vóór synchronisatie.",
"REASON_BEFORE_UPDATE": "Automatische back-up vóór synchronisatie",
"REASON_MANUAL": "Handmatige back-up",
"RESTORE_CONFIRM_MSG": "Weet je zeker dat je de back-up van {{timestamp}} wilt herstellen?\n\nDit zal al je huidige gegevens VOLLEDIG VERVANGEN!\n\nReden: {{reason}}\n\nKlik op OK om door te gaan of Annuleren om te stoppen.",
"RESTORE_CONFIRM_TITLE": "Back-up herstellen",
"RESTORE_FAILED": "Back-up herstellen mislukt: {{error}}",
"RESTORED_SUCCESS": "Back-up succesvol hersteld",
"SLOT_BEFORE_TODAY": "Eerste back-up van vóór vandaag",
"SLOT_RECENT": "Recente back-up",
"SLOT_TODAY": "Eerste back-up van vandaag",
"TITLE": "Veiligheidsback-ups synchroniseren",
"TOOLTIP_CLEAR_ALL": "Alle veiligheidsback-ups verwijderen",
"TOOLTIP_CREATE_MANUAL": "Maak een handmatige back-up van al je gegevens",
"TOOLTIP_DELETE": "Deze back-up verwijderen",
"TOOLTIP_REFRESH": "Back-uplijst vernieuwen",
"TOOLTIP_RESTORE": "Deze back-up herstellen (vervangt alle huidige gegevens)"
}
},
"TAG": {
@ -1201,6 +1255,7 @@
}
},
"TAG_FOLDER": {
"CONFIRM_DELETE": "Weet je zeker dat je de map \"{{title}}\" wilt verwijderen? Alle tags in deze map worden naar het hoofdniveau verplaatst.",
"DIALOG": {
"CREATE_TITLE": "Map aanmaken",
"EDIT_TITLE": "Map bewerken",
@ -1214,7 +1269,8 @@
"LABEL": "Map",
"NO_PARENT": "Geen map (hoofdniveau)",
"PLACEHOLDER": "Selecteer map"
}
},
"TOOLTIP_CREATE": "Tagmap aanmaken"
},
"TASK": {
"ADD_TASK_BAR": {
@ -1224,14 +1280,32 @@
"ADD_TASK_TO_BOTTOM_OF_TODAY": "Taak onderaan lijst toevoegen",
"ADD_TASK_TO_TOP_OF_BACKLOG": "Taak bovenaan de achterstand toevoegen",
"ADD_TASK_TO_TOP_OF_TODAY": "Taak bovenaan lijst toevoegen",
"CREATE_NEW_TAGS": "Nieuwe tags aanmaken",
"CREATE_TASK": "Maak een nieuwe taak",
"DUE_BUTTON": "Vervaldatum",
"ESTIMATE_BUTTON": "Schatting",
"EXAMPLE": "Voorbeeld: \"Een taaktitel + projectnaam # een tag # een andere tag 10 m / 3 uur\"",
"NO_DATE": "Geen datum",
"NO_TIME": "Geen tijd",
"PLACEHOLDER_CREATE": "Een taak titel #tag @16:00",
"PLACEHOLDER_SEARCH": "Bestaande taak of issues toevoegen...",
"SEARCH_INFO_TEXT": "Zoek en voeg issues en taken toe uit archief en andere projecten",
"START": "Druk nogmaals op Enter om te beginnen",
"TAGS_BUTTON": "Tags",
"TODAY": "Vandaag",
"TOGGLE_ADD_TO_BACKLOG_TODAY": "Toggle taak toevoegen aan backlog / lijst van vandaag'.",
"TOGGLE_ADD_TOP_OR_BOTTOM": "Schakelen tussen het toevoegen van taken aan de boven- en onderkant van de lijst",
"TOMORROW": "Morgen"
"TOMORROW": "Morgen",
"TOOLTIP_ADD_TASK": "Taak toevoegen",
"TOOLTIP_ADD_TO_BACKLOG": "Toevoegen aan backlog",
"TOOLTIP_ADD_TO_BOTTOM": "Toevoegen aan onderkant (Ctrl+1)",
"TOOLTIP_ADD_TO_TODAY": "Toevoegen aan vandaag",
"TOOLTIP_ADD_TO_TOP": "Toevoegen aan bovenkant (Ctrl+1)",
"TOOLTIP_CLEAR_DATE": "Datum wissen",
"TOOLTIP_CLEAR_ESTIMATE": "Schatting wissen",
"TOOLTIP_CLEAR_TAGS": "Tags wissen",
"TOOLTIP_DISABLE_SEARCH": "Zoeken naar issues uitschakelen (Ctrl+2)",
"TOOLTIP_ENABLE_SEARCH": "Zoeken naar issues inschakelen (Ctrl+2)"
},
"ADDITIONAL_INFO": {
"ADD_ATTACHMENT": "Voeg bijlage toe",
@ -1302,6 +1376,8 @@
"ADD_TO_TODAY": "Voeg toe aan vandaag",
"COMPLETE": "Voltooien",
"COMPLETE_ALL": "Alles voltooien",
"DISMISS_ALL_REMINDERS_KEEP_TODAY": "Alle herinneringen negeren (Blijf in Vandaag)",
"DISMISS_REMINDER_KEEP_TODAY": "Herinnering negeren (Blijf in Vandaag)",
"DONE": "Gedaan",
"DUE_TASK": "Gepaste taak",
"DUE_TASKS": "Geplande taken",
@ -1338,7 +1414,8 @@
},
"D_SELECT_DATE_AND_TIME": {
"DATE": "Datum",
"TIME": "Tijd"
"TIME": "Tijd",
"TITLE": "Selecteer datum en tijd"
},
"D_TIME": {
"ADD_FOR_OTHER_DAY": "Voeg tijd toe voor een andere dag",
@ -1367,6 +1444,7 @@
"FOUND_MOVE_FROM_BACKLOG": "Taak <strong>{{title}}</strong> verplaatst van backlog naar de takenlijst van vandaag",
"FOUND_MOVE_FROM_OTHER_LIST": "Taak <strong>{{title}}</strong> van <strong>{{contextTitle}}</strong> toegevoegd aan huidige lijst",
"FOUND_RESTORE_FROM_ARCHIVE": "Herstelde taak <strong>{{title}}</strong> gerelateerd aan probleem uit archief",
"GO_TO_TASK": "Ga naar taak",
"LAST_TAG_DELETION_WARNING": "U probeert de laatste tag van een niet-projecttaak te verwijderen. Dit mag niet!",
"MOVED_TO_ARCHIVE": "Verplaatste {{nr}} taken naar archief",
"MOVED_TO_PROJECT": "Taak \"{{taskTitle}}\" verplaatst naar project \"{{projectTitle}}\"",
@ -1421,6 +1499,10 @@
"MSG": "Er zijn {{tasksNr}} instanties aangemaakt voor deze herhaalbare taak. Wilt u ze allemaal bijwerken met de nieuwe standaardwaarden of alleen toekomstige taken?",
"OK": "Alle instanties bijwerken"
},
"D_DELETE_INSTANCE": {
"MSG": "De herhaalde taakinstantie op {{date}} verwijderen? Dit voorkomt dat de taak alleen op deze datum wordt aangemaakt.",
"OK": "Instantie verwijderen"
},
"D_EDIT": {
"ADD": "Voeg herhaalde taakconfiguratie toe",
"ADVANCED_CFG": "Uitgebreide configuratie",
@ -1437,7 +1519,11 @@
"C_WEEK": "Week",
"C_YEAR": "Jaar",
"DEFAULT_ESTIMATE": "Standaardschatting",
"DISABLE_AUTO_UPDATE_SUBTASKS": "Automatisch bijwerken van subtaken uitschakelen",
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Geërfde subtaken niet automatisch bijwerken wanneer de nieuwste instantie verandert",
"FRIDAY": "vrijdag",
"INHERIT_SUBTASKS": "Subtaken erven",
"INHERIT_SUBTASKS_DESCRIPTION": "Wanneer ingeschakeld, worden subtaken van de meest recente taakinstantie opnieuw aangemaakt met de herhalende taak",
"IS_ADD_TO_BOTTOM": "Verplaats taak naar de onderkant van de lijst",
"MONDAY": "maandag",
"NOTES": "Standaard notities",
@ -1452,9 +1538,16 @@
"QUICK_SETTING": "Herhaal configuratie",
"REMIND_AT": "Herinner bij",
"REMIND_AT_PLACEHOLDER": "Selecteer wanneer u eraan wilt herinneren",
"REMOVE_FOR_DATE": "Verwijderen voor {{date}}",
"REMOVE_INSTANCE": "Vandaag verwijderen",
"REPEAT_CYCLE": "Cyclus herhalen",
"REPEAT_EVERY": "Herhalen iedere",
"REPEAT_FROM_COMPLETION_DATE": "Herhalen, wanneer voltooid",
"REPEAT_FROM_COMPLETION_DATE_DESCRIPTION": "De volgende taak wordt aangemaakt vanaf je voltooiingsdatum, niet vanaf de startdatum. (bijv. 'Elke 7 dagen' = 7 dagen na voltooiing)",
"SATURDAY": "zaterdag",
"SCHEDULE_TYPE_FIXED": "Vast schema (bijv. elke maandag, 1e van de maand)",
"SCHEDULE_TYPE_FLEXIBLE": "Na voltooiing (bijv. 7 dagen nadat ik klaar ben)",
"SCHEDULE_TYPE_LABEL": "Type schema",
"START_DATE": "Start datum",
"START_TIME": "Geplande starttijd",
"START_TIME_DESCRIPTION": "bijv. 15:00 uur. Laat leeg voor een hele dagtaak",
@ -1473,6 +1566,7 @@
"FILTER_BY": "Filter op",
"FILTER_DEFAULT": "Geen filter",
"FILTER_ESTIMATED_TIME": "Geschatte tijd",
"FILTER_NOT_SPECIFIED": "Niet gespecificeerd",
"FILTER_PROJECT": "Project",
"FILTER_SCHEDULED_DATE": "Geplande datum",
"FILTER_TAG": "Tag",
@ -1493,12 +1587,13 @@
"SORT_CREATION_DATE": "Creatiedatum",
"SORT_DEFAULT": "Standaard",
"SORT_NAME": "Naam",
"SORT_PERMANENT": "Sorteren (permanent)",
"SORT_SCHEDULED_DATE": "Geplande datum",
"SORT_TEMPORARY": "Sorteren (tijdelijk)",
"TIME_1HOUR": "> 1 Uur",
"TIME_2HOUR": "> 2 Uren",
"TIME_10MIN": "> 10 Minuten",
"TIME_30MIN": "> 30 Minuten",
"FILTER_NOT_SPECIFIED": "Niet gespecificeerd",
"TIME_SPENT": "Tijd besteed",
"TITLE": "Taakweergave Aanpassen"
}
@ -1510,7 +1605,8 @@
},
"B_TTR": {
"ADD_TO_TASK": "Toevoegen aan taak",
"MSG": "U heeft de tijd niet bijgehouden voor {{time}}"
"MSG": "U heeft de tijd niet bijgehouden voor {{time}}",
"MSG_WITHOUT_TIME": "Je hebt geen tijd bijgehouden"
},
"D_IDLE": {
"ADD_ENTRY": "Item toevoegen voor volgen",
@ -1526,7 +1622,9 @@
"SKIP": "Overslaan",
"SPLIT_TIME": "Verdeel tijd in meerdere taken en pauzes",
"TASK": "Taak",
"TRACK_TO": "Track naar:"
"TRACK_TO": "Track naar:",
"WARN_SIMPLE_COUNTER": "Tijd wordt geteld voor de geactiveerde eenvoudige tellerknoppen.",
"WARN_SIMPLE_COUNTER_BREAK": "Tijd wordt NOG STEEDS geteld op de geactiveerde eenvoudige tellerknoppen."
},
"D_TRACKING_REMINDER": {
"CREATE_AND_TRACK": "<em>Maken</em> en volgen naar",
@ -1589,6 +1687,7 @@
},
"WEEK": {
"EXPORT": "Weekgegevens exporteren",
"FOCUS_SUMMARY": "Focussessies (aantal / tijd)",
"NO_DATA": "Nog geen taken deze week.",
"TITLE": "Titel"
}
@ -1627,6 +1726,8 @@
"DELETE": "Verwijderen",
"DISMISS": "Afwijzen",
"DO_IT": "Doe het!",
"DONT_SHOW_AGAIN": "Niet meer weergeven",
"DUPLICATE": "Duplicaat",
"DURATION_DESCRIPTION": "bijvoorbeeld \"5h 23m\" wat resulteert in 5 uur in 23 minuten",
"EDIT": "Bewerk",
"ENABLED": "Ingeschakeld",
@ -1659,6 +1760,22 @@
"YESTERDAY": "Gisteren"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Borden",
"DONATE_PAGE": "Doneerpagina",
"FOCUS_MODE": "Focusmodus",
"HELP": "Specifieke app-functies in- of uitschakelen in de hele gebruikersinterface.",
"ISSUES_PANEL": "Problemenpaneel",
"PLANNER": "Planner",
"PROJECT_NOTES": "Projectnotities",
"SCHEDULE": "Schema",
"SCHEDULE_DAY_PANEL": "Schema Dag Paneel",
"SYNC_BUTTON": "Synchronisatieknop",
"TIME_TRACKING": "Stopwatch Tijdregistratie",
"TITLE": "App-functies",
"USER_PROFILES": "Gebruikersprofielen (Experimenteel)",
"USER_PROFILES_HINT": "Staat u toe verschillende gebruikersprofielen te maken en te wisselen, elk met aparte instellingen, taken en synchronisatieconfiguraties. De knop voor profielbeheer verschijnt rechtsboven wanneer ingeschakeld. Opmerking: het uitschakelen van deze functie verbergt de gebruikersinterface maar behoudt uw profielgegevens (Experimentele functie, geen garanties. Zorg ervoor dat u een back-up hebt)."
},
"AUTO_BACKUPS": {
"HELP": "Sla alle gegevens automatisch op in uw app-map om deze gereed te hebben voor het geval er iets misgaat.",
"LABEL_IS_ENABLED": "Schakel automatische back-ups in",
@ -1681,7 +1798,12 @@
"FOCUS_MODE": {
"HELP": "De Focusmodus opent een scherm zonder afleiding om je te helpen focussen op je huidige taak.",
"L_ALWAYS_OPEN_FOCUS_MODE": "Altijd open scherpstelmodus, bij tracking",
"L_IS_PLAY_TICK": "Speel tikkend geluid af tijdens focus-sessies",
"L_MANUAL_BREAK_START": "Start pauzes handmatig (Pomodoro)",
"L_PAUSE_TRACKING_DURING_BREAK": "Pauzeer taakregistratie tijdens pauzes",
"L_SKIP_PREPARATION_SCREEN": "Voorbereidingsscherm overslaan (stretchen enz.)",
"L_START_IN_BACKGROUND": "Start focus-sessies alleen met banner (geen overlay)",
"L_SYNC_SESSION_WITH_TRACKING": "Synchroniseer focus-sessie met tijdregistratie",
"TITLE": "Scherpstelmodus"
},
"IDLE": {
@ -1697,6 +1819,7 @@
},
"KEYBOARD": {
"ADD_NEW_NOTE": "Nieuwe notitie toevoegen",
"ADD_NEW_PROJECT": "Nieuw project toevoegen",
"ADD_NEW_TASK": "Nieuwe taak toevoegen",
"APP_WIDE_SHORTCUTS": "Globale snelkoppelingen (toepassingsbreed)",
"COLLAPSE_SUB_TASKS": "Subtaken samenvouwen",
@ -1719,6 +1842,7 @@
"MOVE_TO_BACKLOG": "Verplaats taak naar backlog",
"MOVE_TO_REGULARS_TASKS": "Verplaats taak naar de takenlijst van vandaag",
"OPEN_PROJECT_NOTES": "Projectnotities tonen / verbergen",
"PLUGIN_SHORTCUTS": "Plugin Snelkoppelingen",
"SAVE_NOTE": "Notitie opslaan",
"SELECT_NEXT_TASK": "Selecteer de volgende taak",
"SELECT_PREVIOUS_TASK": "Selecteer vorige taak",
@ -1768,8 +1892,24 @@
"NL": "Nederlands",
"PL": "Pools",
"PT": "Portugees",
"PT_BR": "Portugees (Brazilië)",
"RU": "Russisch",
"SK": "Slowaaks",
"TIME_LOCALE": "Datum- en tijdnotatie locale",
"TIME_LOCALE_AUTO": "Systeemstandaard",
"TIME_LOCALE_DE_DE": "Duits: 24 uur, DD.MM.JJJJ",
"TIME_LOCALE_DESCRIPTION": "OPMERKING: deze opties kunnen nu het tijdinvoertype (12/24 uur) niet wijzigen omdat dit wordt geregeld door uw besturingssysteem",
"TIME_LOCALE_EN_GB": "Engels (VK): 24 uur, DD/MM/JJJJ",
"TIME_LOCALE_EN_US": "Engels (VS): 12 uur AM/PM, MM/DD/JJJJ",
"TIME_LOCALE_ES_ES": "Spaans: 24 uur, DD/MM/JJJJ",
"TIME_LOCALE_FR_FR": "Frans: 24 uur, DD/MM/JJJJ",
"TIME_LOCALE_IT_IT": "Italiaans: 24-uurs, DD/MM/JJJJ",
"TIME_LOCALE_JA_JP": "Japans: 24-uurs, JJJJ/MM/DD",
"TIME_LOCALE_KO_KR": "Koreaans: 12-uurs AM/PM, JJJJ. MM. DD",
"TIME_LOCALE_PT_BR": "Portugees (Brazilië): 24-uurs, DD/MM/JJJJ",
"TIME_LOCALE_RU_RU": "Russisch: 24-uurs, DD.MM.JJJJ",
"TIME_LOCALE_TR_TR": "Turks: 24-uurs, DD.MM.JJJJ",
"TIME_LOCALE_ZH_CN": "Chinees (Vereenvoudigd): 24-uurs, JJJJ/MM/DD",
"TITLE": "Taal",
"TR": "Türkçe",
"UK": "Українська",
@ -1778,10 +1918,12 @@
},
"MISC": {
"DARK_MODE": "Donkere modus",
"DARK_MODE_ARIA_LABEL": "Donkere modus selectie",
"DARK_MODE_DARK": "Donker",
"DARK_MODE_LIGHT": "Licht",
"DARK_MODE_SYSTEM": "SYSTEEM",
"DEFAULT_PROJECT": "Standaardproject om te gebruiken voor taken als er geen is opgegeven",
"DEFAULT_START_PAGE": "Standaard startpagina",
"FIRST_DAY_OF_WEEK": "Eerste dag van de week",
"HELP": "<p><strong>Ziet u geen bureaubladmeldingen?</strong> Voor vensters wilt u misschien Systeem> Meldingen en acties controleren en controleren of de vereiste meldingen zijn ingeschakeld.</p>",
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Voeg automatisch de tag voor vandaag toe aan taken",
@ -1791,20 +1933,25 @@
"IS_DARK_MODE": "Donkere modus",
"IS_DISABLE_ANIMATIONS": "Alle animaties uitschakelen",
"IS_DISABLE_CELEBRATION": "Vieren uitschakelen in dagoverzicht",
"USER_PROFILES": "Gebruikersprofielen inschakelen (Beta)",
"USER_PROFILES_HINT": "Maakt het mogelijk om verschillende gebruikersprofielen te maken en er tussen te schakelen, elk met afzonderlijke instellingen, taken en synchronisatieconfiguraties. De profielbeheerknop verschijnt in de rechterbovenhoek wanneer ingeschakeld. Opmerking: het uitschakelen van deze functie verbergt de gebruikersinterface, maar behoudt uw profielgegevens (bètafunctie, geen garanties. Zorg ervoor dat u een back-up hebt).",
"IS_HIDE_NAV": "Verberg navigatie totdat de hoofdkop is opgehangen (alleen desktop)",
"IS_MINIMIZE_TO_TRAY": "Minimaliseren naar lade (alleen desktop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Overlay indicatorvenster inschakelen (desktop linux/gnome)",
"IS_SHOW_TIP_LONGER": "Toon productiviteitstip op app-start iets langer",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Toon huidige countdown in de tray / statusmenu (alleen desktop mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Toon huidige taak in het systeemvak / statusmenu (alleen desktop)",
"IS_TURN_OFF_MARKDOWN": "Schakel markdown-parsering voor notities uit",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Gebruik aangepaste titelbalk (alleen Windows/Linux)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Herstart vereist om effect te hebben",
"IS_USE_MINIMAL_SIDE_NAV": "Gebruik een minimale navigatiebalk (toon alleen pictogrammen)",
"START_OF_NEXT_DAY": "Starttijd van de volgende dag",
"START_OF_NEXT_DAY_HINT": "vanaf wanneer (in uur) je wilt tellen dat de volgende dag is begonnen. standaard is middernacht wat 0 is.",
"TASK_NOTES_TPL": "Taak omschrijving template",
"THEME": "Thema",
"TITLE": "Diverse instellingen"
"THEME_EXPERIMENTAL": "Thema (experimenteel)",
"THEME_SELECT_LABEL": "Selecteer thema",
"TITLE": "Diverse instellingen",
"USER_PROFILES": "Gebruikersprofielen inschakelen (Beta)",
"USER_PROFILES_HINT": "Maakt het mogelijk om verschillende gebruikersprofielen te maken en er tussen te schakelen, elk met afzonderlijke instellingen, taken en synchronisatieconfiguraties. De profielbeheerknop verschijnt in de rechterbovenhoek wanneer ingeschakeld. Opmerking: het uitschakelen van deze functie verbergt de gebruikersinterface, maar behoudt uw profielgegevens (bètafunctie, geen garanties. Zorg ervoor dat u een back-up hebt)."
},
"POMODORO": {
"BREAK_DURATION": "Duur van korte pauzes",
@ -1814,6 +1961,8 @@
},
"REMINDER": {
"COUNTDOWN_DURATION": "Toon banner X vóór de eigenlijke herinnering",
"DEFAULT_TASK_REMIND_OPTION": "Standaard herinneringsoptie geselecteerd bij het aanmaken van taken",
"DISABLE_REMINDERS": "Alle herinneringen uitschakelen",
"IS_COUNTDOWN_BANNER_ENABLED": "Aftelbanner tonen voordat herinneringen klaar zijn",
"TITLE": "Herinneringen"
},
@ -1876,6 +2025,9 @@
"TITLE": "Tijdregistratie"
}
},
"GLOBAL": {
"COPY_SUFFIX": " (kopiëren)"
},
"GLOBAL_RELATIVE_TIME": {
"FUTURE": {
"A_DAY": "over een dag",
@ -1906,15 +2058,22 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Gekopieerd naar het klembord",
"DUPLICATE_PROJECT_ERROR": "Het project kon niet worden gedupliceerd",
"DUPLICATE_PROJECT_SUCCESS": "Project succesvol gedupliceerd",
"ERR_COMPRESSION": "Fout voor compressie-interface",
"FILE_DOWNLOADED": "{{fileName}} gedownload",
"FILE_DOWNLOADED_BTN": "Open folder",
"NAVIGATE_TO_TASK_ERR": "Kon zich niet concentreren op taak. Heb je het verwijderd?",
"NO_TASKS_TO_COPY": "Geen taken om te kopiëren",
"NO_TASKS_TO_UNPLAN": "Geen taken om te ontplannen",
"PERSISTENCE_DISALLOWED": "Gegevens worden niet permanent bewaard. Houd er rekening mee dat dit kan leiden tot gegevensverlies !!",
"PERSISTENCE_ERROR": "Fout bij verzoek om gegevens te bewaren: {{err}}",
"RUNNING_X": "\"{{Str}}\" wordt uitgevoerd.",
"SHARE_FAILED": "Delen mislukt. Kopieer handmatig.",
"SHARE_FAILED_FALLBACK": "Delen mislukt. In plaats daarvan gekopieerd naar klembord.",
"SHARE_UNAVAILABLE_FALLBACK": "Gekopieerd naar klembord.",
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} ingedrukt, maar de snelkoppeling naar bladwijzers openen is alleen beschikbaar in projectcontext."
"SHORTCUT_WARN_OPEN_BOOKMARKS_FROM_TAG": "{{keyCombo}} ingedrukt, maar de snelkoppeling naar bladwijzers openen is alleen beschikbaar in projectcontext.",
"UNPLANNED_TODAY_TASKS": "Alle taken van Vandaag ontpland"
},
"GPB": {
"ASSETS": "Activa laden ...",
@ -1938,6 +2097,8 @@
"CREATE_TAG": "Tag maken",
"DELETE_PROJECT": "Project verwijderen",
"DELETE_TAG": "Tag verwijderen",
"DONATE": "Steun ons",
"DUPLICATE_PROJECT": "Duplicaat",
"ENTER_FOCUS_MODE": "Voer Focusmodus in",
"GO_TO_TASK_LIST": "Ga naar takenlijst",
"HELP": "Help",
@ -1954,6 +2115,7 @@
"METRICS": "Metrische gegevens",
"NO_PROJECT_INFO": "Geen projecten beschikbaar. U kunt een nieuw project maken door op de knop \"Project maken\" te klikken.",
"NO_TAG_INFO": "Er zijn momenteel geen tags. U kunt tags toevoegen door `#uwTagNaam` in te voeren bij het toevoegen of bewerken van taken.",
"NO_TASKS_TO_TRACK": "Voeg eerst een taak toe om tijd bij te houden",
"NOTES": "Opmerkingen",
"NOTES_PANEL_INFO": "Notities kunnen alleen worden weergegeven vanuit de agenda en de gewone takenlijst.",
"PLANNER": "Planner",
@ -1965,7 +2127,9 @@
"SCHEDULE": "Schema",
"SEARCH": "Zoeken",
"SETTINGS": "Instellingen",
"SHARE_TASK_LIST_MARKDOWN": "De takenlijst delen",
"SHOW_SEARCH_BAR": "Toon zoekbalk",
"SIDE_PANEL_MENU": "Zijpaneelmenu",
"TAGS": "Tags",
"TASK_LIST": "Takenlijst",
"TASKS": "Taken",
@ -1974,6 +2138,7 @@
"TOGGLE_SHOW_NOTES": "Projectnotities tonen / verbergen",
"TOGGLE_TRACK_TIME": "Start / stop volgtijd",
"TRIGGER_SYNC": "Synchronisatie handmatig activeren",
"UNPLAN_ALL_TASKS": "Alle taken ontplannen",
"WORKLOG": "Werklogboek"
},
"MIGRATE": {
@ -1985,6 +2150,10 @@
},
"PDS": {
"ADD_TASKS_FROM_TODAY": "Taken toevoegen vanaf vandaag",
"ARCHIVED_TASKS": {
"PLURAL": "{{count}} voltooide hoofdtaken zijn gearchiveerd",
"SINGULAR": "{{count}} voltooide hoofdtaken is gearchiveerd"
},
"BREAK_LABEL": "Pauzes (nr / tijd)",
"CELEBRATE": "Neem even de tijd om <i>te vieren!</i>",
"CLEAR_ALL_CONTINUE": "Alles wissen en doorgaan",
@ -1993,9 +2162,11 @@
"MSG": "Applicatie afsluiten?",
"OK": "Afsluiten"
},
"END_OF_DAYS_RITUALS_PLACEHOLDER": "Je kunt deze ruimte gebruiken om je eigen einde-van-de-dag rituelen op te schrijven, waar je aan herinnerd wilt worden.",
"ESTIMATE_TOTAL": "Totale schatting:",
"EVALUATE_DAY": "Evalueer",
"EXPORT_TASK_LIST": "Takenlijst exporteren",
"FOCUS_SUMMARY": "Focussessies",
"NO_TASKS": "Er zijn geen taken voor deze dag",
"PLAN_TOMORROW": "Plan",
"REVIEW_TASKS": "Beoordeling",
@ -2019,16 +2190,104 @@
"WEEK": "Week"
},
"PLUGINS": {
"ACTION_TYPE_NOT_ALLOWED": "Actietype '{{type}}' is niet toegestaan",
"ALREADY_INITIALIZED": "Plugin is al geïnitialiseerd",
"CANCEL": "Annuleren",
"CAPABILITIES": {
"ACCESS_FILES": "Toegang tot en wijziging van bestanden op uw systeem",
"RUN_COMMANDS": "Voer systeemopdrachten uit",
"USE_NODE_APIS": "Gebruik Node.js API's en modules"
},
"CHOOSE_PLUGIN_FILE": "Kies Pluginbestand",
"CLEAR_PLUGIN_CACHE": "Wis Plugincache",
"CODE_TOO_LARGE": "Plugincode is te groot (maximaal {{maxSize}}MB)",
"CONFIGURATION": "Configuratie",
"CONFIGURE": "Configureren",
"CONFIRM_REMOVE": "Weet u zeker dat u de plugin \"{{name}}\" wilt verwijderen?",
"DISABLED": "Uitgeschakeld",
"ELECTRON_API_NOT_AVAILABLE": "Electron API is niet beschikbaar",
"ENABLED": "Ingeschakeld",
"ERROR": "Fout",
"ERROR_LOADING_PLUGIN": "Fout bij het laden van plugin",
"EXPERIMENTAL_WARNING": "Het pluginsysteem bevindt zich in een experimentele fase en moet met uiterste voorzichtigheid worden gebruikt.",
"EXPERIMENTAL_WARNING_TITLE": "Experimentele functie - Beveiligingswaarschuwing",
"FAILED_TO_CLEAR_CACHE": "Kon plugincache niet wissen",
"FAILED_TO_EXECUTE_SCRIPT": "Kon Node.js-script niet uitvoeren",
"FAILED_TO_EXTRACT_ZIP": "Kon plugin ZIP-bestand niet uitpakken",
"FAILED_TO_INSTALL": "Kon plugin niet installeren",
"FAILED_TO_LOAD": "Kon plugin niet laden",
"FAILED_TO_LOAD_CONFIG": "Kon pluginconfiguratie niet laden",
"FAILED_TO_REMOVE": "Kon plugin niet verwijderen",
"FAILED_TO_SAVE_CONFIG": "Kon pluginconfiguratie niet opslaan",
"FILE_TOO_LARGE": "Bestand te groot ({{fileSize}}MB). Maximale grootte is {{maxSize}}MB",
"GO_BACK": "Ga terug",
"GRANT_PERMISSION": "Toestemming verlenen",
"HOOKS": "Hooks",
"ID": "ID:",
"INDEX_HTML_NOT_LOADED": "Plugin index.html niet geladen",
"INSTALL_PLUGIN": "Plugin installeren",
"INSTALL_WARNING": "Zorg ervoor dat u de bron van een plugin vertrouwt en de gevraagde machtigingen begrijpt voordat u een plugin installeert.",
"INSTALLING": "Bezig met installeren...",
"LOADING_INTERFACE": "Plugininterface laden...",
"LOADING_PLUGIN": "Laden...",
"MANIFEST_NOT_FOUND": "Pluginmanifest (manifest.json) niet gevonden in ZIP-bestand",
"MANIFEST_TOO_LARGE": "Pluginmanifest is te groot (maximaal {{maxSize}}KB)",
"MENU_ENTRY_ICON_STRING": "Pictogram van menu-item moet een tekenreeks zijn",
"MENU_ENTRY_LABEL_REQUIRED": "Label van menu-item is verplicht",
"MENU_ENTRY_ONCLICK_REQUIRED": "OnClick-handler van menu-item is verplicht",
"MIN_VERSION": "Min. Versie:",
"NO_ADDITIONAL_INFO": "Geen aanvullende informatie beschikbaar",
"NO_CONTENT_PROVIDED": "Geen inhoud opgegeven",
"NO_PLUGIN_CONTEXT_ACTION": "Plugin heeft geen toestemming om acties uit te voeren",
"NO_PLUGIN_CONTEXT_HEADER_BUTTON": "Plugin heeft geen toestemming om kopknoppen toe te voegen",
"NO_PLUGIN_CONTEXT_LOADING": "Plugin heeft geen context voor het laden van gegevens",
"NO_PLUGIN_CONTEXT_MENU_ENTRY": "Plugin heeft geen toestemming om menu-items toe te voegen",
"NO_PLUGIN_CONTEXT_NODE": "Plugin heeft geen toestemming om Node.js-code uit te voeren",
"NO_PLUGIN_CONTEXT_PERSISTENCE": "Plugin heeft geen toestemming om gegevens op te slaan",
"NO_PLUGIN_CONTEXT_SHORTCUT": "Plugin heeft geen toestemming om sneltoetsen te registreren",
"NO_PLUGIN_CONTEXT_SIDE_PANEL": "Plugin heeft geen toestemming om knoppen in het zijpaneel toe te voegen",
"NO_PLUGIN_CONTEXT_SYNC": "Plugin heeft geen toestemming om synchronisatie te gebruiken",
"NO_PLUGIN_ID_PROVIDED_FOR_HTML": "Geen plugin-ID opgegeven voor HTML-inhoud",
"NO_PLUGIN_MANIFEST_NODE": "Pluginmanifest bevat geen Node.js-module",
"NODE_EXECUTION_REQUIRED": "Deze plugin vereist Node.js-uitvoering die alleen beschikbaar is in de desktop-app.",
"NODE_ONLY_DESKTOP": "Node.js-uitvoering is alleen beschikbaar in de desktop-app",
"OK": "Oké",
"PARENT_TASK_DOES_NOT_EXIST": "Oudertaak bestaat niet",
"PARENT_TASK_NOT_FOUND": "Oudertaak niet gevonden in context '{{contextId}}'",
"PERMISSIONS": "Machtigingen",
"REMOVE": "Verwijderen"
"PLEASE_SELECT_ZIP_FILE": "Selecteer een ZIP-bestand",
"PLUGIN_DIALOG_TITLE": "Pluginbericht",
"PLUGIN_DOES_NOT_SUPPORT_IFRAME": "Deze plugin ondersteunt geen iframe-interface",
"PLUGIN_ID_NOT_PROVIDED": "Plugin-ID niet opgegeven",
"PLUGIN_JS_NOT_FOUND": "Plugin JavaScript-bestand (plugin.js) niet gevonden in ZIP-bestand",
"PLUGIN_NOT_FOUND": "Plugin niet gevonden",
"PLUGIN_SYSTEM_FAILED_INIT": "Plugin-systeem kon niet worden geïnitialiseerd",
"PROJECT_DOES_NOT_EXIST": "Project bestaat niet",
"PROJECT_NOT_FOUND": "Project '{{contextId}}' niet gevonden",
"RECOMMENDATION": "Installeer alleen plugins van vertrouwde bronnen en controleer indien mogelijk hun code. Maak altijd een back-up van uw gegevens voordat u nieuwe plugins installeert.",
"REMEMBER_CHOICE": "Onthoud mijn keuze voor deze plugin",
"REMOVE": "Verwijderen",
"RISK_DATA_ACCESS": "Plugins kunnen AL uw taken, projecten en persoonlijke gegevens lezen, wijzigen en verwijderen",
"RISK_MALICIOUS_CODE": "Kwaadaardige plugins kunnen gevoelige informatie stelen of uw gegevens beschadigen",
"RISK_PERFORMANCE": "Slecht geschreven plugins kunnen prestatieproblemen of crashes veroorzaken",
"RISK_SYSTEM_ACCESS": "Desktop-plugins met Node.js-machtigingen kunnen systeemopdrachten uitvoeren",
"SECURITY_WARNING": "Plugins hebben aanzienlijke toegang tot uw gegevens en systeem. Het installeren van niet-vertrouwde plugins brengt ernstige beveiligingsrisico's met zich mee:",
"SIDE_PANEL_LABEL_REQUIRED": "Label voor zijpaneelknop is vereist",
"SIDE_PANEL_ONCLICK_REQUIRED": "OnClick-handler voor zijpaneelknop is vereist",
"SYSTEM_ACCESS_REQUEST_DESC": "Deze plugin vraagt toestemming om Node.js-code op uw systeem uit te voeren. Dit stelt het in staat om:",
"SYSTEM_ACCESS_REQUEST_TITLE": "Systeemtoegangsverzoek",
"TAGS_DO_NOT_EXIST": "Een of meer tags bestaan niet",
"TASK_NOT_FOUND": "Taak met ID '{{taskId}}' niet gevonden",
"TASKS_NOT_IN_PROJECT": "Een of meer taken zijn niet in project '{{contextId}}'",
"TASKS_NOT_SUBTASKS": "Een of meer taken zijn geen subtaken van '{{contextId}}'",
"TOGGLE_PLUGIN": "Schakel {{pluginName}} in/uit",
"TRUST_WARNING": "Verleen alleen toestemming als u deze plugin vertrouwt",
"TYPE": "Type: {{type}}",
"UNABLE_TO_PERSIST_DATA": "Kan gegevens niet opslaan in pluginopslag",
"UNKNOWN_ERROR": "Er is een onbekende fout opgetreden",
"UPLOAD_PLUGIN_INSTRUCTION": "Upload een plugin ZIP-bestand om het te installeren:",
"USER_DECLINED_NODE_PERMISSION": "Gebruiker heeft toestemming voor Node.js-uitvoering geweigerd",
"VALIDATION_FAILED": "Validatie mislukt"
},
"PM": {
"TITLE": "Projectstatistieken"
@ -2036,6 +2295,8 @@
"PS": {
"GLOBAL_SETTINGS": "Algemene instellingen",
"ISSUE_INTEGRATION": "Probleemintegratie",
"NO_PLUGINS_INSTALLED": "Er zijn momenteel geen plugins geïnstalleerd",
"PLUGINS": "Plugins",
"PRIVACY_POLICY": "Privébeleid",
"PRODUCTIVITY_HELPER": "Productiviteitshulp",
"PROJECT_SETTINGS": "Projectspecifieke instellingen",
@ -2046,6 +2307,7 @@
"TOGGLE_DARK_MODE": "Schakel de donkere modus in"
},
"SCHEDULE": {
"LAST": "Laatste:",
"NEXT": "Volgende",
"NO_REPEATABLE_TASKS": "Er zijn momenteel geen herhaalde taken. Je kunt een taak inplannen door \"Taak herhalen\" te kiezen in het zijpaneel van de taak. Om het te openen klik je op het meest rechtse pictogram dat verschijnt wanneer je met de muis over een taak gaat (of tik gewoon op de taak op mobiel).",
"NO_SCHEDULED": "Er zijn momenteel geen geplande taken. U kunt een taak plannen door \"Taak plannen\" te kiezen in het taakcontextmenu. Klik op de 3 kleine puntjes rechts van een taak om deze te openen.",
@ -2114,6 +2376,7 @@
"FINISH_DAY_FOR_TAG": "Einddatum voor deze tag",
"FINISH_DAY_TOOLTIP": "Evalueer je dag, verplaats alle uitgevoerde taken naar het archief (optioneel) en/of plan je volgende dag.",
"HELP_PROCRASTINATION": "Help, ik stel het uit!",
"LATER_TODAY": "Later vandaag",
"MOVE_DONE_TO_ARCHIVE": "Verplaatsing gedaan naar archief",
"NO_DONE_TASKS": "Er zijn momenteel geen voltooide taken",
"NO_PLANNED_TASK_ALL_DONE": "alle taken voltooid",

View file

@ -213,9 +213,12 @@
"ADD_TIME_MINUTE": "Dodaj 1 minutę",
"B": {
"BREAK_RUNNING": "Przerwa trwa",
"PAUSE": "Pauza",
"POMODORO_BREAK_RUNNING": "Przerwa #{{cycleNr}} trwa",
"POMODORO_SESSION_RUNNING": "Sesja Pomodoro #{{cycleNr}} trwa",
"RESUME": "CV",
"SESSION_RUNNING": "Sesja skupienia trwa",
"START": "Rozpocznij",
"TO_FOCUS_OVERLAY": "Do nakładki skupienia"
},
"BACK_TO_PLANNING": "Powrót do planowania",
@ -810,6 +813,9 @@
}
},
"PROJECT": {
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Anuluj"
},
"D_CREATE": {
"CREATE": "Stwórz projekt",
"EDIT": "Edycja projektu",
@ -1149,6 +1155,7 @@
"CREATE_TASK": "Create new task",
"DUE_BUTTON": "Zaległe ",
"EXAMPLE": "Example: \"Some task title +projectName #some-tag #some-other-tag 10m/3h\"",
"NO_DATE": "Brak daty",
"START": "Press enter one more time to start",
"TAGS_BUTTON": "Tagi",
"TODAY": "Dzisiaj",
@ -1396,6 +1403,7 @@
"FILTER_BY": "Filtruj według",
"FILTER_DEFAULT": "Brak filtra",
"FILTER_ESTIMATED_TIME": "Szacowany czas",
"FILTER_NOT_SPECIFIED": "Nie określono",
"FILTER_PROJECT": "Projekt",
"FILTER_SCHEDULED_DATE": "Data zaplanowana",
"FILTER_TAG": "Tag",
@ -1421,7 +1429,6 @@
"TIME_2HOUR": "> 2 godziny",
"TIME_10MIN": "> 10 minut",
"TIME_30MIN": "> 30 minut",
"FILTER_NOT_SPECIFIED": "Nie określono",
"TIME_SPENT": "Czas spędzony",
"TITLE": "Dostosuj widok zadań"
}
@ -1550,6 +1557,7 @@
"DELETE": "Usuń",
"DISMISS": "Dismiss",
"DO_IT": "Do it!",
"DUPLICATE": "Duplikat",
"DURATION_DESCRIPTION": "np. \"5h 23m\", co daje wynik 5 godzin w 23 minuty.",
"EDIT": "Edytuj",
"ENABLED": "Włączone",
@ -1582,6 +1590,10 @@
"YESTERDAY": "Yesterday"
},
"GCF": {
"APP_FEATURES": {
"BOARDS": "Tablice",
"SCHEDULE": "Harmonogram"
},
"AUTO_BACKUPS": {
"HELP": "Automatycznie zapisuj wszystkie dane w folderze aplikacji, aby mieć kopię na wypadek problemów.",
"LABEL_IS_ENABLED": "Włącz automatic backups",
@ -1714,8 +1726,6 @@
"IS_DARK_MODE": "Dark Mode",
"IS_DISABLE_ANIMATIONS": "Wyłącz wszystkie animacje",
"IS_DISABLE_CELEBRATION": "Wyłącz świętowanie w podsumowaniu dnia",
"USER_PROFILES": "Włącz profile użytkowników (Beta)",
"USER_PROFILES_HINT": "Umożliwia tworzenie i przełączanie się między różnymi profilami użytkowników, każdy z oddzielnymi ustawieniami, zadaniami i konfiguracjami synchronizacji. Przycisk zarządzania profilami pojawi się w prawym górnym rogu po włączeniu. Uwaga: wyłączenie tej funkcji ukryje interfejs użytkownika, ale zachowa dane profilu (funkcja Beta, bez gwarancji. Upewnij się, że masz kopię zapasową).",
"IS_HIDE_NAV": "Ukrywaj nawigację, dopóki nie najedziesz kursorem na nagłówek główny (tylko desktop)",
"IS_MINIMIZE_TO_TRAY": "Minimize to tray (desktop only)",
"IS_SHOW_TIP_LONGER": "Pokaż wskazówkę dotyczącą produktywności przy starcie aplikacji nieco dłużej",
@ -1726,7 +1736,9 @@
"START_OF_NEXT_DAY": "Godzina rozpoczęcia następnego dnia",
"START_OF_NEXT_DAY_HINT": "od kiedy (w godzinach) ma być liczony następny dzień. domyślnie jest to północ, czyli 0.",
"TASK_NOTES_TPL": "Task description template",
"TITLE": "Ustawienia różne"
"TITLE": "Ustawienia różne",
"USER_PROFILES": "Włącz profile użytkowników (Beta)",
"USER_PROFILES_HINT": "Umożliwia tworzenie i przełączanie się między różnymi profilami użytkowników, każdy z oddzielnymi ustawieniami, zadaniami i konfiguracjami synchronizacji. Przycisk zarządzania profilami pojawi się w prawym górnym rogu po włączeniu. Uwaga: wyłączenie tej funkcji ukryje interfejs użytkownika, ale zachowa dane profilu (funkcja Beta, bez gwarancji. Upewnij się, że masz kopię zapasową)."
},
"POMODORO": {
"BREAK_DURATION": "Duration of short breaks",
@ -1859,6 +1871,7 @@
"CREATE_TAG": "Stwórz Tag",
"DELETE_PROJECT": "Usuń projekt",
"DELETE_TAG": "Usuń Tag",
"DUPLICATE_PROJECT": "Duplikat",
"ENTER_FOCUS_MODE": "Włącz tryb skupienia",
"GO_TO_TASK_LIST": "Idź do listy zadań",
"HELP": "Pomoc",

Some files were not shown because too many files have changed in this diff Show more