mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
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:
commit
a42c8a4cee
68 changed files with 12901 additions and 10684 deletions
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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!
|
||||
|
|
|
|||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/manual-build.yml
vendored
2
.github/workflows/manual-build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/publish-to-hub-docker.yml
vendored
2
.github/workflows/publish-to-hub-docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
20392
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -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)**
|
||||
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -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.
|
||||
|
||||
[//]: # '[](https://fink.inlang.com/github.com/johannesjo/super-productivity?ref=badge)'
|
||||
[//]: # '[](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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
50
docs/TRANSLATING.md
Normal 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`)
|
||||
|
|
@ -10,12 +10,11 @@ For polling GitLab Issues, you need to provide an access token.
|
|||

|
||||
|
||||
## 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>.
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
307
src/app/features/issue/issue.service.spec.ts
Normal file
307
src/app/features/issue/issue.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue