Merge branch 'master' into feat/operation-logs

* master:
  refactor(dialog): remove unused OnDestroy implementation from DialogAddNoteComponent
  fix(calendar): poll all calendar tasks and prevent auto-move of existing tasks
  docs: add info about how to translate stuff #5893
  refactor(calendar): replace deprecated toPromise with firstValueFrom
  build: update links to match our new organization
  add QuestArc to community plugins list
  feat(calendar): implement polling for calendar task updates and enhance data retrieval logic
  fix(heatmap): use app theme class instead of prefers-color-scheme
  fix(focus-mode): start break from banner when manual break start enabled
  feat(i18n): connect Finnish and Swedish translation files
  refactor(focus-mode): split sessionComplete$ and breakComplete$ into single-responsibility effects
  Fixing Plugin API doc on persistence

# Conflicts:
#	src/app/features/issue/store/poll-issue-updates.effects.ts
#	src/app/t.const.ts
This commit is contained in:
Johannes Millan 2026-01-05 19:12:46 +01:00
commit a42c8a4cee
68 changed files with 12901 additions and 10684 deletions

View file

@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question
url: https://github.com/johannesjo/super-productivity/discussions/categories/q-a
url: https://github.com/super-productivity/super-productivity/discussions/categories/q-a
about: Please ask and answer questions here.
- name: Submit an idea
url: https://github.com/johannesjo/super-productivity/discussions/categories/ideas
url: https://github.com/super-productivity/super-productivity/discussions/categories/ideas
about: Got an non fleshed out improvement idea you want to discuss? Post it here!

View file

@ -176,7 +176,7 @@ jobs:
# - run: |
# echo Installed chromium version: ${{ steps.setup-chrome.outputs.chrome-version }}
# ${{ steps.setup-chrome.outputs.chrome-path }} --version
# Disabled because not working atm: https://github.com/johannesjo/super-productivity/actions/runs/5924016145/job/16060737982
# Disabled because not working atm: https://github.com/super-productivity/super-productivity/actions/runs/5924016145/job/16060737982
# - name: Test E2E
# run: npm run e2e

View file

@ -131,7 +131,7 @@ jobs:
# - run: |
# echo Installed chromium version: ${{ steps.setup-chrome.outputs.chrome-version }}
# ${{ steps.setup-chrome.outputs.chrome-path }} --version
# Disabled because not working atm: https://github.com/johannesjo/super-productivity/actions/runs/5924016145/job/16060737982
# Disabled because not working atm: https://github.com/super-productivity/super-productivity/actions/runs/5924016145/job/16060737982
# - name: Test E2E
# run: npm run e2e

View file

@ -32,7 +32,7 @@ jobs:
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: johannesjo/super-productivity
images: super-productivity/super-productivity
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View file

@ -22,7 +22,7 @@ jobs:
In case you want to work on this issue, please comment down below! We will try to get back to you as soon as we can. 👀
For more open ended discussions and/or specific questions, please visit the [discussions page](https://github.com/johannesjo/super-productivity/discussions). 💖
For more open ended discussions and/or specific questions, please visit the [discussions page](https://github.com/super-productivity/super-productivity/discussions). 💖
pr_message: |
Hello there ${{ github.actor }}! 👋

20392
CHANGELOG.md

File diff suppressed because it is too large Load diff

View file

@ -14,20 +14,20 @@ In case you want to contribute, but you wouldn't know how, here are some suggest
1. **Spread the word:** More users means more people testing and contributing to the app which in turn means better stability and possibly more and better features. You can vote for Super Productivity on [Slant](https://www.slant.co/topics/14021/viewpoints/7/~productivity-tools-for-linux~super-productivity), [Product Hunt](https://www.producthunt.com/posts/super-productivity), [Softpedia](https://www.softpedia.com/get/Office-tools/Diary-Organizers-Calendar/Super-Productivity.shtml) or on [AlternativeTo](https://alternativeto.net/software/super-productivity/), you can [tweet about it](https://twitter.com/intent/tweet?text=I%20like%20Super%20Productivity%20%20https%3A%2F%2Fsuper-productivity.com), share it on [LinkedIn](http://www.linkedin.com/shareArticle?mini=true&url=https://super-productivity.com&title=I%20like%20Super%20Productivity&), [reddit](http://www.reddit.com/submit?url=https%3A%2F%2Fsuper-productivity.com&title=I%20like%20Super%20Productivity) or any of your favorite social media platforms. Every little bit helps!
2. **Provide a Pull Request:** Here is a list of [the most popular community requests](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) and here some info on [how to run the development build](https://github.com/johannesjo/super-productivity#running-the-development-server).
2. **Provide a Pull Request:** Here is a list of [the most popular community requests](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) and here some info on [how to run the development build](https://github.com/super-productivity/super-productivity#running-the-development-server).
Please make sure that you're following the [angular commit guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) and to also include the issue number in your commit message, if you're fixing a particular issue (e.g.: `feat: add nice feature with the number #31`).
3. **[Answer questions](https://github.com/johannesjo/super-productivity/discussions)**: You know the answer to another user's problem? Share your knowledge!
3. **[Answer questions](https://github.com/super-productivity/super-productivity/discussions)**: You know the answer to another user's problem? Share your knowledge!
4. **[Provide your opinion](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22community+feedback+wanted%22):** Some community suggestions are controversial. Your input might be helpful even if it is just an up- or down-vote.
4. **[Provide your opinion](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22community+feedback+wanted%22):** Some community suggestions are controversial. Your input might be helpful even if it is just an up- or down-vote.
5. **[Provide a more refined UI spec for existing feature requests](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+concept+and%2For+ui+spec%22)**
5. **[Provide a more refined UI spec for existing feature requests](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+concept+and%2For+ui+spec%22)**
6. **[Report bugs](https://github.com/johannesjo/super-productivity/issues/new)**
6. **[Report bugs](https://github.com/super-productivity/super-productivity/issues/new)**
7. **[Make a feature or improvement request](https://github.com/johannesjo/super-productivity/issues/new)**: Something can be done better? Something essential missing? Let us know!
7. **[Make a feature or improvement request](https://github.com/super-productivity/super-productivity/issues/new)**: Something can be done better? Something essential missing? Let us know!
8. **[Translations](https://github.com/johannesjo/super-productivity/tree/master/src/assets/i18n), Icons, etc.**: You don't have to be programmer to help. Some of the icons really need improvement and many of the translations could use some love.
8. **[Translations](docs/TRANSLATING.md)**: You don't have to be programmer to help. See our [translation guide](docs/TRANSLATING.md) for details on how to contribute translations.
9. **[Sponsor the project](https://github.com/sponsors/johannesjo)**

View file

@ -9,7 +9,7 @@
<strong>An advanced todo list app with timeboxing & time tracking capabilities that supports importing tasks from your calendar, Jira, GitHub and others</strong>
<p>
<p align="center">:globe_with_meridians: <a href="https://app.super-productivity.com">Open Web App</a> or :computer: <a href="https://github.com/johannesjo/super-productivity/releases">Download</a></p>
<p align="center">:globe_with_meridians: <a href="https://app.super-productivity.com">Open Web App</a> or :computer: <a href="https://github.com/super-productivity/super-productivity/releases">Download</a></p>
<br>
@ -35,9 +35,9 @@
src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square"
align="center">
</a>
<a href="https://github.com/johannesjo/super-productivity/releases">
<a href="https://github.com/super-productivity/super-productivity/releases">
<img alt="GitHub All Releases"
src="https://img.shields.io/github/downloads/johannesjo/super-productivity/total"
src="https://img.shields.io/github/downloads/super-productivity/super-productivity/total"
align="center">
</a>
<a href="https://community.chocolatey.org/packages/super-productivity">
@ -112,7 +112,7 @@
style="height: 50px"
height="50" />
</a>
<a href='http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/johannesjo/super-productivity/releases'>
<a href='http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/super-productivity/super-productivity/releases'>
<img src='https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png'
align="center"
alt='Obtanium Badge'
@ -149,9 +149,9 @@ If you need some help, [this article on dev.to is the best place to start](https
If you prefer, there is also a (long) [YouTube video available](https://www.youtube.com/watch?v=VoF2_RSdNXA).
There is another older the app looks and feels much better now ;) [article](https://dev.to/johannesjo/super-productivity-how-to-grow-fond-of-time-tracking-and-task-management-22ee) on how I personally might use the app - and still [another one](https://dev.to/johannesjo/the-prioritising-scheme-how-to-eat-the-frog-with-super-productivity-mlk) on how I implement the 'eat the frog' prioritizing scheme in the app.
There is another older the app looks and feels much better now ;) [article](https://dev.to/super-productivity/super-productivity-how-to-grow-fond-of-time-tracking-and-task-management-22ee) on how I personally might use the app - and still [another one](https://dev.to/johannesjo/the-prioritising-scheme-how-to-eat-the-frog-with-super-productivity-mlk) on how I implement the 'eat the frog' prioritizing scheme in the app.
[If you have further questions, please refer to the discussions page](https://github.com/johannesjo/super-productivity/discussions).
[If you have further questions, please refer to the discussions page](https://github.com/super-productivity/super-productivity/discussions).
<details>
<summary> <b>⌨ Keyboard shortcuts and short-syntax</b></summary>
@ -197,7 +197,7 @@ If you want the Jira integration and idle time tracking to work, you also have t
### All Platforms
[Install from the releases page](https://github.com/johannesjo/super-productivity/releases).
[Install from the releases page](https://github.com/super-productivity/super-productivity/releases).
### Windows
@ -261,7 +261,7 @@ makepkg -si
#### AppImage
If you encounter problems, please have a look here:
https://github.com/johannesjo/super-productivity/issues/3193#issuecomment-2131315513
https://github.com/super-productivity/super-productivity/issues/3193#issuecomment-2131315513
### MacOS
@ -314,22 +314,22 @@ There are several ways to help.
1. **Spread the word:** More users mean more people testing and contributing to the app which in turn means better stability and possibly more and better features. You can vote for Super Productivity on [Slant](https://www.slant.co/topics/14021/viewpoints/7/~productivity-tools-for-linux~super-productivity), [Product Hunt](https://www.producthunt.com/posts/super-productivity), [Softpedia](https://www.softpedia.com/get/Office-tools/Diary-Organizers-Calendar/Super-Productivity.shtml) or on [AlternativeTo](https://alternativeto.net/software/super-productivity/), you can [tweet about it](https://twitter.com/intent/tweet?text=I%20like%20Super%20Productivity%20%20https%3A%2F%2Fsuper-productivity.com), share it on [LinkedIn](http://www.linkedin.com/shareArticle?mini=true&url=https://super-productivity.com&title=I%20like%20Super%20Productivity&), [reddit](http://www.reddit.com/submit?url=https%3A%2F%2Fsuper-productivity.com&title=I%20like%20Super%20Productivity) or any of your favorite social media platforms. Every little bit helps!
2. **Provide a Pull Request:** Here is a list of [the most popular community requests](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) and here some info on [how to run the development build](https://github.com/johannesjo/super-productivity#running-the-development-server).
2. **Provide a Pull Request:** Here is a list of [the most popular community requests](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) and here some info on [how to run the development build](https://github.com/super-productivity/super-productivity#running-the-development-server).
Please make sure that you're following the [angular commit guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) and to also include the issue number in your commit message, if you're fixing a particular issue (e.g.: `feat: add nice feature with the number #31`).
3. **[Answer questions](https://github.com/johannesjo/super-productivity/discussions)**: You know the answer to another user's problem? Share your knowledge!
3. **[Answer questions](https://github.com/super-productivity/super-productivity/discussions)**: You know the answer to another user's problem? Share your knowledge!
4. **[Provide your opinion](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22community+feedback+wanted%22):** Some community suggestions are controversial. Your input might be helpful and if it is just an up- or down-vote.
4. **[Provide your opinion](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22community+feedback+wanted%22):** Some community suggestions are controversial. Your input might be helpful and if it is just an up- or down-vote.
5. **[Provide a more refined UI spec for existing feature requests](https://github.com/johannesjo/super-productivity/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+concept+and%2For+ui+spec%22)**
5. **[Provide a more refined UI spec for existing feature requests](https://github.com/super-productivity/super-productivity/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+concept+and%2For+ui+spec%22)**
6. **[Report bugs](https://github.com/johannesjo/super-productivity/issues/new)**
6. **[Report bugs](https://github.com/super-productivity/super-productivity/issues/new)**
7. **[Make a feature or improvement request](https://github.com/johannesjo/super-productivity/issues/new)**: Something can be done better? Something essential missing? Let us know!
7. **[Make a feature or improvement request](https://github.com/super-productivity/super-productivity/issues/new)**: Something can be done better? Something essential missing? Let us know!
8. **[Translations](https://github.com/johannesjo/super-productivity/tree/master/src/assets/i18n), Icons, etc.**: You don't have to be a programmer to help. Some of the icons really need improvement and many of the translations could use some love.
8. **[Translations](https://github.com/super-productivity/super-productivity/tree/master/src/assets/i18n), Icons, etc.**: You don't have to be a programmer to help. Some of the icons really need improvement and many of the translations could use some love.
[//]: # '[![inlang status badge](https://badge.inlang.com/?url=github.com/johannesjo/super-productivity)](https://fink.inlang.com/github.com/johannesjo/super-productivity?ref=badge)'
[//]: # '[![inlang status badge](https://badge.inlang.com/?url=github.com/super-productivity/super-productivity)](https://fink.inlang.com/github.com/super-productivity/super-productivity?ref=badge)'
[//]: #
[//]: # 'You can use the Fink Localization Editor to edit, lint, and add translations for different languages. [Contribute via fink Guide](https://inlang.com/g/6ddyhpoi).'
@ -358,7 +358,7 @@ To run the development server you need to have Node installed (version 20 or hig
**Clone repo**
```bash
git clone https://github.com/johannesjo/super-productivity.git
git clone https://github.com/super-productivity/super-productivity.git
```
**Install dependencies**
@ -398,11 +398,11 @@ Further customizations to the Codespaces dev container can be performed by editi
### Packaging the app
Packaging the app is done via [electron-builder](https://github.com/electron-userland/electron-builder). To start packaging run `npm run dist`. If you want to add new platforms and experiment with the build options the easiest way to do so is manipulating the `build` property in the [package.json](https://github.com/johannesjo/super-productivity/blob/develop/package.json), but you can also use the [command line interface of electron builder](https://www.electron.build/cli).
Packaging the app is done via [electron-builder](https://github.com/electron-userland/electron-builder). To start packaging run `npm run dist`. If you want to add new platforms and experiment with the build options the easiest way to do so is manipulating the `build` property in the [package.json](https://github.com/super-productivity/super-productivity/blob/develop/package.json), but you can also use the [command line interface of electron builder](https://www.electron.build/cli).
### Building for Android
_This feature was added on October 7, 2024. See [Pull Request #57](https://github.com/johannesjo/super-productivity-android/pull/57)._
_This feature was added on October 7, 2024. See [Pull Request #57](https://github.com/super-productivity/super-productivity-android/pull/57)._
To build the Android version of Super Productivity, please refer to the [Android Build Documentation](./android/README.md), which includes instructions on configuring **Connectivity-Free Mode** and **Online-Only Mode (Compatibility Mode)**.
@ -411,7 +411,7 @@ Ensure you follow the setup steps properly to configure the environment for buil
## Run as Docker Container
```bash
docker run -d -p 80:80 johannesjo/super-productivity:latest
docker run -d -p 80:80 super-productivity/super-productivity:latest
```
> [!NOTE]
@ -433,7 +433,7 @@ Download the pre-configured `docker-compose.yaml` and `webdav.yaml` from this re
```bash
# Alternatively, you can get them by cloning this repository
git clone https://github.com/johannesjo/super-productivity.git
git clone https://github.com/super-productivity/super-productivity.git
mkdir -p sp
cp super-productivity/docker-compose.yaml sp/
cp super-productivity/webdav.yaml sp/
@ -461,7 +461,7 @@ You can provide the default values for WebDAV settings in the "Sync" section of
In addition to color coding your projects and tags and to the dark and light theme you can also load completely custom CSS to restyle everything. To load a custom theme you simply need to put them into a new file named `styles.css` directly in the [user data folder](#user-data-folder).
There is a great set of [themes available for download in this repository](https://github.com/johannesjo/super-productivity-themes/tree/main/dist) as well as some [info on how to create your own custom themes](https://github.com/johannesjo/super-productivity-themes).
There is a great set of [themes available for download in this repository](https://github.com/super-productivity/super-productivity-themes/tree/main/dist) as well as some [info on how to create your own custom themes](https://github.com/super-productivity/super-productivity-themes).
## Custom WebDAV Syncing

View file

@ -4,4 +4,4 @@ I am a web developer with a frontend focus and no security expert. I tried to fo
## Reporting a Vulnerability
Please report any vulnerabilities using the [form here on github](https://github.com/johannesjo/super-productivity/security/advisories/new).
Please report any vulnerabilities using the [form here on github](https://github.com/super-productivity/super-productivity/security/advisories/new).

View file

@ -6,7 +6,7 @@ I am not an Android developer, so help would be very welcome!!
## New Connectivity-Free Mode is Here!
_This feature was added on October 7, 2024. See [Pull Request #57](https://github.com/johannesjo/super-productivity-android/pull/57)._
_This feature was added on October 7, 2024. See [Pull Request #57](https://github.com/super-productivity/super-productivity-android/pull/57)._
You can now use the core features of the app without an internet connection, offering a smoother and more reliable experience. We've made several key updates to enhance usability:

View file

@ -17,7 +17,7 @@ For users performing a **new installation**, setting `LAUNCH_MODE` to `2` ensure
To set up the project, clone the `super-productivity` repository instead of directly cloning the `super-productivity-android` repository. This ensures that all submodules, including the Android project, are properly initialized.
```bash
git clone https://github.com/johannesjo/super-productivity.git
git clone https://github.com/super-productivity/super-productivity.git
cd super-productivity
git submodule init
git submodule update

View file

@ -108,7 +108,7 @@ dependencies {
// Force androidx.webkit to 1.11.0 for better API 28 compatibility
// This avoids NoClassDefFoundError for WebViewRenderProcessClient on Android 9
// while retaining audio controls and URL utils added in 1.11.0
// See: https://github.com/johannesjo/super-productivity/issues/5285
// See: https://github.com/super-productivity/super-productivity/issues/5285
configurations.all {
resolutionStrategy {
force "androidx.webkit:webkit:1.11.0"

View file

@ -8,7 +8,7 @@ import android.webkit.WebView.getCurrentWebViewPackage
/**
* Logs detailed WebView version and provider information for debugging.
* This helps diagnose WebView-related issues, especially on older Android versions.
* See: https://github.com/johannesjo/super-productivity/issues/5285
* See: https://github.com/super-productivity/super-productivity/issues/5285
*/
fun printWebViewVersion(webView: WebView) {
val tag = "SP-WebView"

View file

@ -60,7 +60,7 @@ services:
# Super Productivity app
app:
image: johannesjo/super-productivity:latest
image: super-productivity/super-productivity:latest
ports:
- '8080:80'
environment:

50
docs/TRANSLATING.md Normal file
View file

@ -0,0 +1,50 @@
# Translation Guide
Super Productivity uses JSON files for translations, located in `src/assets/i18n/`.
## How to Contribute
1. Find your language file in `src/assets/i18n/` (e.g., `de.json` for German)
2. Edit the JSON file directly
3. Submit a pull request
## Important Notes
### Fallback Language
**English (`en.json`) is the fallback language.** If a translation is missing or empty, the app automatically displays the English text.
### Empty Values Are Intentional
When you see empty strings (`""`), this is **intentional** - it triggers the English fallback. Do not copy the English text into empty fields unless you're providing an actual translation.
```json
{
"SOME_KEY": ""
}
```
The above will display the English text for `SOME_KEY`.
### File Format
- Nested JSON structure
- Keys use SCREAMING_SNAKE_CASE
- Keep the structure intact - only change the string values
### Example
```json
{
"G": {
"CANCEL": "Abbrechen",
"SAVE": "Speichern"
}
}
```
## Tips
- Use `en.json` as reference for context
- Keep translations concise (UI space is limited)
- Test your translations locally if possible (`ng serve`)

View file

@ -10,12 +10,11 @@ For polling GitLab Issues, you need to provide an access token.
![Personal Token](https://github.com/user-attachments/assets/76fb204e-450a-4516-9d93-897ae2a32f6d)
## Project Access Token
If you self-host GitLab or have the Premium/Ultimate license, it's possible to get a Project Access Token, which is scoped to a project.
The scope is similar to the Personal Access token, but you also set a role. To learn what each role can do, see the <a href="https://docs.gitlab.com/ee/user/permissions.html#project-planning">Documentation</a>.
If you self-host GitLab or have the Premium/Ultimate license, it's possible to get a Project Access Token, which is scoped to a project.
The scope is similar to the Personal Access token, but you also set a role. To learn what each role can do, see the <a href="https://docs.gitlab.com/ee/user/permissions.html#project-planning">Documentation</a>.
![Project Token](https://github.com/user-attachments/assets/f008f114-3d3e-450d-9301-7825222f9812)
For GitHub Personal Access Token instructions, please visit the following link:
[GitHub Access Token Instructions](https://github.com/johannesjo/super-productivity/blob/master/docs/github-access-token-instructions.md)
[GitHub Access Token Instructions](https://github.com/super-productivity/super-productivity/blob/master/docs/github-access-token-instructions.md)

View file

@ -125,7 +125,7 @@ cat /tmp/mas-profile.b64 | pbcopy
Then update the GitHub Actions secret:
1. Go to: https://github.com/johannesjo/super-productivity/settings/secrets/actions
1. Go to: https://github.com/super-productivity/super-productivity/settings/secrets/actions
2. Find `mas_provision_profile`
3. Click **Update**
4. Paste the base64-encoded content

View file

@ -274,50 +274,58 @@ Plugins that render custom UI in a sandboxed iframe.
- `updateTag(tagId, updates)` - Update tag
#### Simple Counters
Simple counters let you track lightweight metrics (e.g., daily clicks or habits) that persist and sync with your data. There are two levels: **basic** (key-value pairs for today's count) and **full model** (full CRUD on `SimpleCounter` entities with date-specific values).
##### Basic Counters
These treat counters as a simple `{ [id: string]: number }` map for today's values (auto-upserts via NgRx).
| Method | Description | Example |
|--------|-------------|---------|
| `getAllCounters()` | Get all counters as `{ [id: string]: number }` | `const counters = await PluginAPI.getAllCounters(); console.log(counters['my-key']);` |
| `getCounter(id)` | Get today's value for a counter (returns `null` if unset) | `const val = await PluginAPI.getCounter('daily-commits');` |
| `setCounter(id, value)` | Set today's value (non-negative number; validates id regex `/^[A-Za-z0-9_-]+$/`) | `await PluginAPI.setCounter('daily-commits', 5);` |
| `incrementCounter(id, incrementBy = 1)` | Increment and return new value (floors at 0) | `const newVal = await PluginAPI.incrementCounter('daily-commits', 2);` |
| `decrementCounter(id, decrementBy = 1)` | Decrement and return new value (floors at 0) | `const newVal = await PluginAPI.decrementCounter('daily-commits');` |
| `deleteCounter(id)` | Delete the counter | `await PluginAPI.deleteCounter('daily-commits');` |
| Method | Description | Example |
| --------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `getAllCounters()` | Get all counters as `{ [id: string]: number }` | `const counters = await PluginAPI.getAllCounters(); console.log(counters['my-key']);` |
| `getCounter(id)` | Get today's value for a counter (returns `null` if unset) | `const val = await PluginAPI.getCounter('daily-commits');` |
| `setCounter(id, value)` | Set today's value (non-negative number; validates id regex `/^[A-Za-z0-9_-]+$/`) | `await PluginAPI.setCounter('daily-commits', 5);` |
| `incrementCounter(id, incrementBy = 1)` | Increment and return new value (floors at 0) | `const newVal = await PluginAPI.incrementCounter('daily-commits', 2);` |
| `decrementCounter(id, decrementBy = 1)` | Decrement and return new value (floors at 0) | `const newVal = await PluginAPI.decrementCounter('daily-commits');` |
| `deleteCounter(id)` | Delete the counter | `await PluginAPI.deleteCounter('daily-commits');` |
**Example:**
```javascript
// Track daily commits
let commits = await PluginAPI.getCounter('daily-commits') ?? 0;
let commits = (await PluginAPI.getCounter('daily-commits')) ?? 0;
await PluginAPI.incrementCounter('daily-commits');
PluginAPI.showSnack({ msg: `Commits today: ${await PluginAPI.getCounter('daily-commits')}`, type: 'INFO' });
PluginAPI.showSnack({
msg: `Commits today: ${await PluginAPI.getCounter('daily-commits')}`,
type: 'INFO',
});
```
##### Full SimpleCounter Model
For advanced use: Full CRUD on counters with metadata (title, enabled state, date-specific values via `countOnDay: { [date: string]: number }`).
| Method | Description | Example |
|--------|-------------|---------|
| `getAllSimpleCounters()` | Get all as `SimpleCounter[]` | `const all = await PluginAPI.getAllSimpleCounters();` |
| `getSimpleCounter(id)` | Get one by id (returns `undefined` if not found) | `const counter = await PluginAPI.getSimpleCounter('my-id');` |
| `updateSimpleCounter(id, updates)` | Partial update (e.g., `{ title: 'New Title', countOnDay: { '2025-11-17': 10 } }`) | `await PluginAPI.updateSimpleCounter('my-id', { isEnabled: false });` |
| `toggleSimpleCounter(id)` | Toggle `isOn` state (throws if not found) | `await PluginAPI.toggleSimpleCounter('my-id');` |
| `setSimpleCounterEnabled(id, isEnabled)` | Set enabled state | `await PluginAPI.setSimpleCounterEnabled('my-id', true);` |
| `deleteSimpleCounter(id)` | Delete by id | `await PluginAPI.deleteSimpleCounter('my-id');` |
| `setSimpleCounterToday(id, value)` | Set today's value (YYYY-MM-DD) | `await PluginAPI.setSimpleCounterToday('my-id', 10);` |
| `setSimpleCounterDate(id, date, value)` | Set value for specific date (validates YYYY-MM-DD) | `await PluginAPI.setSimpleCounterDate('my-id', '2025-11-16', 5);` |
| Method | Description | Example |
| ---------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `getAllSimpleCounters()` | Get all as `SimpleCounter[]` | `const all = await PluginAPI.getAllSimpleCounters();` |
| `getSimpleCounter(id)` | Get one by id (returns `undefined` if not found) | `const counter = await PluginAPI.getSimpleCounter('my-id');` |
| `updateSimpleCounter(id, updates)` | Partial update (e.g., `{ title: 'New Title', countOnDay: { '2025-11-17': 10 } }`) | `await PluginAPI.updateSimpleCounter('my-id', { isEnabled: false });` |
| `toggleSimpleCounter(id)` | Toggle `isOn` state (throws if not found) | `await PluginAPI.toggleSimpleCounter('my-id');` |
| `setSimpleCounterEnabled(id, isEnabled)` | Set enabled state | `await PluginAPI.setSimpleCounterEnabled('my-id', true);` |
| `deleteSimpleCounter(id)` | Delete by id | `await PluginAPI.deleteSimpleCounter('my-id');` |
| `setSimpleCounterToday(id, value)` | Set today's value (YYYY-MM-DD) | `await PluginAPI.setSimpleCounterToday('my-id', 10);` |
| `setSimpleCounterDate(id, date, value)` | Set value for specific date (validates YYYY-MM-DD) | `await PluginAPI.setSimpleCounterDate('my-id', '2025-11-16', 5);` |
**Example:**
```javascript
// Create/update a habit counter
await PluginAPI.updateSimpleCounter('habit-streak', {
title: 'Daily Streak',
type: 'ClickCounter',
isEnabled: true,
countOnDay: { '2025-11-17': 1 } // Today's count
countOnDay: { '2025-11-17': 1 }, // Today's count
});
await PluginAPI.toggleSimpleCounter('habit-streak');
const counter = await PluginAPI.getSimpleCounter('habit-streak');
@ -445,11 +453,11 @@ You can persist data that will also be synced vai the `persistDataSynced` and `l
```javascript
// Save plugin data
await PluginAPI.persistDataSynced('myKey', { count: 42 });
await PluginAPI.persistDataSynced(JSON.stringify({ count: 42 }));
// Load saved data
const data = await PluginAPI.loadSyncedData('myKey');
console.log(data); // { count: 42 }
const data = await PluginAPI.loadSyncedData();
console.log(data); // '{ count: 42 }'
```
## Best Practices
@ -579,8 +587,8 @@ async function testAPI() {
- **Plugin Boilerplate**: [boilerplate-solid-js](../packages/plugin-dev/boilerplate-solid-js)
- **Example Plugins**: [plugin-dev](../packages/plugin-dev)
- **Community Plugins**:
- [counter-tester-plugin](https://github.com/Mustache-Games/counter-tester-plugin) by [Mustache Dev](https://github.com/Mustache-Games)
- [sp-reporter](https://github.com/dougcooper/sp-reporter) by [dougcooper](https://github.com/dougcooper)
- [counter-tester-plugin](https://github.com/Mustache-Games/counter-tester-plugin) by [Mustache Dev](https://github.com/Mustache-Games)
- [sp-reporter](https://github.com/dougcooper/sp-reporter) by [dougcooper](https://github.com/dougcooper)
## Contributing
@ -606,7 +614,7 @@ Happy plugin development! 🚀
```md
Can you you write me a plugin for Super Productivity that plays a beep sound every time i click on a header button (You need to add a header button via PluginAPI.registerHeaderButton).
Here are the docs: https://github.com/johannesjo/super-productivity/blob/master/docs/plugin-development.md
Here are the docs: https://github.com/super-productivity/super-productivity/blob/master/docs/plugin-development.md
Don't use any PluginAPI methods that are not listed in the guide.

View file

@ -1,6 +1,6 @@
/**
* E2E test for GitHub issue #5117
* https://github.com/johannesjo/super-productivity/issues/5117
* https://github.com/super-productivity/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.

View file

@ -69,7 +69,7 @@ export const startApp = (): void => {
app.commandLine.appendSwitch('enable-speech-dispatcher');
// work around for #4375
// https://github.com/johannesjo/super-productivity/issues/4375#issuecomment-2883838113
// https://github.com/super-productivity/super-productivity/issues/4375#issuecomment-2883838113
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');

View file

@ -37,7 +37,7 @@
* missing directive
* monkey patch mat-context menu to fix mat menu issue of triggering directly the element under the finger in submenus
* parameterized selectors for task due date selectors
* preserve default sync folder path when no overriding (3561d6e), closes /github.com/johannesjo/super-productivity/issues/4545#issuecomment-2974843258
* preserve default sync folder path when no overriding (3561d6e), closes /github.com/super-productivity/super-productivity/issues/4545#issuecomment-2974843258
* prevent model validation error
* re-enable reload
* remove 'any' type from mapSubTasksToTasks function and fix test selectors
@ -48,7 +48,7 @@
* restore and fix remaining e2e tests in task-list-basic directory
* restore missing logic in shared reducers
* restore task reordering logic for removeTasksFromTodayTag
* start nginx via its built-in entrypoint script (afc6264), closes /github.com/johannesjo/super-productivity/issues/4545#issuecomment-2974843258
* start nginx via its built-in entrypoint script (afc6264), closes /github.com/super-productivity/super-productivity/issues/4545#issuecomment-2974843258
* **sync:** data not being properly persisted during sync
* **sync:** ensure database unlock is called in finally block to prevent deadlocks
* **sync:** make android error handling more robust

View file

@ -12,7 +12,7 @@
"homepage": "https://super-productivity.com",
"repository": {
"type": "git",
"url": "git://github.com/johannesjo/super-productivity.git"
"url": "git://github.com/super-productivity/super-productivity.git"
},
"license": "MIT",
"author": "Johannes Millan <contact@super-productivity.com> (http://super-productivity.com)",

View file

@ -1,6 +1,6 @@
# @super-productivity/plugin-api
Official TypeScript definitions for developing [Super Productivity](https://github.com/johannesjo/super-productivity) plugins.
Official TypeScript definitions for developing [Super Productivity](https://github.com/super-productivity/super-productivity) plugins.
## Installation
@ -168,4 +168,4 @@ MIT - See the main Super Productivity repository for details.
## Contributing
Please contribute to the main [Super Productivity repository](https://github.com/johannesjo/super-productivity).
Please contribute to the main [Super Productivity repository](https://github.com/super-productivity/super-productivity).

View file

@ -24,13 +24,13 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/johannesjo/super-productivity.git",
"url": "git+https://github.com/super-productivity/super-productivity.git",
"directory": "packages/plugin-api"
},
"bugs": {
"url": "https://github.com/johannesjo/super-productivity/issues"
"url": "https://github.com/super-productivity/super-productivity/issues"
},
"homepage": "https://github.com/johannesjo/super-productivity#readme",
"homepage": "https://github.com/super-productivity/super-productivity#readme",
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",

View file

@ -383,5 +383,5 @@ PluginAPI.registerHook('taskUpdate', (data: unknown) => {
## Support
- GitHub Issues: [Super Productivity Issues](https://github.com/johannesjo/super-productivity/issues)
- GitHub Issues: [Super Productivity Issues](https://github.com/super-productivity/super-productivity/issues)
- Plugin API Docs: See `packages/plugin-api/README.md`

View file

@ -6,10 +6,10 @@
"minSupVersion": "13.0.0",
"description": "AI-powered productivity prompts to help you plan your day, week, and overcome challenges",
"author": "Super Productivity Community",
"homepage": "https://github.com/johannesjo/super-productivity",
"homepage": "https://github.com/super-productivity/super-productivity",
"repository": {
"type": "git",
"url": "https://github.com/johannesjo/super-productivity.git"
"url": "https://github.com/super-productivity/super-productivity.git"
},
"hooks": ["currentTaskChange"],
"permissions": ["showSnack", "openDialog", "addTask", "showIndexHtmlAsView"],

View file

@ -290,7 +290,7 @@ Modify `src/app/App.css` to customize the appearance.
## Resources
- [Super Productivity Plugin API Documentation](https://github.com/johannesjo/super-productivity)
- [Super Productivity Plugin API Documentation](https://github.com/super-productivity/super-productivity)
- [Solid.js Documentation](https://www.solidjs.com/docs/latest)
- [Vite Documentation](https://vitejs.dev/)

View file

@ -290,7 +290,7 @@ Modify `src/app/App.css` to customize the appearance.
## Resources
- [Super Productivity Plugin API Documentation](https://github.com/johannesjo/super-productivity)
- [Super Productivity Plugin API Documentation](https://github.com/super-productivity/super-productivity)
- [Solid.js Documentation](https://www.solidjs.com/docs/latest)
- [Vite Documentation](https://vitejs.dev/)

View file

@ -6,10 +6,10 @@
"minSupVersion": "13.0.0",
"description": "Helps identify procrastination blockers and provides tailored strategies to overcome them",
"author": "Super Productivity Community",
"homepage": "https://github.com/johannesjo/super-productivity",
"homepage": "https://github.com/super-productivity/super-productivity",
"repository": {
"type": "git",
"url": "https://github.com/johannesjo/super-productivity.git"
"url": "https://github.com/super-productivity/super-productivity.git"
},
"hooks": ["currentTaskChange"],
"permissions": ["showSnack", "openDialog", "addTask", "showIndexHtmlAsView"],

View file

@ -5,10 +5,10 @@
"version": "2.0.0",
"description": "Sync markdown files with SuperProductivity projects",
"author": "SuperProductivity",
"homepage": "https://github.com/johannesjo/super-productivity",
"homepage": "https://github.com/super-productivity/super-productivity",
"repository": {
"type": "git",
"url": "https://github.com/johannesjo/super-productivity.git"
"url": "https://github.com/super-productivity/super-productivity.git"
},
"minSupVersion": "10.0.0",
"permissions": [

View file

@ -238,7 +238,7 @@ export class MagicNavConfigService {
id: 'help-online',
label: T.MH.HM.GET_HELP_ONLINE,
icon: 'help_center',
href: 'https://github.com/johannesjo/super-productivity/blob/master/README.md#question-how-to-use-it',
href: 'https://github.com/super-productivity/super-productivity/blob/master/README.md#question-how-to-use-it',
},
{
type: 'action',
@ -252,7 +252,7 @@ export class MagicNavConfigService {
id: 'help-contribute',
label: T.MH.HM.CONTRIBUTE,
icon: 'volunteer_activism',
href: 'https://github.com/johannesjo/super-productivity/blob/master/CONTRIBUTING.md',
href: 'https://github.com/super-productivity/super-productivity/blob/master/CONTRIBUTING.md',
},
{
type: 'href',

View file

@ -79,7 +79,7 @@ export class BannerService {
// action: {
// label: 'Report',
// fn: () =>
// window.open('https://github.com/johannesjo/super-productivity/issues/new'),
// window.open('https://github.com/super-productivity/super-productivity/issues/new'),
// },
// action2: {
// label: 'Reload App',

View file

@ -20,6 +20,8 @@ import localeNb from '@angular/common/locales/nb';
import localeHr from '@angular/common/locales/hr';
import localeUk from '@angular/common/locales/uk';
import localeId from '@angular/common/locales/id';
import localeFi from '@angular/common/locales/fi';
import localeSv from '@angular/common/locales/sv';
/**
* All of available app languages
@ -32,6 +34,7 @@ export enum LanguageCode {
en = 'en',
es = 'es',
fa = 'fa',
fi = 'fi',
fr = 'fr',
hr = 'hr',
id = 'id',
@ -45,6 +48,7 @@ export enum LanguageCode {
pt_br = 'pt-br', // Portuguese (Brazil)
ru = 'ru',
sk = 'sk',
sv = 'sv',
tr = 'tr',
uk = 'uk',
zh = 'zh', // Chinese (Simplified)
@ -112,6 +116,7 @@ export const LocalesImports: Record<keyof typeof DateTimeLocales, unknown> = {
ar: localeAr,
cz: localeCs,
fa: localeFa,
fi: localeFi,
fr: localeFr,
id: localeId,
it: localeIt,
@ -122,6 +127,7 @@ export const LocalesImports: Record<keyof typeof DateTimeLocales, unknown> = {
hr: localeHr,
uk: localeUk,
sk: localeSk,
sv: localeSv,
tr: localeTr,
zh: localeZh,
};

View file

@ -38,7 +38,7 @@ export class NotifyService {
if (svcReg && svcReg.showNotification) {
// service worker also seems to need to request permission...
// @see: https://github.com/johannesjo/super-productivity/issues/408
// @see: https://github.com/super-productivity/super-productivity/issues/408
const per = await Notification.requestPermission();
// not supported for basic notifications so we delete them
if (per === 'granted') {

View file

@ -21,6 +21,7 @@ export const LANGUAGE_SELECTION_FORM_FORM: ConfigFormSection<LocalizationConfig>
{ label: T.GCF.LANG.ES, value: LanguageCode.es },
{ label: T.GCF.LANG.EN, value: LanguageCode.en },
{ label: T.GCF.LANG.FA, value: LanguageCode.fa },
{ label: T.GCF.LANG.FI, value: LanguageCode.fi },
{ label: T.GCF.LANG.FR, value: LanguageCode.fr },
{ label: T.GCF.LANG.HR, value: LanguageCode.hr },
{ label: T.GCF.LANG.ID, value: LanguageCode.id },
@ -34,6 +35,7 @@ export const LANGUAGE_SELECTION_FORM_FORM: ConfigFormSection<LocalizationConfig>
{ label: T.GCF.LANG.PT_BR, value: LanguageCode.pt_br },
{ label: T.GCF.LANG.RU, value: LanguageCode.ru },
{ label: T.GCF.LANG.SK, value: LanguageCode.sk },
{ label: T.GCF.LANG.SV, value: LanguageCode.sv },
{ label: T.GCF.LANG.TR, value: LanguageCode.tr },
{ label: T.GCF.LANG.UK, value: LanguageCode.uk },
{ label: T.GCF.LANG.ZH, value: LanguageCode.zh },

View file

@ -9,7 +9,7 @@
mat-button
color="primary"
target="_blank"
href="https://github.com/johannesjo/super-productivity/blob/master/docs/how-to-rate.md"
href="https://github.com/super-productivity/super-productivity/blob/master/docs/how-to-rate.md"
><mat-icon>open_in_new</mat-icon>
<strong>{{ T.F.D_RATE.A_HOW | translate }}</strong></a
>

View file

@ -1,6 +1,6 @@
/**
* Bug reproduction test for GitHub issue #5117
* https://github.com/johannesjo/super-productivity/issues/5117
* https://github.com/super-productivity/super-productivity/issues/5117
*
* Bug: Flowtime focus mode stops counting up at 25:00 minutes (or whatever
* the Countdown duration was set to).

View file

@ -1,6 +1,6 @@
/**
* Integration tests for GitHub issue #5813
* https://github.com/johannesjo/super-productivity/issues/5813
* https://github.com/super-productivity/super-productivity/issues/5813
*
* Bug: New Pomodoro Timer doesn't work if you change the time tracking interval
*

View file

@ -1,6 +1,6 @@
/**
* Integration tests for GitHub issue #5875
* https://github.com/johannesjo/super-productivity/issues/5875
* https://github.com/super-productivity/super-productivity/issues/5875
*
* Bug: You can break Pomodoro timer syncing
*
@ -207,88 +207,69 @@ describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
describe('Bug 2: Manual End Session should stop time tracking', () => {
it('should dispatch unsetCurrentTask when session is manually ended and sync is enabled', (done) => {
// Setup: Session is running, sync is enabled, task is being tracked
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: false,
isManualBreakStart: true, // No auto-break, so we can isolate the tracking stop behavior
});
currentTaskId$.next('task-123'); // Task is being tracked
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeDefined();
effects.stopTrackingOnManualEnd$.pipe(take(1)).subscribe((action) => {
expect(action.type).toEqual(unsetCurrentTask.type);
done();
});
});
it('should NOT dispatch unsetCurrentTask when session ends automatically (timer completion)', (done) => {
// Setup: Session completes automatically (not manual), manual break start enabled
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
// Setup: Session completes automatically (not manual)
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isPauseTrackingDuringBreak: false, // Don't pause during break
isManualBreakStart: true, // Manual break start, so no auto-break
});
currentTaskId$.next('task-123');
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: false }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
// For automatic completion with manual break start and no pause during break,
// tracking should continue (user explicitly chose not to pause during break)
expect(unsetAction).toBeUndefined();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
// For automatic completion, tracking should continue
expect(actionsArr.length).toBe(0);
done();
});
});
it('should NOT dispatch unsetCurrentTask when sync is disabled', (done) => {
// Setup: Sync is disabled
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: false, // Sync disabled
isSkipPreparation: false,
isManualBreakStart: true,
});
currentTaskId$.next('task-123');
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeUndefined();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
it('should NOT dispatch unsetCurrentTask when no task is being tracked', (done) => {
// Setup: No task is being tracked
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
isManualBreakStart: true,
});
currentTaskId$.next(null); // No task tracking
store.refreshState();
actions$ = of(actions.completeFocusSession({ isManual: true }));
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === unsetCurrentTask.type);
expect(unsetAction).toBeUndefined();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});

View file

@ -60,6 +60,7 @@ describe('FocusModeEffects', () => {
taskServiceMock = {
currentTaskId$: currentTaskId$.asObservable(),
currentTaskId: jasmine.createSpy('currentTaskId').and.returnValue(null),
};
globalConfigServiceMock = {
@ -335,173 +336,293 @@ describe('FocusModeEffects', () => {
});
});
describe('sessionComplete$', () => {
it('should dispatch incrementCycle for Pomodoro mode', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
describe('session completion effects (refactored)', () => {
describe('incrementCycleOnSessionComplete$', () => {
it('should dispatch incrementCycle for Pomodoro mode', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
effects.sessionComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(actions.incrementCycle());
done();
});
});
it('should NOT dispatch incrementCycle for Flowtime mode', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 0,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
const result: any[] = [];
effects.sessionComplete$.subscribe({
next: (action) => result.push(action),
complete: () => {
const hasIncrementCycle = result.some(
(a) => a.type === actions.incrementCycle.type,
);
expect(hasIncrementCycle).toBeFalse();
effects.incrementCycleOnSessionComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(actions.incrementCycle());
done();
},
});
});
it('should NOT dispatch incrementCycle for Flowtime mode', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
store.refreshState();
const result: any[] = [];
effects.incrementCycleOnSessionComplete$.subscribe({
next: (action) => result.push(action),
complete: () => {
expect(result.length).toBe(0);
done();
},
});
});
});
it('should dispatch startBreak for automatic (non-manual) completions when strategy allows', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
describe('autoStartBreakOnSessionComplete$', () => {
it('should dispatch startBreak for automatic completions when strategy allows', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
expect(startBreakAction.duration).toBe(5 * 60 * 1000);
expect(startBreakAction.isLongBreak).toBeFalse();
done();
effects.autoStartBreakOnSessionComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
expect(startBreakAction.duration).toBe(5 * 60 * 1000);
expect(startBreakAction.isLongBreak).toBeFalse();
done();
});
});
it('should dispatch startBreak for manual completions (to allow early break start)', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
effects.autoStartBreakOnSessionComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
done();
});
});
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.autoStartBreakOnSessionComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
it('should dispatch correct isLongBreak based on cycle', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 4);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: true,
shouldAutoStartNextSession: true,
getBreakDuration: jasmine
.createSpy('getBreakDuration')
.and.returnValue({ duration: 15 * 60 * 1000, isLong: true }),
});
effects.autoStartBreakOnSessionComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
expect(startBreakAction.isLongBreak).toBeTrue();
expect(startBreakAction.duration).toBe(15 * 60 * 1000);
done();
});
});
it('should NOT dispatch when strategy.shouldStartBreakAfterSession is false', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 0,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
effects.autoStartBreakOnSessionComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
});
it('should dispatch startBreak for manual completions (to allow early break start)', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
describe('stopTrackingOnManualEnd$', () => {
it('should dispatch unsetCurrentTask when isManual=true AND isSyncSessionWithTracking=true AND currentTaskId exists', (done) => {
currentTaskId$.next('task-123');
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
done();
effects.stopTrackingOnManualEnd$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(unsetCurrentTask());
done();
});
});
it('should NOT dispatch unsetCurrentTask when isManual=true but isSyncSessionWithTracking=false', (done) => {
currentTaskId$.next('task-123');
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: false,
isSkipPreparation: false,
});
store.refreshState();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
it('should NOT dispatch unsetCurrentTask when isManual=true but currentTaskId is null', (done) => {
currentTaskId$.next(null);
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.refreshState();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
it('should NOT dispatch unsetCurrentTask when isManual=false (auto completion)', (done) => {
currentTaskId$.next('task-123');
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.refreshState();
effects.stopTrackingOnManualEnd$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
});
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();
describe('edge cases', () => {
it('should handle missing focusModeConfig gracefully in incrementCycleOnSessionComplete$', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: true }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.overrideSelector(selectFocusModeConfig, null as any);
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);
store.overrideSelector(selectors.selectCurrentCycle, 4);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: true,
shouldAutoStartNextSession: true,
getBreakDuration: jasmine
.createSpy('getBreakDuration')
.and.returnValue({ duration: 15 * 60 * 1000, isLong: true }),
});
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const startBreakAction = actionsArr.find(
(a) => a.type === actions.startBreak.type,
);
expect(startBreakAction).toBeDefined();
expect(startBreakAction.isLongBreak).toBeTrue();
expect(startBreakAction.duration).toBe(15 * 60 * 1000);
done();
// Should still dispatch incrementCycle
effects.incrementCycleOnSessionComplete$.pipe(take(1)).subscribe({
next: (action) => {
expect(action).toEqual(actions.incrementCycle());
done();
},
error: (err) => {
fail('Should not throw error: ' + err);
},
});
});
});
});
describe('breakComplete$', () => {
it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => {
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
describe('break completion effects (refactored)', () => {
describe('autoStartSessionOnBreakComplete$', () => {
it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => {
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
effects.breakComplete$.subscribe((action) => {
expect(action).toEqual(actions.startFocusSession({ duration: 25 * 60 * 1000 }));
done();
});
});
it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => {
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
const result: any[] = [];
effects.breakComplete$.subscribe({
next: (action) => result.push(action),
complete: () => {
expect(result.length).toBe(0);
effects.autoStartSessionOnBreakComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(actions.startFocusSession({ duration: 25 * 60 * 1000 }));
done();
},
});
});
it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => {
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
effects.autoStartSessionOnBreakComplete$
.pipe(toArray())
.subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
});
it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => {
const pausedTaskId = 'test-paused-task-id';
actions$ = of(actions.completeBreak({ pausedTaskId }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown);
store.refreshState();
describe('resumeTrackingOnBreakComplete$', () => {
it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => {
const pausedTaskId = 'test-paused-task-id';
actions$ = of(actions.completeBreak({ pausedTaskId }));
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
effects.resumeTrackingOnBreakComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(setCurrentTask({ id: pausedTaskId }));
done();
});
});
effects.breakComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(setCurrentTask({ id: pausedTaskId }));
done();
it('should NOT dispatch setCurrentTask when pausedTaskId is null', (done) => {
actions$ = of(actions.completeBreak({ pausedTaskId: null }));
effects.resumeTrackingOnBreakComplete$.pipe(toArray()).subscribe((actionsArr) => {
expect(actionsArr.length).toBe(0);
done();
});
});
});
describe('combined behavior', () => {
it('should dispatch setCurrentTask from resumeTrackingOnBreakComplete$ when pausedTaskId exists', (done) => {
const pausedTaskId = 'test-paused-task-id';
actions$ = of(actions.completeBreak({ pausedTaskId }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
// Test resumeTrackingOnBreakComplete$ independently
effects.resumeTrackingOnBreakComplete$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(setCurrentTask({ id: pausedTaskId }));
done();
});
});
});
});
@ -1575,7 +1696,7 @@ describe('FocusModeEffects', () => {
});
});
describe('pauseTrackingDuringBreak', () => {
describe('pauseTrackingDuringBreak (autoStartBreakOnSessionComplete$)', () => {
it('should dispatch unsetCurrentTask when break starts and isPauseTrackingDuringBreak is true', (done) => {
actions$ = of(actions.completeFocusSession({ isManual: false }));
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
@ -1588,7 +1709,7 @@ describe('FocusModeEffects', () => {
currentTaskId$.next('task-123');
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
effects.autoStartBreakOnSessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === '[Task] UnsetCurrentTask');
expect(unsetAction).toBeDefined();
done();
@ -1607,11 +1728,458 @@ describe('FocusModeEffects', () => {
currentTaskId$.next('task-123');
store.refreshState();
effects.sessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
effects.autoStartBreakOnSessionComplete$.pipe(toArray()).subscribe((actionsArr) => {
const unsetAction = actionsArr.find((a) => a.type === '[Task] UnsetCurrentTask');
expect(unsetAction).toBeUndefined();
done();
});
});
});
describe('detectSessionCompletion$', () => {
it('should dispatch completeFocusSession when timer completes (elapsed >= duration)', (done) => {
// Setup timer that just completed
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000, // Exactly at duration
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
// Need to recreate effects after selector override
effects = TestBed.inject(FocusModeEffects);
effects.detectSessionCompletion$.pipe(take(1)).subscribe((action) => {
expect(action).toEqual(actions.completeFocusSession({ isManual: false }));
done();
});
});
it('should NOT dispatch for Flowtime mode (timer runs indefinitely)', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
// Wait a bit to ensure no action is dispatched
setTimeout(() => {
done(); // If no emission occurred, test passes
}, 50);
});
it('should NOT dispatch when timer is still running', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: true, // Still running
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
setTimeout(() => {
done();
}, 50);
});
it('should NOT dispatch when elapsed < duration', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 20 * 60 * 1000, // Not complete yet
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
setTimeout(() => {
done();
}, 50);
});
it('should NOT dispatch when purpose is break', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'break', // Not a work session
duration: 5 * 60 * 1000,
elapsed: 5 * 60 * 1000,
}),
);
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
setTimeout(() => {
done();
}, 50);
});
});
describe('detectBreakTimeUp$', () => {
it('should call notification when break timer completes', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: false,
purpose: 'break',
duration: 5 * 60 * 1000,
elapsed: 5 * 60 * 1000,
}),
);
store.refreshState();
// Create new effects instance and spy on _notifyUser
effects = TestBed.inject(FocusModeEffects);
const notifyUserSpy = spyOn(effects as any, '_notifyUser');
effects.detectBreakTimeUp$.pipe(take(1)).subscribe(() => {
expect(notifyUserSpy).toHaveBeenCalled();
done();
});
});
it('should NOT trigger while break timer is running (elapsed < duration)', (done) => {
store.overrideSelector(
selectors.selectTimer,
createMockTimer({
isRunning: true,
purpose: 'break',
duration: 5 * 60 * 1000,
elapsed: 3 * 60 * 1000, // Not complete
}),
);
store.refreshState();
effects = TestBed.inject(FocusModeEffects);
const notifyUserSpy = spyOn(effects as any, '_notifyUser');
setTimeout(() => {
expect(notifyUserSpy).not.toHaveBeenCalled();
done();
}, 50);
});
});
describe('_getIconButtonActions banner button behavior (issue #5889)', () => {
let dispatchSpy: jasmine.Spy;
beforeEach(() => {
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
});
it('should dispatch startBreak when session completed with isManualBreakStart=true in Pomodoro mode', (done) => {
// Setup Pomodoro mode with manual break start
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
const timer = createMockTimer({
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
});
const focusModeConfig = {
isManualBreakStart: true,
isPauseTrackingDuringBreak: false,
};
// Access private method via bracket notation
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false, // isOnBreak
true, // isSessionCompleted
false, // isBreakTimeUp
focusModeConfig,
);
// Verify play button exists
expect(buttonActions.action).toBeDefined();
expect(buttonActions.action.icon).toBe('play_arrow');
// Click the button
buttonActions.action.fn();
// Wait for async store select to complete
setTimeout(() => {
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startBreakCall).toBeDefined();
expect(startBreakCall?.args[0].duration).toBe(5 * 60 * 1000);
expect(startBreakCall?.args[0].isLongBreak).toBeFalse();
done();
}, 50);
});
it('should dispatch startFocusSession when session completed with isManualBreakStart=false', (done) => {
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
const timer = createMockTimer({
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
});
const focusModeConfig = {
isManualBreakStart: false, // Disabled
};
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false,
true,
false,
focusModeConfig,
);
buttonActions.action.fn();
setTimeout(() => {
const startSessionCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startFocusSession.type);
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startSessionCall).toBeDefined();
expect(startBreakCall).toBeUndefined();
done();
}, 50);
});
it('should dispatch startFocusSession for Flowtime mode even with isManualBreakStart=true', (done) => {
// Flowtime doesn't support breaks
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 0,
shouldStartBreakAfterSession: false,
shouldAutoStartNextSession: false,
getBreakDuration: () => null,
});
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
const timer = createMockTimer({
purpose: 'work',
duration: 0,
elapsed: 30 * 60 * 1000,
});
const focusModeConfig = {
isManualBreakStart: true, // Even if set, should not start break
};
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false,
true,
false,
focusModeConfig,
);
buttonActions.action.fn();
setTimeout(() => {
const startSessionCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startFocusSession.type);
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startSessionCall).toBeDefined();
expect(startBreakCall).toBeUndefined();
done();
}, 50);
});
it('should dispatch unsetCurrentTask before startBreak when isPauseTrackingDuringBreak=true', (done) => {
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
// Mock that there's a current task
taskServiceMock.currentTaskId = jasmine
.createSpy('currentTaskId')
.and.returnValue('task-123');
const timer = createMockTimer({
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
});
const focusModeConfig = {
isManualBreakStart: true,
isPauseTrackingDuringBreak: true, // Should pause tracking
};
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false,
true,
false,
focusModeConfig,
);
buttonActions.action.fn();
setTimeout(() => {
const unsetTaskCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === '[Task] UnsetCurrentTask');
expect(unsetTaskCall).toBeDefined();
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startBreakCall).toBeDefined();
expect(startBreakCall?.args[0].pausedTaskId).toBe('task-123');
done();
}, 50);
});
it('should dispatch startBreak with long break when cycle triggers long break', (done) => {
// Mock strategy to return long break
strategyFactoryMock.getStrategy.and.returnValue({
initialSessionDuration: 25 * 60 * 1000,
shouldStartBreakAfterSession: true,
shouldAutoStartNextSession: true,
getBreakDuration: jasmine.createSpy('getBreakDuration').and.returnValue({
duration: 15 * 60 * 1000,
isLong: true,
}),
});
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 4);
store.refreshState();
const timer = createMockTimer({
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
});
const focusModeConfig = {
isManualBreakStart: true,
isPauseTrackingDuringBreak: false,
};
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false,
true,
false,
focusModeConfig,
);
buttonActions.action.fn();
setTimeout(() => {
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startBreakCall).toBeDefined();
expect(startBreakCall?.args[0].duration).toBe(15 * 60 * 1000);
expect(startBreakCall?.args[0].isLongBreak).toBeTrue();
done();
}, 50);
});
it('should NOT dispatch startBreak when focusModeConfig is undefined', (done) => {
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectCurrentCycle, 1);
store.refreshState();
const timer = createMockTimer({
purpose: 'work',
duration: 25 * 60 * 1000,
elapsed: 25 * 60 * 1000,
});
const buttonActions = (effects as any)._getIconButtonActions(
timer,
false,
true,
false,
undefined, // No config
);
buttonActions.action.fn();
setTimeout(() => {
// Should dispatch startFocusSession since no config means no manual break
const startSessionCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startFocusSession.type);
const startBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.startBreak.type);
expect(startSessionCall).toBeDefined();
expect(startBreakCall).toBeUndefined();
done();
}, 50);
});
it('should handle isBreakTimeUp case correctly (existing behavior)', (done) => {
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
store.overrideSelector(selectors.selectPausedTaskId, null);
store.refreshState();
const timer = createMockTimer({
purpose: 'break',
duration: 5 * 60 * 1000,
elapsed: 5 * 60 * 1000,
isRunning: false,
});
const focusModeConfig = {
isManualBreakStart: true,
};
const buttonActions = (effects as any)._getIconButtonActions(
timer,
true, // isOnBreak
false, // isSessionCompleted
true, // isBreakTimeUp
focusModeConfig,
);
buttonActions.action.fn();
setTimeout(() => {
// Should dispatch skipBreak (existing behavior for break time up)
const skipBreakCall = dispatchSpy.calls
.all()
.find((call) => call.args[0]?.type === actions.skipBreak.type);
expect(skipBreakCall).toBeDefined();
done();
}, 50);
});
});
});

View file

@ -31,6 +31,7 @@ import {
selectIsFocusModeEnabled,
selectPomodoroConfig,
} from '../../config/store/global-config.reducer';
import { FocusModeConfig } from '../../config/global-config.model';
import { updateGlobalConfigSection } from '../../config/store/global-config.actions';
import { FocusModeMode, FocusScreen, TimerState } from '../focus-mode.model';
import { BannerService } from '../../../core/banner/banner.service';
@ -265,8 +266,36 @@ export class FocusModeEffects {
{ dispatch: false },
);
// Handle session completion
sessionComplete$ = createEffect(() =>
// Session completion effects - split into separate concerns for better maintainability
// Effect 1: Increment cycle for Pomodoro mode
incrementCycleOnSessionComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeFocusSession),
withLatestFrom(this.store.select(selectors.selectMode)),
filter(([_, mode]) => mode === FocusModeMode.Pomodoro),
map(() => actions.incrementCycle()),
),
);
// Effect 2: Stop tracking on manual session end (bug #5875 fix)
stopTrackingOnManualEnd$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeFocusSession),
withLatestFrom(
this.store.select(selectFocusModeConfig),
this.taskService.currentTaskId$,
),
filter(
([action, config, taskId]) =>
!!action.isManual && !!config?.isSyncSessionWithTracking && !!taskId,
),
map(() => unsetCurrentTask()),
),
);
// Effect 3: Auto-start break after session completion
autoStartBreakOnSessionComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeFocusSession),
withLatestFrom(
@ -275,93 +304,93 @@ export class FocusModeEffects {
this.store.select(selectFocusModeConfig),
this.taskService.currentTaskId$,
),
switchMap(([action, mode, cycle, focusModeConfig, currentTaskId]) => {
filter(([_, mode, __, config]) => {
const strategy = this.strategyFactory.getStrategy(mode);
const actionsToDispatch: any[] = [];
return strategy.shouldStartBreakAfterSession && !config?.isManualBreakStart;
}),
switchMap(([_, mode, cycle, config, currentTaskId]) => {
const strategy = this.strategyFactory.getStrategy(mode);
const breakInfo = strategy.getBreakDuration(cycle);
const shouldPauseTracking = config?.isPauseTrackingDuringBreak && currentTaskId;
const actionsArr: any[] = [];
// Show notification (sound + window focus)
this._notifyUser();
// For Pomodoro mode, always increment cycle after session completion
if (mode === FocusModeMode.Pomodoro) {
actionsToDispatch.push(actions.incrementCycle());
// Pause tracking during break if configured
if (shouldPauseTracking) {
actionsArr.push(unsetCurrentTask());
}
// Stop time tracking when session is manually ended and sync is enabled
// This fixes bug #5875: "End Session" button should stop tracking
const shouldStopTrackingOnManualEnd =
action.isManual && focusModeConfig?.isSyncSessionWithTracking && currentTaskId;
if (shouldStopTrackingOnManualEnd) {
actionsToDispatch.push(unsetCurrentTask());
// Start break with appropriate duration
if (breakInfo) {
actionsArr.push(
actions.startBreak({
duration: breakInfo.duration,
isLongBreak: breakInfo.isLong,
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
} else {
// Fallback if no break info
actionsArr.push(
actions.startBreak({
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
}
// Check if we should start a break after session completion
// Skip if manual break start is enabled (user must click "Start Break")
const shouldAutoStartBreak =
strategy.shouldStartBreakAfterSession && !focusModeConfig?.isManualBreakStart;
if (shouldAutoStartBreak) {
// Pause task tracking during break if enabled
const shouldPauseTracking =
focusModeConfig?.isPauseTrackingDuringBreak && currentTaskId;
if (shouldPauseTracking) {
actionsToDispatch.push(unsetCurrentTask());
}
// Get break duration from strategy
const breakInfo = strategy.getBreakDuration(cycle);
if (breakInfo) {
actionsToDispatch.push(
actions.startBreak({
duration: breakInfo.duration,
isLongBreak: breakInfo.isLong,
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
} else {
// Fallback if no break info (shouldn't happen for Pomodoro)
actionsToDispatch.push(
actions.startBreak({
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
}
}
return actionsToDispatch.length > 0 ? of(...actionsToDispatch) : EMPTY;
return of(...actionsArr);
}),
),
);
// Handle break completion
// Effect 4: Notification side effect (non-dispatching)
notifyOnSessionComplete$ = createEffect(
() =>
this.actions$.pipe(
ofType(actions.completeFocusSession),
tap(() => this._notifyUser()),
),
{ dispatch: false },
);
// Break completion effects - split into separate concerns for better maintainability
// Note: pausedTaskId is passed in action payload to avoid race condition
// (reducer clears pausedTaskId before effect reads state)
breakComplete$ = createEffect(() =>
// Effect 1: Resume tracking after break
resumeTrackingOnBreakComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeBreak),
filter((action) => !!action.pausedTaskId),
map((action) => setCurrentTask({ id: action.pausedTaskId! })),
),
);
// Effect 2: Auto-start next session after break
autoStartSessionOnBreakComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.completeBreak),
withLatestFrom(this.store.select(selectors.selectMode)),
switchMap(([action, mode]) => {
filter(([_, mode]) => {
const strategy = this.strategyFactory.getStrategy(mode);
const actionsToDispatch: any[] = [];
// Show notification (sound + window focus)
this._notifyUser();
// Resume task tracking if we paused it during break
if (action.pausedTaskId) {
actionsToDispatch.push(setCurrentTask({ id: action.pausedTaskId }));
}
// Auto-start next session if configured
if (strategy.shouldAutoStartNextSession) {
const duration = strategy.initialSessionDuration;
actionsToDispatch.push(actions.startFocusSession({ duration }));
}
return actionsToDispatch.length > 0 ? of(...actionsToDispatch) : EMPTY;
return strategy.shouldAutoStartNextSession;
}),
map(([_, mode]) => {
const strategy = this.strategyFactory.getStrategy(mode);
return actions.startFocusSession({ duration: strategy.initialSessionDuration });
}),
),
);
// Effect 3: Notification side effect (non-dispatching)
notifyOnBreakComplete$ = createEffect(
() =>
this.actions$.pipe(
ofType(actions.completeBreak),
tap(() => this._notifyUser()),
),
{ dispatch: false },
);
// Handle skip break
// Note: pausedTaskId is passed in action payload to avoid race condition
skipBreak$ = createEffect(() =>
@ -674,6 +703,7 @@ export class FocusModeEffects {
isOnBreak,
isSessionCompleted,
isBreakTimeUp,
focusModeConfig,
)
: this._getTextButtonActions(isSessionCompleted)),
});
@ -715,6 +745,7 @@ export class FocusModeEffects {
isOnBreak: boolean,
isSessionCompleted: boolean,
isBreakTimeUp: boolean,
focusModeConfig: FocusModeConfig | undefined,
): Pick<Banner, 'action' | 'action2' | 'action3'> {
const isPaused = !timer.isRunning && timer.purpose !== null;
@ -750,17 +781,46 @@ export class FocusModeEffects {
}
});
} else {
// Start a new session using the current mode's strategy
this.store
.select(selectors.selectMode)
// Session completed - check if we should start a break or new session
combineLatest([
this.store.select(selectors.selectMode),
this.store.select(selectors.selectCurrentCycle),
])
.pipe(take(1))
.subscribe((mode) => {
.subscribe(([mode, cycle]) => {
const strategy = this.strategyFactory.getStrategy(mode);
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
// If manual break start is enabled and mode supports breaks, start a break
if (
focusModeConfig?.isManualBreakStart &&
strategy.shouldStartBreakAfterSession
) {
const breakInfo = strategy.getBreakDuration(cycle ?? 1);
if (breakInfo) {
const currentTaskId = this.taskService.currentTaskId();
const shouldPauseTracking =
focusModeConfig?.isPauseTrackingDuringBreak && currentTaskId;
if (shouldPauseTracking) {
this.store.dispatch(unsetCurrentTask());
}
this.store.dispatch(
actions.startBreak({
duration: breakInfo.duration,
isLongBreak: breakInfo.isLong,
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
}),
);
}
} else {
// Otherwise start a new session
this.store.dispatch(
actions.startFocusSession({
duration: strategy.initialSessionDuration,
}),
);
}
});
}
},

View file

@ -0,0 +1,307 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { IssueService } from './issue.service';
import { TaskService } from '../tasks/task.service';
import { SnackService } from '../../core/snack/snack.service';
import { WorkContextService } from '../work-context/work-context.service';
import { WorkContextType } from '../work-context/work-context.model';
import { IssueProviderService } from './issue-provider.service';
import { ProjectService } from '../project/project.service';
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service';
import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service';
import { Task, TaskWithSubTasks } from '../tasks/task.model';
import { of } from 'rxjs';
import { T } from '../../t.const';
import { TODAY_TAG } from '../tag/tag.const';
import { ICalIssueReduced } from './providers/calendar/calendar.model';
import { SnackParams } from '../../core/snack/snack.model';
import { JiraCommonInterfacesService } from './providers/jira/jira-common-interfaces.service';
import { GithubCommonInterfacesService } from './providers/github/github-common-interfaces.service';
import { TrelloCommonInterfacesService } from './providers/trello/trello-common-interfaces.service';
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service';
import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service';
import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service';
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
import { ClickUpCommonInterfacesService } from './providers/clickup/clickup-common-interfaces.service';
import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service';
describe('IssueService', () => {
let service: IssueService;
let taskServiceSpy: jasmine.SpyObj<TaskService>;
let snackServiceSpy: jasmine.SpyObj<SnackService>;
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
let issueProviderServiceSpy: jasmine.SpyObj<IssueProviderService>;
let projectServiceSpy: jasmine.SpyObj<ProjectService>;
let calendarIntegrationServiceSpy: jasmine.SpyObj<CalendarIntegrationService>;
let storeSpy: jasmine.SpyObj<Store>;
let translateServiceSpy: jasmine.SpyObj<TranslateService>;
let globalProgressBarServiceSpy: jasmine.SpyObj<GlobalProgressBarService>;
let navigateToTaskServiceSpy: jasmine.SpyObj<NavigateToTaskService>;
const createMockTask = (overrides: Partial<Task> = {}): Task =>
({
id: 'existing-task-123',
title: 'Existing Calendar Event Task',
issueId: 'cal-event-456',
issueProviderId: 'calendar-provider-1',
issueType: 'ICAL',
dueWithTime: new Date('2025-01-20T14:00:00Z').getTime(),
projectId: 'project-1',
tagIds: [],
...overrides,
}) as Task;
const createMockCalendarEvent = (
overrides: Partial<ICalIssueReduced> = {},
): ICalIssueReduced => ({
id: 'cal-event-456',
calProviderId: 'calendar-provider-1',
title: 'Calendar Event',
start: new Date('2025-01-20T14:00:00Z').getTime(),
duration: 3600000,
...overrides,
});
beforeEach(() => {
taskServiceSpy = jasmine.createSpyObj('TaskService', [
'checkForTaskWithIssueEverywhere',
'getByIdWithSubTaskData$',
'moveToCurrentWorkContext',
'add',
'addAndSchedule',
'restoreTask',
]);
snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], {
activeWorkContextId: TODAY_TAG.id,
activeWorkContextType: WorkContextType.TAG,
});
issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [
'getCfgOnce$',
]);
projectServiceSpy = jasmine.createSpyObj('ProjectService', [
'getByIdOnce$',
'moveTaskToTodayList',
]);
calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [
'skipCalendarEvent',
]);
storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']);
translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']);
globalProgressBarServiceSpy = jasmine.createSpyObj('GlobalProgressBarService', [
'countUp',
'countDown',
]);
navigateToTaskServiceSpy = jasmine.createSpyObj('NavigateToTaskService', [
'navigate',
]);
// Default mock return values - use 'as any' to bypass strict type checking
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
of({ defaultProjectId: 'project-1' } as any),
);
// Default mock for getByIdWithSubTaskData$ - needed when task already exists
taskServiceSpy.getByIdWithSubTaskData$.and.returnValue(
of({
id: 'existing-task-123',
title: 'Existing Task',
subTasks: [],
} as any),
);
// Default mock for projectService
projectServiceSpy.getByIdOnce$.and.returnValue(of({ title: 'Project 1' } as any));
// Create mock providers for all common interface services
const mockCommonInterfaceService = jasmine.createSpyObj('CommonInterfaceService', [
'isEnabled',
'getAddTaskData',
]);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
IssueService,
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: WorkContextService, useValue: workContextServiceSpy },
{ provide: IssueProviderService, useValue: issueProviderServiceSpy },
{ provide: ProjectService, useValue: projectServiceSpy },
{ provide: CalendarIntegrationService, useValue: calendarIntegrationServiceSpy },
{ provide: Store, useValue: storeSpy },
{ provide: TranslateService, useValue: translateServiceSpy },
{ provide: GlobalProgressBarService, useValue: globalProgressBarServiceSpy },
{ provide: NavigateToTaskService, useValue: navigateToTaskServiceSpy },
{ provide: JiraCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: GithubCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: TrelloCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: GitlabCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: CaldavCommonInterfacesService, useValue: mockCommonInterfaceService },
{
provide: OpenProjectCommonInterfacesService,
useValue: mockCommonInterfaceService,
},
{ provide: GiteaCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: RedmineCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: LinearCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: ClickUpCommonInterfacesService, useValue: mockCommonInterfaceService },
{
provide: CalendarCommonInterfacesService,
useValue: mockCommonInterfaceService,
},
],
});
service = TestBed.inject(IssueService);
});
describe('addTaskFromIssue - ICAL task already exists', () => {
it('should NOT move existing ICAL task to current context when task already exists', async () => {
const existingTask = createMockTask();
const calendarEvent = createMockCalendarEvent();
// Task already exists - checkForTaskWithIssueEverywhere returns the task
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
await service.addTaskFromIssue({
issueDataReduced: calendarEvent,
issueProviderId: 'calendar-provider-1',
issueProviderKey: 'ICAL',
});
// Should NOT call moveToCurrentWorkContext - this is the key assertion
expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled();
});
it('should show snackbar with Go to Task action when ICAL task already exists', async () => {
const existingTask = createMockTask();
const calendarEvent = createMockCalendarEvent();
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
await service.addTaskFromIssue({
issueDataReduced: calendarEvent,
issueProviderId: 'calendar-provider-1',
issueProviderKey: 'ICAL',
});
// Should show snackbar with task title and Go to Task action
expect(snackServiceSpy.open).toHaveBeenCalledWith(
jasmine.objectContaining({
msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
actionStr: T.F.TASK.S.GO_TO_TASK,
actionFn: jasmine.any(Function),
}),
);
});
it('should navigate to task when Go to Task action is clicked', async () => {
const existingTask = createMockTask();
const calendarEvent = createMockCalendarEvent();
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
await service.addTaskFromIssue({
issueDataReduced: calendarEvent,
issueProviderId: 'calendar-provider-1',
issueProviderKey: 'ICAL',
});
// Get the actionFn from the snackbar call and execute it
const snackCall = snackServiceSpy.open.calls.mostRecent();
const snackParams = snackCall.args[0] as SnackParams;
const actionFn = snackParams.actionFn;
actionFn!();
expect(navigateToTaskServiceSpy.navigate).toHaveBeenCalledWith(
existingTask.id,
false,
);
});
it('should preserve original dueWithTime when ICAL task already exists', async () => {
const originalDueWithTime = new Date('2025-01-25T10:00:00Z').getTime();
const existingTask = createMockTask({ dueWithTime: originalDueWithTime });
const calendarEvent = createMockCalendarEvent();
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
await service.addTaskFromIssue({
issueDataReduced: calendarEvent,
issueProviderId: 'calendar-provider-1',
issueProviderKey: 'ICAL',
});
// Should not modify the task at all - no moveToCurrentWorkContext
expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled();
});
it('should return undefined when ICAL task already exists', async () => {
const existingTask = createMockTask();
const calendarEvent = createMockCalendarEvent();
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
const result = await service.addTaskFromIssue({
issueDataReduced: calendarEvent,
issueProviderId: 'calendar-provider-1',
issueProviderKey: 'ICAL',
});
expect(result).toBeUndefined();
});
});
describe('addTaskFromIssue - non-ICAL issue types (unchanged behavior)', () => {
it('should still move non-ICAL tasks to current context when found', async () => {
const existingTask = createMockTask({ issueType: 'GITHUB' });
const githubIssue = {
id: 'github-issue-123',
title: 'GitHub Issue',
};
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
task: existingTask,
subTasks: null,
isFromArchive: false,
});
taskServiceSpy.getByIdWithSubTaskData$.and.returnValue(
of(existingTask as TaskWithSubTasks),
);
await service.addTaskFromIssue({
issueDataReduced: githubIssue as any,
issueProviderId: 'github-provider-1',
issueProviderKey: 'GITHUB',
});
// For non-ICAL types, should still call moveToCurrentWorkContext
expect(taskServiceSpy.moveToCurrentWorkContext).toHaveBeenCalled();
});
});
});

View file

@ -58,6 +58,7 @@ import { getDbDateStr } from '../../util/get-db-date-str';
import { TODAY_TAG } from '../tag/tag.const';
import typia from 'typia';
import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service';
import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service';
@Injectable({
providedIn: 'root',
@ -83,6 +84,7 @@ export class IssueService {
private _calendarIntegrationService = inject(CalendarIntegrationService);
private _store = inject(Store);
private _globalProgressBarService = inject(GlobalProgressBarService);
private _navigateToTaskService = inject(NavigateToTaskService);
ISSUE_SERVICE_MAP: { [key: string]: IssueServiceInterface } = {
[GITLAB_TYPE]: this._gitlabCommonInterfacesService,
@ -662,6 +664,19 @@ export class IssueService {
translateParams: { title: res.task.title },
});
return true;
} else if (issueType === ICAL_TYPE) {
// For calendar events, don't move to today - just show snackbar with navigation
const taskId = res.task.id;
this._snackService.open({
ico: 'info',
msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
translateParams: { title: res.task.title },
actionStr: T.F.TASK.S.GO_TO_TASK,
actionFn: () => {
this._navigateToTaskService.navigate(taskId, false);
},
});
return true;
} else {
const taskWithTaskSubTasks = await this._taskService
.getByIdWithSubTaskData$(res.task.id)

View file

@ -5,22 +5,34 @@ import { IssueProviderService } from '../../issue-provider.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ICalIssueReduced } from './calendar.model';
import { getDbDateStr } from '../../../../util/get-db-date-str';
import { of } from 'rxjs';
import { Task } from '../../../tasks/task.model';
import { CalendarIntegrationEvent } from '../../../calendar-integration/calendar-integration.model';
describe('CalendarCommonInterfacesService', () => {
let service: CalendarCommonInterfacesService;
let calendarIntegrationServiceSpy: jasmine.SpyObj<CalendarIntegrationService>;
let issueProviderServiceSpy: jasmine.SpyObj<IssueProviderService>;
beforeEach(() => {
calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [
'requestEventsForSchedule$',
]);
issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [
'getCfgOnce$',
]);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
CalendarCommonInterfacesService,
{
provide: CalendarIntegrationService,
useValue: {},
useValue: calendarIntegrationServiceSpy,
},
{
provide: IssueProviderService,
useValue: {},
useValue: issueProviderServiceSpy,
},
],
});
@ -118,4 +130,484 @@ describe('CalendarCommonInterfacesService', () => {
expect(result.notes).toBe('');
});
});
describe('getFreshDataForIssueTask', () => {
const mockCalendarCfg = {
id: 'provider-1',
isEnabled: true,
icalUrl: 'https://example.com/calendar.ics',
};
const createMockTask = (overrides: Partial<Task> = {}): Task =>
({
id: 'task-1',
issueId: 'event-123',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Original Title',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000,
...overrides,
}) as Task;
const createMockCalendarEvent = (
overrides: Partial<CalendarIntegrationEvent> = {},
): CalendarIntegrationEvent => ({
id: 'event-123',
calProviderId: 'provider-1',
title: 'Original Title',
start: new Date('2025-01-15T10:00:00Z').getTime(),
duration: 3600000,
...overrides,
});
it('should return null when task has no issueProviderId', async () => {
const task = createMockTask({ issueProviderId: undefined });
const result = await service.getFreshDataForIssueTask(task);
expect(result).toBeNull();
});
it('should return null when task has no issueId', async () => {
const task = createMockTask({ issueId: undefined });
const result = await service.getFreshDataForIssueTask(task);
expect(result).toBeNull();
});
it('should return null when provider config is not found', async () => {
const task = createMockTask();
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(null as any));
const result = await service.getFreshDataForIssueTask(task);
expect(result).toBeNull();
});
it('should return null when matching event is not found', async () => {
const task = createMockTask();
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(of([]));
const result = await service.getFreshDataForIssueTask(task);
expect(result).toBeNull();
});
it('should return null when event has no changes', async () => {
const task = createMockTask();
const calendarEvent = createMockCalendarEvent();
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).toBeNull();
});
it('should return taskChanges when event time changed', async () => {
const task = createMockTask();
const newStartTime = new Date('2025-01-15T14:00:00Z').getTime();
const calendarEvent = createMockCalendarEvent({ start: newStartTime });
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).not.toBeNull();
expect(result!.taskChanges.dueWithTime).toBe(newStartTime);
expect(result!.taskChanges.issueWasUpdated).toBe(true);
expect(result!.issueTitle).toBe('Original Title');
});
it('should return taskChanges when event title changed', async () => {
const task = createMockTask();
const calendarEvent = createMockCalendarEvent({ title: 'Updated Title' });
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).not.toBeNull();
expect(result!.taskChanges.title).toBe('Updated Title');
expect(result!.issueTitle).toBe('Updated Title');
});
it('should return taskChanges when event duration changed', async () => {
const task = createMockTask();
const newDuration = 7200000; // 2 hours
const calendarEvent = createMockCalendarEvent({ duration: newDuration });
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).not.toBeNull();
expect(result!.taskChanges.timeEstimate).toBe(newDuration);
});
it('should match event by legacy ID', async () => {
const task = createMockTask({ issueId: 'legacy-event-id' });
const calendarEvent = createMockCalendarEvent({
id: 'new-event-id',
legacyIds: ['legacy-event-id'],
title: 'Updated Title',
});
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).not.toBeNull();
expect(result!.taskChanges.title).toBe('Updated Title');
});
it('should handle all-day event conversion correctly', async () => {
const task = createMockTask({ dueWithTime: undefined, dueDay: '2025-01-15' });
// Use midday (12:00) to ensure date is consistent across all timezones
const eventStartTime = new Date('2025-01-16T12:00:00Z').getTime();
const calendarEvent = createMockCalendarEvent({
isAllDay: true,
start: eventStartTime,
});
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTask(task);
expect(result).not.toBeNull();
expect(result!.taskChanges.dueDay).toBe(getDbDateStr(eventStartTime));
expect(result!.taskChanges.dueWithTime).toBeUndefined();
});
});
describe('getFreshDataForIssueTasks', () => {
const mockCalendarCfg = {
id: 'provider-1',
isEnabled: true,
icalUrl: 'https://example.com/calendar.ics',
};
it('should return empty array when no tasks have changes', async () => {
const task = {
id: 'task-1',
issueId: 'event-123',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Same Title',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const calendarEvent: CalendarIntegrationEvent = {
id: 'event-123',
calProviderId: 'provider-1',
title: 'Same Title',
start: new Date('2025-01-15T10:00:00Z').getTime(),
duration: 3600000,
};
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTasks([task]);
expect(result).toEqual([]);
});
it('should return only tasks with changes', async () => {
const task1 = {
id: 'task-1',
issueId: 'event-1',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Same Title',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const task2 = {
id: 'task-2',
issueId: 'event-2',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Old Title',
dueWithTime: new Date('2025-01-15T11:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const calendarEvent1: CalendarIntegrationEvent = {
id: 'event-1',
calProviderId: 'provider-1',
title: 'Same Title',
start: new Date('2025-01-15T10:00:00Z').getTime(),
duration: 3600000,
};
const calendarEvent2: CalendarIntegrationEvent = {
id: 'event-2',
calProviderId: 'provider-1',
title: 'New Title',
start: new Date('2025-01-15T11:00:00Z').getTime(),
duration: 3600000,
};
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent1, calendarEvent2]),
);
const result = await service.getFreshDataForIssueTasks([task1, task2]);
expect(result.length).toBe(1);
expect(result[0].task.id).toBe('task-2');
expect(result[0].taskChanges.title).toBe('New Title');
});
it('should batch tasks by provider to minimize calendar fetches', async () => {
const mockCfgProvider1 = {
id: 'provider-1',
isEnabled: true,
icalUrl: 'https://example.com/calendar1.ics',
};
const mockCfgProvider2 = {
id: 'provider-2',
isEnabled: true,
icalUrl: 'https://example.com/calendar2.ics',
};
// Tasks from two different providers
const task1 = {
id: 'task-1',
issueId: 'event-1',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Old Title 1',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const task2 = {
id: 'task-2',
issueId: 'event-2',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Old Title 2',
dueWithTime: new Date('2025-01-15T11:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const task3 = {
id: 'task-3',
issueId: 'event-3',
issueProviderId: 'provider-2',
issueType: 'ICAL',
title: 'Old Title 3',
dueWithTime: new Date('2025-01-15T12:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
// Calendar events with updated titles
const calendarEvent1: CalendarIntegrationEvent = {
id: 'event-1',
calProviderId: 'provider-1',
title: 'New Title 1',
start: new Date('2025-01-15T10:00:00Z').getTime(),
duration: 3600000,
};
const calendarEvent2: CalendarIntegrationEvent = {
id: 'event-2',
calProviderId: 'provider-1',
title: 'New Title 2',
start: new Date('2025-01-15T11:00:00Z').getTime(),
duration: 3600000,
};
const calendarEvent3: CalendarIntegrationEvent = {
id: 'event-3',
calProviderId: 'provider-2',
title: 'New Title 3',
start: new Date('2025-01-15T12:00:00Z').getTime(),
duration: 3600000,
};
// Setup spies to return different configs for different providers
issueProviderServiceSpy.getCfgOnce$.and.callFake((providerId: string) => {
if (providerId === 'provider-1') return of(mockCfgProvider1 as any);
if (providerId === 'provider-2') return of(mockCfgProvider2 as any);
return of(null as any);
});
// Setup calendar service to return events based on config
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.callFake((cfg: any) => {
if (cfg.id === 'provider-1') return of([calendarEvent1, calendarEvent2]);
if (cfg.id === 'provider-2') return of([calendarEvent3]);
return of([]);
});
const result = await service.getFreshDataForIssueTasks([task1, task2, task3]);
// Should return all 3 tasks with changes
expect(result.length).toBe(3);
// Verify batching: getCfgOnce$ should be called once per provider (not per task)
expect(issueProviderServiceSpy.getCfgOnce$.calls.count()).toBe(2);
// Verify batching: requestEventsForSchedule$ should be called once per provider
expect(calendarIntegrationServiceSpy.requestEventsForSchedule$.calls.count()).toBe(
2,
);
// Verify all tasks got their updates
const task1Result = result.find((r) => r.task.id === 'task-1');
const task2Result = result.find((r) => r.task.id === 'task-2');
const task3Result = result.find((r) => r.task.id === 'task-3');
expect(task1Result?.taskChanges.title).toBe('New Title 1');
expect(task2Result?.taskChanges.title).toBe('New Title 2');
expect(task3Result?.taskChanges.title).toBe('New Title 3');
});
it('should handle mixed scenarios: some providers fail, some succeed', async () => {
const mockCfgProvider1 = {
id: 'provider-1',
isEnabled: true,
icalUrl: 'https://example.com/calendar1.ics',
};
const task1 = {
id: 'task-1',
issueId: 'event-1',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Old Title',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const task2 = {
id: 'task-2',
issueId: 'event-2',
issueProviderId: 'provider-missing',
issueType: 'ICAL',
title: 'Old Title 2',
dueWithTime: new Date('2025-01-15T11:00:00Z').getTime(),
timeEstimate: 3600000,
} as Task;
const calendarEvent1: CalendarIntegrationEvent = {
id: 'event-1',
calProviderId: 'provider-1',
title: 'New Title',
start: new Date('2025-01-15T10:00:00Z').getTime(),
duration: 3600000,
};
issueProviderServiceSpy.getCfgOnce$.and.callFake((providerId: string) => {
if (providerId === 'provider-1') return of(mockCfgProvider1 as any);
return of(null as any); // Provider not found
});
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent1]),
);
const result = await service.getFreshDataForIssueTasks([task1, task2]);
// Should only return the task from the working provider
expect(result.length).toBe(1);
expect(result[0].task.id).toBe('task-1');
expect(result[0].taskChanges.title).toBe('New Title');
});
it('should skip tasks without issueProviderId or issueId', async () => {
const taskWithoutProvider = {
id: 'task-1',
issueId: 'event-1',
issueProviderId: undefined, // Missing provider
issueType: 'ICAL',
title: 'Task 1',
} as Task;
const taskWithoutIssueId = {
id: 'task-2',
issueId: undefined, // Missing issue ID
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Task 2',
} as Task;
const result = await service.getFreshDataForIssueTasks([
taskWithoutProvider,
taskWithoutIssueId,
]);
// Should return empty array since both tasks are invalid
expect(result).toEqual([]);
// Should not call any services
expect(issueProviderServiceSpy.getCfgOnce$.calls.count()).toBe(0);
expect(calendarIntegrationServiceSpy.requestEventsForSchedule$.calls.count()).toBe(
0,
);
});
it('should handle multiple changes in a single event', async () => {
const task = {
id: 'task-1',
issueId: 'event-1',
issueProviderId: 'provider-1',
issueType: 'ICAL',
title: 'Old Title',
dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(),
timeEstimate: 3600000, // 1 hour
} as Task;
// Event with multiple changes: title, time, and duration
const calendarEvent: CalendarIntegrationEvent = {
id: 'event-1',
calProviderId: 'provider-1',
title: 'New Title',
start: new Date('2025-01-16T14:00:00Z').getTime(), // Different day and time
duration: 7200000, // 2 hours
};
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any));
calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(
of([calendarEvent]),
);
const result = await service.getFreshDataForIssueTasks([task]);
expect(result.length).toBe(1);
expect(result[0].taskChanges.title).toBe('New Title');
expect(result[0].taskChanges.dueWithTime).toBe(
new Date('2025-01-16T14:00:00Z').getTime(),
);
expect(result[0].taskChanges.timeEstimate).toBe(7200000);
expect(result[0].taskChanges.issueWasUpdated).toBe(true);
});
});
describe('pollInterval', () => {
it('should have a poll interval of 10 minutes', () => {
expect(service.pollInterval).toBe(10 * 60 * 1000);
});
});
});

View file

@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { firstValueFrom, Observable } from 'rxjs';
import { Task, TaskCopy } from '../../../tasks/task.model';
import { IssueServiceInterface } from '../../issue-service-interface';
import {
@ -9,12 +9,14 @@ import {
SearchResultItem,
} from '../../issue.model';
import { CalendarIntegrationService } from '../../../calendar-integration/calendar-integration.service';
import { map, switchMap } from 'rxjs/operators';
import { first, map, switchMap } from 'rxjs/operators';
import { matchesAnyCalendarEventId } from '../../../calendar-integration/get-calendar-event-id-candidates';
import { IssueProviderService } from '../../issue-provider.service';
import { CalendarProviderCfg, ICalIssueReduced } from './calendar.model';
import { HttpClient } from '@angular/common/http';
import { ICAL_TYPE } from '../../issue.const';
import { getDbDateStr } from '../../../../util/get-db-date-str';
import { CALENDAR_POLL_INTERVAL } from './calendar.const';
@Injectable({
providedIn: 'root',
@ -28,8 +30,7 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface {
return cfg.isEnabled && cfg.icalUrl?.length > 0;
}
// We currently don't support polling for calendar events
pollInterval: number = 0;
pollInterval: number = CALENDAR_POLL_INTERVAL;
issueLink(issueId: number, issueProviderId: string): Promise<string> {
return Promise.resolve('NONE');
@ -65,9 +66,12 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface {
};
}
searchIssues(query: string, issueProviderId: string): Promise<SearchResultItem[]> {
return this._getCfgOnce$(issueProviderId)
.pipe(
async searchIssues(
query: string,
issueProviderId: string,
): Promise<SearchResultItem[]> {
const result = await firstValueFrom(
this._getCfgOnce$(issueProviderId).pipe(
switchMap((cfg) =>
this._calendarIntegrationService.requestEventsForSchedule$(cfg, true),
),
@ -82,9 +86,9 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface {
issueData: calEvent,
})),
),
)
.toPromise()
.then((result) => result ?? []);
),
);
return result ?? [];
}
async getFreshDataForIssueTask(task: Task): Promise<{
@ -92,7 +96,13 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface {
issue: IssueData;
issueTitle: string;
} | null> {
return null;
const results = await this.getFreshDataForIssueTasks([task]);
if (!results.length) return null;
return {
taskChanges: results[0].taskChanges,
issue: results[0].issue,
issueTitle: (results[0].issue as unknown as ICalIssueReduced).title,
};
}
async getFreshDataForIssueTasks(tasks: Task[]): Promise<
@ -102,7 +112,56 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface {
issue: IssueData;
}[]
> {
return [];
// Group tasks by provider to minimize fetches
const tasksByProvider = new Map<string, Task[]>();
for (const task of tasks) {
if (!task.issueProviderId || !task.issueId) continue;
const existing = tasksByProvider.get(task.issueProviderId) || [];
existing.push(task);
tasksByProvider.set(task.issueProviderId, existing);
}
const results: {
task: Readonly<Task>;
taskChanges: Partial<Readonly<Task>>;
issue: IssueData;
}[] = [];
for (const [providerId, providerTasks] of tasksByProvider) {
const cfg = await firstValueFrom(this._getCfgOnce$(providerId).pipe(first()));
if (!cfg) continue;
const events = await firstValueFrom(
this._calendarIntegrationService
.requestEventsForSchedule$(cfg, false)
.pipe(first()),
);
if (!events?.length) continue;
for (const task of providerTasks) {
const matchingEvent = events.find((ev) =>
matchesAnyCalendarEventId(ev, [task.issueId as string]),
);
if (!matchingEvent) continue;
const taskData = this.getAddTaskData(matchingEvent);
const hasChanges =
taskData.dueWithTime !== task.dueWithTime ||
taskData.dueDay !== task.dueDay ||
taskData.title !== task.title ||
taskData.timeEstimate !== task.timeEstimate;
if (hasChanges) {
results.push({
task,
taskChanges: { ...taskData, issueWasUpdated: true },
issue: matchingEvent as unknown as IssueData,
});
}
}
}
return results;
}
async getNewIssuesToAddToBacklog(

View file

@ -2,13 +2,16 @@ import { ConfigFormSection } from '../../../config/global-config.model';
import { T } from '../../../../t.const';
import { IssueProviderCalendar } from '../../issue.model';
import { CalendarProviderCfg } from './calendar.model';
import { ISSUE_PROVIDER_FF_DEFAULT_PROJECT } from '../../common-issue-form-stuff.const';
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
import { IS_ELECTRON } from '../../../../app.constants';
import { IssueLog } from '../../../../core/log';
// 5 minutes for local file:// URLs (faster polling for local calendars)
export const LOCAL_FILE_CHECK_INTERVAL = 5 * 60 * 1000;
// Poll interval for checking calendar task updates (10 minutes)
export const CALENDAR_POLL_INTERVAL = 10 * 60 * 1000;
export const getEffectiveCheckInterval = (calProvider: IssueProviderCalendar): number => {
if (calProvider.icalUrl?.startsWith('file://')) {
return LOCAL_FILE_CHECK_INTERVAL;
@ -51,7 +54,6 @@ export const CALENDAR_FORM_CFG_NEW: ConfigFormSection<IssueProviderCalendar> = {
label: T.GCF.CALENDARS.CAL_PATH,
},
},
ISSUE_PROVIDER_FF_DEFAULT_PROJECT,
{
type: 'duration',
key: 'checkUpdatesEvery',
@ -106,19 +108,6 @@ export const CALENDAR_FORM_CFG_NEW: ConfigFormSection<IssueProviderCalendar> = {
label: 'Disable when using web application',
},
},
// {
// type: 'icon',
// key: 'icon',
// hooks: {
// onInit: (field) => {
// if (!field?.formControl?.value) {
// field?.formControl?.setValue('event');
// }
// },
// },
// templateOptions: {
// label: T.GCF.CALENDARS.ICON,
// },
// },
...ISSUE_PROVIDER_COMMON_FORM_FIELDS,
],
};

View file

@ -45,7 +45,7 @@ export const GITHUB_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderGithub>[]
{
type: 'link',
props: {
url: 'https://github.com/johannesjo/super-productivity/blob/master/docs/github-access-token-instructions.md',
url: 'https://github.com/super-productivity/super-productivity/blob/master/docs/github-access-token-instructions.md',
txt: T.F.ISSUE.HOW_TO_GET_A_TOKEN,
},
},

View file

@ -44,7 +44,7 @@ export const GITLAB_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderGitlab>[]
{
type: 'link',
templateOptions: {
url: 'https://github.com/johannesjo/super-productivity/blob/master/docs/gitlab-access-token-instructions.md',
url: 'https://github.com/super-productivity/super-productivity/blob/master/docs/gitlab-access-token-instructions.md',
txt: T.F.ISSUE.HOW_TO_GET_A_TOKEN,
},
},

View file

@ -1,25 +1,566 @@
// import { TestBed } from '@angular/core/testing';
// import { provideMockActions } from '@ngrx/effects/testing';
// import { Observable } from 'rxjs';
//
// import { PollIssueUpdatesEffects } from './poll-issue-updates.effects';
//
// describe('PollIssueUpdatesEffects', () => {
// let actions$: Observable<any>;
// let effects: PollIssueUpdatesEffects;
//
// beforeEach(() => {
// TestBed.configureTestingModule({
// providers: [
// PollIssueUpdatesEffects,
// provideMockActions(() => actions$)
// ]
// });
//
// effects = TestBed.inject(PollIssueUpdatesEffects);
// });
//
// it('should be created', () => {
// expect(effects).toBeTruthy();
// });
// });
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { Observable, of, Subject } from 'rxjs';
import { PollIssueUpdatesEffects } from './poll-issue-updates.effects';
import { IssueService } from '../issue.service';
import { WorkContextService } from '../../work-context/work-context.service';
import { WorkContextType } from '../../work-context/work-context.model';
import { setActiveWorkContext } from '../../work-context/store/work-context.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { selectEnabledIssueProviders } from './issue-provider.selectors';
import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors';
import { ICAL_TYPE, GITHUB_TYPE, JIRA_TYPE } from '../issue.const';
import { Task, TaskWithSubTasks } from '../../tasks/task.model';
import { IssueProvider } from '../issue.model';
describe('PollIssueUpdatesEffects', () => {
let effects: PollIssueUpdatesEffects;
let actions$: Observable<any>;
let store: MockStore;
let issueServiceSpy: jasmine.SpyObj<IssueService>;
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
const createMockTask = (overrides: Partial<Task> = {}): Task =>
({
id: 'task-1',
title: 'Test Task',
projectId: 'project-1',
tagIds: [],
subTaskIds: [],
timeSpentOnDay: {},
timeSpent: 0,
timeEstimate: 0,
isDone: false,
created: Date.now(),
attachments: [],
...overrides,
}) as Task;
const createMockIssueProvider = (
overrides: Partial<IssueProvider> = {},
): IssueProvider =>
({
id: 'provider-1',
issueProviderKey: ICAL_TYPE,
isEnabled: true,
isAutoPoll: true,
isAutoAddToBacklog: false,
isIntegratedAddTaskBar: false,
defaultProjectId: null,
pinnedSearch: null,
...overrides,
}) as IssueProvider;
beforeEach(() => {
issueServiceSpy = jasmine.createSpyObj('IssueService', [
'getPollInterval',
'refreshIssueTasks',
]);
workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], {
allTasksForCurrentContext$: of([]),
});
// Default: calendar poll interval is 10 minutes
issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => {
if (providerKey === ICAL_TYPE) return 600000; // 10 minutes
if (providerKey === GITHUB_TYPE) return 300000; // 5 minutes
return 0;
});
TestBed.configureTestingModule({
providers: [
PollIssueUpdatesEffects,
provideMockActions(() => actions$),
provideMockStore({
selectors: [
{ selector: selectEnabledIssueProviders, value: [] },
{ selector: selectAllCalendarIssueTasks, value: [] },
],
}),
{ provide: IssueService, useValue: issueServiceSpy },
{ provide: WorkContextService, useValue: workContextServiceSpy },
],
});
effects = TestBed.inject(PollIssueUpdatesEffects);
store = TestBed.inject(MockStore);
});
afterEach(() => {
store.resetSelectors();
});
describe('pollIssueChangesForCurrentContext$', () => {
it('should be created', () => {
expect(effects).toBeTruthy();
});
it('should trigger polling when setActiveWorkContext action is dispatched', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
const calendarTask = createMockTask({
id: 'cal-task-1',
issueId: 'cal-event-123',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
// Subscribe to the effect
effects.pollIssueChangesForCurrentContext$.subscribe();
// Dispatch the action
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
// Wait for the delay before polling starts (default is 10 seconds)
tick(10001);
// Should call refreshIssueTasks with calendar tasks
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
[calendarTask],
calendarProvider,
);
}));
it('should use selectAllCalendarIssueTasks for ICAL providers instead of current context', fakeAsync(() => {
// Setup: Calendar provider and tasks in DIFFERENT projects
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
// Calendar tasks from multiple projects
const calTaskProject1 = createMockTask({
id: 'cal-task-1',
projectId: 'project-1',
issueId: 'cal-event-1',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
const calTaskProject2 = createMockTask({
id: 'cal-task-2',
projectId: 'project-2', // Different project
issueId: 'cal-event-2',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
const calTaskProject3 = createMockTask({
id: 'cal-task-3',
projectId: 'project-3', // Third project
issueId: 'cal-event-3',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
// Current context only has tasks from project-1
const currentContextTasks: TaskWithSubTasks[] = [
{ ...calTaskProject1, subTasks: [] },
];
// But selectAllCalendarIssueTasks returns tasks from ALL projects
const allCalendarTasks = [calTaskProject1, calTaskProject2, calTaskProject3];
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, allCalendarTasks);
store.refreshState();
// Mock current context to only return project-1 tasks
Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', {
get: () => of(currentContextTasks),
});
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should call refreshIssueTasks with ALL calendar tasks, not just current context
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
allCalendarTasks,
calendarProvider,
);
// Verify it's NOT using current context tasks (which would only have 1 task)
const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args;
expect(callArgs[0].length).toBe(3); // All 3 calendar tasks
}));
it('should use current context tasks for non-ICAL providers like GITHUB', fakeAsync(() => {
const githubProvider = createMockIssueProvider({
id: 'github-provider-1',
issueProviderKey: GITHUB_TYPE,
});
const githubTaskCurrentContext = createMockTask({
id: 'github-task-1',
projectId: 'project-1',
issueId: 'issue-123',
issueType: GITHUB_TYPE,
issueProviderId: 'github-provider-1',
});
const currentContextTasks: TaskWithSubTasks[] = [
{ ...githubTaskCurrentContext, subTasks: [] },
];
store.overrideSelector(selectEnabledIssueProviders, [githubProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, []); // No calendar tasks
store.refreshState();
Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', {
get: () => of(currentContextTasks),
});
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// For GITHUB provider, should use current context tasks
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalled();
const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args;
// Verify the task has the expected properties (subTasks may be added by stream)
expect(callArgs[0][0].id).toBe('github-task-1');
expect(callArgs[0][0].issueType).toBe(GITHUB_TYPE);
expect(callArgs[0][0].issueProviderId).toBe('github-provider-1');
expect(callArgs[1]).toEqual(githubProvider);
}));
it('should filter calendar tasks by provider ID', fakeAsync(() => {
const calendarProvider1 = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
const calendarProvider2 = createMockIssueProvider({
id: 'cal-provider-2',
issueProviderKey: ICAL_TYPE,
});
const calTaskProvider1 = createMockTask({
id: 'cal-task-1',
issueId: 'cal-event-1',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
const calTaskProvider2 = createMockTask({
id: 'cal-task-2',
issueId: 'cal-event-2',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-2',
});
store.overrideSelector(selectEnabledIssueProviders, [
calendarProvider1,
calendarProvider2,
]);
store.overrideSelector(selectAllCalendarIssueTasks, [
calTaskProvider1,
calTaskProvider2,
]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should be called twice - once for each provider
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2);
// First call should only have tasks for provider 1
const firstCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(0);
expect(firstCall[0]).toEqual([calTaskProvider1]);
expect(firstCall[1]).toEqual(calendarProvider1);
// Second call should only have tasks for provider 2
const secondCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(1);
expect(secondCall[0]).toEqual([calTaskProvider2]);
expect(secondCall[1]).toEqual(calendarProvider2);
}));
it('should not poll providers with isAutoPoll set to false', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
isAutoPoll: false, // Disabled
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should NOT call refreshIssueTasks since auto-poll is disabled
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
}));
it('should not poll providers with 0 poll interval', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: JIRA_TYPE, // JIRA returns 0 poll interval
});
// Override to return 0 for JIRA
issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => {
if (providerKey === JIRA_TYPE) return 0;
return 600000;
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should NOT call refreshIssueTasks since poll interval is 0
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
}));
it('should handle empty providers array gracefully', fakeAsync(() => {
store.overrideSelector(selectEnabledIssueProviders, []);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should NOT call refreshIssueTasks since no providers
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
}));
it('should not call refreshIssueTasks when no tasks match the provider', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
// Return empty array - no tasks match this provider
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, []);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should NOT call refreshIssueTasks since no tasks match
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
}));
it('should filter out tasks without issueId', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
const validTask = createMockTask({
id: 'cal-task-1',
issueId: 'cal-event-123',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
// Task without issueId (corrupted data)
const invalidTask = createMockTask({
id: 'cal-task-2',
issueId: undefined as any, // Missing issueId
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, [validTask, invalidTask]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
tick(10001);
// Should only call with valid task (the one with issueId)
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
[validTask],
calendarProvider,
);
}));
it('should continue polling after an error occurs', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
const calendarTask = createMockTask({
id: 'cal-task-1',
issueId: 'cal-event-123',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
store.refreshState();
// First call throws error, second succeeds
let callCount = 0;
issueServiceSpy.refreshIssueTasks.and.callFake(() => {
callCount++;
if (callCount === 1) {
throw new Error('Network error');
}
return Promise.resolve();
});
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
actionsSubject.next(
setActiveWorkContext({
activeType: WorkContextType.PROJECT,
activeId: 'project-1',
}),
);
// First poll (should fail but not crash)
tick(10001);
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(1);
// Second poll (should succeed)
tick(600000); // 10 minutes
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2);
}));
it('should trigger polling on loadAllData action', fakeAsync(() => {
const calendarProvider = createMockIssueProvider({
id: 'cal-provider-1',
issueProviderKey: ICAL_TYPE,
});
const calendarTask = createMockTask({
id: 'cal-task-1',
issueId: 'cal-event-123',
issueType: ICAL_TYPE,
issueProviderId: 'cal-provider-1',
});
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
store.refreshState();
const actionsSubject = new Subject<any>();
actions$ = actionsSubject.asObservable();
effects.pollIssueChangesForCurrentContext$.subscribe();
// Use loadAllData instead of setActiveWorkContext
actionsSubject.next(loadAllData({ appDataComplete: {} as any }));
tick(10001);
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
[calendarTask],
calendarProvider,
);
}));
});
});

View file

@ -1,17 +1,19 @@
import { Injectable, inject } from '@angular/core';
import { createEffect, ofType } from '@ngrx/effects';
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
import { forkJoin, Observable, timer } from 'rxjs';
import { EMPTY, merge, Observable, timer } from 'rxjs';
import { first, map, switchMap, tap } from 'rxjs/operators';
import { IssueService } from '../issue.service';
import { TaskWithSubTasks } from '../../tasks/task.model';
import { Task, TaskWithSubTasks } from '../../tasks/task.model';
import { WorkContextService } from '../../work-context/work-context.service';
import { setActiveWorkContext } from '../../work-context/store/work-context.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { Store } from '@ngrx/store';
import { IssueProvider } from '../issue.model';
import { selectEnabledIssueProviders } from './issue-provider.selectors';
import { DELAY_BEFORE_ISSUE_POLLING } from '../issue.const';
import { DELAY_BEFORE_ISSUE_POLLING, ICAL_TYPE } from '../issue.const';
import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors';
import { IssueLog } from '../../../core/log';
@Injectable()
export class PollIssueUpdatesEffects {
@ -28,44 +30,89 @@ export class PollIssueUpdatesEffects {
this.pollIssueTaskUpdatesActions$.pipe(
switchMap(() => this._store.select(selectEnabledIssueProviders).pipe(first())),
// Get the list of enabled issue providers
switchMap((enabledProviders: IssueProvider[]) =>
forkJoin(
// For each enabled provider, start a polling timer
enabledProviders
// only for providers that have auto-polling enabled
.filter((provider) => provider.isAutoPoll)
// filter out providers with 0 poll interval (no polling)
.filter(
(provider) =>
this._issueService.getPollInterval(provider.issueProviderKey) > 0,
)
.map((provider) =>
timer(
DELAY_BEFORE_ISSUE_POLLING,
this._issueService.getPollInterval(provider.issueProviderKey),
).pipe(
// => whenever the provider specific poll timer ticks:
// ---------------------------------------------------
// Get all tasks for the current context
switchMap(() =>
this._workContextService.allTasksForCurrentContext$.pipe(
// get once each cycle and no updates
first(),
map((tasks) =>
// only use tasks that are assigned to the current issue provider
tasks.filter((task) => task.issueProviderId === provider.id),
),
),
),
// Refresh issue tasks for the current provider
tap((issueTasks: TaskWithSubTasks[]) =>
this._issueService.refreshIssueTasks(issueTasks, provider),
),
),
switchMap((enabledProviders: IssueProvider[]) => {
const providers = enabledProviders
// only for providers that have auto-polling enabled
.filter((provider) => provider.isAutoPoll)
// filter out providers with 0 poll interval (no polling)
.filter(
(provider) =>
this._issueService.getPollInterval(provider.issueProviderKey) > 0,
);
// Handle empty providers case
if (providers.length === 0) {
return EMPTY;
}
// Use merge instead of forkJoin so each timer can emit independently
// (forkJoin waits for all observables to complete, but timer never completes)
return merge(
...providers.map((provider) =>
timer(
DELAY_BEFORE_ISSUE_POLLING,
this._issueService.getPollInterval(provider.issueProviderKey),
).pipe(
// => whenever the provider specific poll timer ticks:
// ---------------------------------------------------
// Get tasks to refresh based on provider type
switchMap(() => this._getTasksForProvider(provider)),
// Refresh issue tasks for the current provider
// Use try-catch to prevent errors from killing the polling stream
tap((issueTasks: Task[]) => {
if (issueTasks.length > 0) {
try {
this._issueService.refreshIssueTasks(issueTasks, provider);
} catch (err) {
IssueLog.error(
'Error polling issue updates for ' + provider.id,
err,
);
}
}
}),
),
),
),
),
);
}),
),
{ dispatch: false },
);
/**
* Gets tasks to refresh for a provider.
* For calendar (ICAL) providers, returns ALL calendar tasks across all projects
* since calendar events can be assigned to any project.
* For other providers, returns only tasks in the current work context.
*/
private _getTasksForProvider(provider: IssueProvider): Observable<Task[]> {
if (provider.issueProviderKey === ICAL_TYPE) {
// For calendar providers, poll ALL calendar tasks across all projects
// This ensures calendar event updates are synced regardless of which project is active
return this._store.select(selectAllCalendarIssueTasks).pipe(
first(),
map((tasks) =>
tasks.filter(
(task) =>
task.issueProviderId === provider.id &&
// Safety: ensure task has valid issueId to prevent errors in refreshIssueTasks
!!task.issueId,
),
),
);
}
// For other providers, only poll tasks in the current context
return this._workContextService.allTasksForCurrentContext$.pipe(
first(),
map((tasks: TaskWithSubTasks[]) =>
tasks.filter(
(task) =>
task.issueProviderId === provider.id &&
// Safety: ensure task has valid issueId
!!task.issueId,
),
),
);
}
}

View file

@ -41,7 +41,7 @@
flex-direction: column;
gap: 8px;
padding: 16px;
background: rgba(0, 0, 0, 0.02);
background: var(--c-dark-10);
border-radius: 4px;
}
@ -67,16 +67,16 @@
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
background: var(--c-dark-10);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
background: var(--c-dark-20);
border-radius: 4px;
&:hover {
background: rgba(0, 0, 0, 0.3);
background: var(--c-dark-30);
}
}
}
@ -87,7 +87,7 @@
font-size: 12px;
line-height: 12px;
height: 12px;
color: rgba(0, 0, 0, 0.6);
color: var(--text-color-muted);
flex-shrink: 0;
.month-label {
@ -101,7 +101,7 @@
flex-direction: column;
gap: 2px;
font-size: 10px;
color: rgba(0, 0, 0, 0.6);
color: var(--text-color-muted);
padding-right: 4px;
width: 40px;
text-align: right;
@ -147,7 +147,7 @@
}
&.level-0 {
background: rgba(0, 0, 0, 0.05);
background: var(--c-dark-10);
}
&.level-1 {
@ -168,7 +168,7 @@
&:not(.empty):hover {
transform: scale(1.3);
outline: 1px solid rgba(0, 0, 0, 0.2);
outline: 1px solid var(--c-dark-20);
z-index: 1;
}
}
@ -179,7 +179,7 @@
justify-content: flex-end;
gap: 4px;
font-size: 11px;
color: rgba(0, 0, 0, 0.6);
color: var(--text-color-muted);
padding-right: 4px;
span {
@ -192,7 +192,7 @@
border-radius: 2px;
&.level-0 {
background: rgba(0, 0, 0, 0.05);
background: var(--c-dark-10);
}
&.level-1 {
@ -214,38 +214,36 @@
}
// Dark theme support
@media (prefers-color-scheme: dark) {
:host-context(.isDarkTheme) {
.heatmap-container {
background: rgba(255, 255, 255, 0.02);
}
.heatmap-months,
.day-labels,
.heatmap-legend {
color: rgba(255, 255, 255, 0.6);
background: var(--c-light-05);
}
.scrollable-content {
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
background: var(--c-light-05);
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background: var(--c-light-10);
&:hover {
background: rgba(255, 255, 255, 0.3);
background: var(--c-light-33);
}
}
}
.day {
&.level-0 {
background: rgba(255, 255, 255, 0.05);
background: var(--c-light-05);
}
&:not(.empty):hover {
outline-color: rgba(255, 255, 255, 0.2);
outline-color: var(--c-light-10);
}
}
.heatmap-legend .legend-item.level-0 {
background: var(--c-light-05);
}
}

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { SS } from '../../../core/persistence/storage-keys.const';
import { T } from '../../../t.const';
import { DialogFullscreenMarkdownComponent } from '../../../ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component';
@ -32,10 +32,7 @@ import { SnackService } from '../../../core/snack/snack.service';
TranslatePipe,
],
})
export class DialogAddNoteComponent
extends DialogFullscreenMarkdownComponent
implements OnDestroy
{
export class DialogAddNoteComponent extends DialogFullscreenMarkdownComponent {
// override _matDialogRef: MatDialogRef<DialogAddNoteComponent> =
// inject<MatDialogRef<DialogAddNoteComponent>>(MatDialogRef);
private _noteService = inject(NoteService);

View file

@ -332,7 +332,7 @@ const convertVEventToCalendarIntegrationEvent = (
const start = vevent.getFirstPropertyValue('dtstart').toJSDate().getTime();
// NOTE: if dtend is missing, it defaults to dtstart; @see #1814 and RFC 2455
// detailed comment in #1814:
// https://github.com/johannesjo/super-productivity/issues/1814#issuecomment-1008132824
// https://github.com/super-productivity/super-productivity/issues/1814#issuecomment-1008132824
const duration = calculateEventDuration(vevent, start);
const isAllDay = isAllDayEvent(vevent);
@ -372,7 +372,7 @@ const getAllPossibleEventsAfterStartFromIcal = (
}
// Wrap updateTimezones in try-catch to handle edge cases in some Office 365 calendars
// that can cause "Cannot read properties of null (reading 'parent')" errors.
// See: https://github.com/johannesjo/super-productivity/issues/5722
// See: https://github.com/super-productivity/super-productivity/issues/5722
let vevents: any[];
try {
vevents = ICAL.helpers.updateTimezones(comp).getAllSubcomponents('vevent');

View file

@ -580,7 +580,7 @@ export const SHEPHERD_STEPS = (
},
{
title: 'Configure Sync',
text: 'This covers syncing. If you have any questions you can always ask them <a href="https://github.com/johannesjo/super-productivity/discussions">on the projects GitHub page</a>. ',
text: 'This covers syncing. If you have any questions you can always ask them <a href="https://github.com/super-productivity/super-productivity/discussions">on the projects GitHub page</a>. ',
buttons: [NEXT_BTN],
},

View file

@ -1226,7 +1226,7 @@ describe('shortSyntax', () => {
// This group of tests address Chrono's parsing the format "<date> <month> <yy}>" as year
// This will cause unintended parsing result when the date syntax is used together with the time estimate syntax
// https://github.com/johannesjo/super-productivity/issues/4194
// https://github.com/super-productivity/super-productivity/issues/4194
// The focus of this test group will be the ability of the parser to get the correct year and time estimate
describe('should not parse time estimate syntax as year', () => {
const today = new Date();

View file

@ -565,6 +565,88 @@ describe('Task Selectors', () => {
expect(result[0]).toBe('ISSUE-123');
});
it('should select all calendar issue tasks', () => {
const result = fromSelectors.selectAllCalendarIssueTasks(mockState);
expect(result.length).toBe(1);
expect(result[0].id).toBe('task8');
expect(result[0].issueType).toBe('ICAL');
});
it('should select all calendar issue tasks from multiple projects', () => {
// Create a state with multiple calendar tasks across different projects
const multiCalendarTasks: { [id: string]: Task } = {
...mockTasks,
calTask1: {
id: 'calTask1',
title: 'Calendar Task 1',
created: Date.now(),
isDone: false,
subTaskIds: [],
tagIds: [],
projectId: 'project1',
timeSpentOnDay: {},
issueId: 'CAL-001',
issueType: 'ICAL',
issueProviderId: 'cal-provider-1',
timeEstimate: 3600000,
timeSpent: 0,
attachments: [],
},
calTask2: {
id: 'calTask2',
title: 'Calendar Task 2',
created: Date.now(),
isDone: false,
subTaskIds: [],
tagIds: [],
projectId: 'project2', // Different project
timeSpentOnDay: {},
issueId: 'CAL-002',
issueType: 'ICAL',
issueProviderId: 'cal-provider-1',
timeEstimate: 1800000,
timeSpent: 0,
attachments: [],
},
calTask3: {
id: 'calTask3',
title: 'Calendar Task 3',
created: Date.now(),
isDone: false,
subTaskIds: [],
tagIds: [],
projectId: 'project3', // Third project (hidden)
timeSpentOnDay: {},
issueId: 'CAL-003',
issueType: 'ICAL',
issueProviderId: 'cal-provider-2', // Different provider
timeEstimate: 7200000,
timeSpent: 0,
attachments: [],
},
};
const multiCalendarState = {
...mockState,
[TASK_FEATURE_NAME]: {
...mockTaskState,
ids: Object.keys(multiCalendarTasks),
entities: multiCalendarTasks,
},
};
const result = fromSelectors.selectAllCalendarIssueTasks(multiCalendarState);
// Should return all 4 ICAL tasks (task8 + 3 new ones)
expect(result.length).toBe(4);
// All should have issueType ICAL
expect(result.every((t) => t.issueType === 'ICAL')).toBe(true);
// Should include tasks from all projects
const projectIds = result.map((t) => t.projectId);
expect(projectIds).toContain('project1');
expect(projectIds).toContain('project2');
expect(projectIds).toContain('project3');
});
it('should select tasks worked on or done for a day', () => {
const result = fromSelectors.selectTasksWorkedOnOrDoneFlat(mockState, {
day: today,

View file

@ -442,6 +442,11 @@ export const selectAllCalendarTaskEventIds = createSelector(
tasks.filter((task) => task.issueType === 'ICAL').map((t) => t.issueId as string),
);
export const selectAllCalendarIssueTasks = createSelector(
selectAllTasks,
(tasks: Task[]): Task[] => tasks.filter((task) => task.issueType === 'ICAL'),
);
export const selectTasksWorkedOnOrDoneFlat = createSelector(
selectAllTasks,
(tasks: Task[], props: { day: string }) => {

View file

@ -5,7 +5,7 @@ import { IS_TOUCH_PRIMARY } from '../../../util/is-mouse-primary';
* Monkey patch for Angular Material menu to fix automatic selection issue on touch devices
* when submenu opens under user's finger near screen edges.
*
* Issue: https://github.com/johannesjo/super-productivity/issues/4436
* Issue: https://github.com/super-productivity/super-productivity/issues/4436
* Related: https://github.com/angular/components/issues/27508
*/
export const applyMatMenuTouchMonkeyPatch = (): void => {

View file

@ -17,7 +17,7 @@ import { Subscription } from 'rxjs';
* This prevents the submenu from immediately selecting an item when it opens under
* the user's finger near screen edges.
*
* Issue: https://github.com/johannesjo/super-productivity/issues/4436
* Issue: https://github.com/super-productivity/super-productivity/issues/4436
* Related Angular Components issues:
* - https://github.com/angular/components/issues/27508
* - https://github.com/angular/components/pull/14538

View file

@ -1,6 +1,6 @@
/**
* Touch-specific fixes for Angular Material menu submenu issues
* Issue: https://github.com/johannesjo/super-productivity/issues/4436
* Issue: https://github.com/super-productivity/super-productivity/issues/4436
*/
// Only apply these styles on touch devices

View file

@ -78,7 +78,7 @@ 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
* Fixes: https://github.com/super-productivity/super-productivity/issues/5859
*/
validateContextAfterDataLoad$: Observable<unknown> = createEffect(() =>
this._actions$.pipe(

View file

@ -5,6 +5,6 @@
"description_2": "It is used to tell the tools and bundlers whether the code under this directory is free of code with non-local side-effect. Any code that does have non-local side-effects can't be well optimized (tree-shaken) and will result in unnecessary increased payload size.",
"description_3": "It should be safe to set this option to 'false' for new applications, but existing code bases could be broken when built with the production config if the application code does contain non-local side-effects that the application depends on.",
"description_4": "To learn more about this file see: https://angular.io/config/app-package-json.",
"NOTE": "Setting sideEffects to true because of some weird behavior in production builds, see https://github.com/johannesjo/super-productivity/pull/501#issuecomment-694488795",
"NOTE": "Setting sideEffects to true because of some weird behavior in production builds, see https://github.com/super-productivity/super-productivity/pull/501#issuecomment-694488795",
"sideEffects": true
}

View file

@ -101,7 +101,7 @@
>
Super Productivity
<a
href="https://github.com/johannesjo/super-productivity/blob/master/CHANGELOG.md"
href="https://github.com/super-productivity/super-productivity/blob/master/CHANGELOG.md"
target="_blank"
>{{ appVersion }}</a
>
@ -119,7 +119,7 @@
>
<a
href="https://github.com/johannesjo/super-productivity/discussions/new"
href="https://github.com/super-productivity/super-productivity/discussions/new"
target="_blank"
>{{ T.PS.PROVIDE_FEEDBACK | translate }}</a
>

View file

@ -551,7 +551,7 @@ export class WebdavApi {
// We need to robustly handle various combinations of encoded/unencoded baseUrls and paths,
// especially for providers like Mailbox.org that include spaces in the user's path.
// We also want to avoid double-encoding if the path is already encoded.
// See: https://github.com/johannesjo/super-productivity/issues/5508
// See: https://github.com/super-productivity/super-productivity/issues/5508
let url: URL;
try {
url = new URL(baseUrl);

View file

@ -232,7 +232,7 @@
<div style="margin-top: 16px; text-align: center">
<a
href="https://github.com/johannesjo/super-productivity/blob/master/src/assets/community-plugins.json"
href="https://github.com/super-productivity/super-productivity/blob/master/src/assets/community-plugins.json"
target="_blank"
mat-button
color="primary"

View file

@ -1536,6 +1536,7 @@ const T = {
REMINDER_ADDED: 'F.TASK.S.REMINDER_ADDED',
REMINDER_DELETED: 'F.TASK.S.REMINDER_DELETED',
REMINDER_UPDATED: 'F.TASK.S.REMINDER_UPDATED',
TASK_ALREADY_EXISTS: 'F.TASK.S.TASK_ALREADY_EXISTS',
TASK_CREATED: 'F.TASK.S.TASK_CREATED',
},
SELECT_OR_CREATE: 'F.TASK.SELECT_OR_CREATE',
@ -1989,6 +1990,7 @@ const T = {
EN: 'GCF.LANG.EN',
ES: 'GCF.LANG.ES',
FA: 'GCF.LANG.FA',
FI: 'GCF.LANG.FI',
FR: 'GCF.LANG.FR',
HR: 'GCF.LANG.HR',
ID: 'GCF.LANG.ID',
@ -2003,6 +2005,7 @@ const T = {
PT_BR: 'GCF.LANG.PT_BR',
RU: 'GCF.LANG.RU',
SK: 'GCF.LANG.SK',
SV: 'GCF.LANG.SV',
TIME_LOCALE: 'GCF.LANG.TIME_LOCALE',
TIME_LOCALE_AUTO: 'GCF.LANG.TIME_LOCALE_AUTO',
TIME_LOCALE_DESCRIPTION: 'GCF.LANG.TIME_LOCALE_DESCRIPTION',

View file

@ -8,5 +8,10 @@
"name": "Archived Tasks Viewer",
"shortDescription": "Read-only viewer to browse archived tasks with grouping, filtering, subtasks preview, and theme toggle.",
"url": "https://github.com/baiyina/Archived-Tasks-Viewer"
},
{
"name": "QuestArc",
"shortDescription": "Turn your workflow into an adventure! Gamify your tasks with Atomic Habit quests, epic boss fights, earnable badges, and a customizable hero profile.",
"url": "https://codeberg.org/Nexumia/QuestArc"
}
]

View file

@ -1565,6 +1565,7 @@
"REMINDER_ADDED": "Scheduled <strong>{{title}}</strong> at <strong>{{date}}</strong>",
"REMINDER_DELETED": "Deleted reminder for task",
"REMINDER_UPDATED": "Updated reminder for task \"{{title}}\"",
"TASK_ALREADY_EXISTS": "Task <strong>{{title}}</strong> already exists",
"TASK_CREATED": "Created task \"{{taskTitle}}\""
},
"SELECT_OR_CREATE": "Select or create task",
@ -2011,6 +2012,7 @@
"EN": "English",
"ES": "Español",
"FA": "فارسی",
"FI": "Suomi",
"FR": "français",
"HR": "Hrvatski",
"ID": "Indonesian",
@ -2025,6 +2027,7 @@
"PT_BR": "Português (Brazil)",
"RU": "Русский",
"SK": "Slovak",
"SV": "Svenska",
"TIME_LOCALE": "Datetime format locale",
"TIME_LOCALE_AUTO": "System default",
"TIME_LOCALE_DE_DE": "German: 24-hour, DD.MM.YYYY",