Compare commits

..
Author SHA1 Message Date
rmcampos d6bebc6779 docs: update changelog with build and docker tags 2026-08-07 16:09:10 +02:00
rmcampos 1381e08273 chore: fix frontend setup buildx step and drop unused files 2026-08-07 16:02:30 +02:00
rmcampos a96a113f49 chore: fix ngrok setup to run locally
Frontend CD / build (push) Failing after 2m31s
2026-08-07 15:55:04 +02:00
rmcampos 923617bdff chore: bump all client deps to latest in minor target 2026-08-07 15:29:29 +02:00
rmcampos 5ef96002cc chore: bump all client deps to latest in patch target 2026-08-07 14:54:03 +02:00
rmcampos d08f611174 feat: add link to changelog from sidebar version 2026-08-07 14:52:23 +02:00
rmcampos 09462e9e84 docs: update changelog with docker images 2026-07-28 12:54:05 -03:00
rmcampos 4993b1e806 feat: add notes archival option before deleting (#20)
Frontend CD / build (push) Successful in 4m59s
Backend CD / build (push) Successful in 22m37s
# What

- Add archiving notes option
- Notes should be archived before deleting.

# Why

- Makes the flow more clear and follow the same pattern as other app.Reviewed-on: #20
2026-07-28 13:52:19 +00:00
rmcampos 7b45e2ccbd feat: add completed tasks and delete confirm (#19)
Backend CD / build (push) Successful in 4m32s
Frontend CD / build (push) Successful in 4m41s
# What

- Add confirm modal before deleting tasks and notes
- Add section with completed tasks

# Why

- That's because why.Reviewed-on: #19
2026-07-23 20:33:23 +00:00
rmcampos 1ebf89668d chore: small styling improvements
Frontend CD / build (push) Successful in 5m50s
2026-07-23 19:52:47 +02:00
rmcampos d7cd922b94 chore: add script to update local user when running in dev 2026-07-23 18:48:42 +02:00
rmcampos c9916a8475 fix: wrong styling in account page buttons 2026-07-23 18:48:21 +02:00
rmcampos 6803ae6ebc chore: bump minor deps to latest version
Frontend CD / build (push) Successful in 8m56s
2026-07-22 22:47:33 +02:00
renovate-botandrmcampos 231e091f5d chore(deps): update caddy docker tag to v2.11.4 (#9)
Frontend CD / build (push) Successful in 3m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| caddy | final | minor | `2.10.2-alpine` → `2.11.4-alpine` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Reviewed-on: #9
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-22 20:33:24 +00:00
rmcampos 749ddbcdad fix: ghcr references and old files (#18)
Frontend CD / build (push) Successful in 4m45s
# What

- Closes #17

# Why

- Keep the repo updated with needed files onlyReviewed-on: #18
2026-07-22 20:22:05 +00:00
renovate-botandrmcampos 294e1b7a6a chore(deps): update dependency com.puppycrawl.tools:checkstyle to v13.7.0 (#10)
Backend CD / build (push) Successful in 4m2s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [com.puppycrawl.tools:checkstyle](https://checkstyle.org/) ([source](https://github.com/checkstyle/checkstyle)) | `13.3.0` → `13.7.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.puppycrawl.tools:checkstyle/13.7.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.puppycrawl.tools:checkstyle/13.3.0/13.7.0?slim=true) |

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Reviewed-on: #10
Reviewed-by: Ricardo Campos <ricardompcampos@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-22 18:47:58 +00:00
rmcampos 58dabc75a6 docs: update changelog file 2026-07-22 12:02:27 -03:00
rmcampos dd15837d4d feat: add closing and saving button to modal. Closes #15 (#16)
Frontend CD / build (push) Successful in 5m7s
# What

- Closes #15

# Why

- Make users life easier.Reviewed-on: #16
2026-07-22 14:53:51 +00:00
rmcampos a79e79c04f fix: wrong image name 2026-07-22 11:16:28 -03:00
rmcampos 488586be48 chore: drop staging and improve to adopt doppler in CI
Backend CD / build (push) Successful in 7m59s
2026-07-22 11:02:14 -03:00
rmcampos 8901084fa9 chore: small clean up and updates 2026-07-22 10:10:12 -03:00
rmcampos 466e255594 docs: restructure changelog file (only the last change) 2026-07-22 10:06:19 -03:00
gitea-actions[bot] 300b2e0576 chore: bump api version to 35 [skip ci] 2026-07-20 16:41:31 +00:00
rmcampos 0250b558cf Group three changes in a single PR (#13)
Main CI-Frontend / Build & Push (push) Successful in 46s
Main CI-Backend / Build & Push (push) Successful in 54s
## What

- fix: background image position moving around in the landing, login and register pages.
- chore: bump minor dependencies versions to latest version.
- feat: replace "Due in 1 day" and "Due in 0 days" to Time Ago format: "Due tomorrow" and "Due today".

## Why

- Keeping the app improving and updated.
- Date picker in mobile works better using the default/native.

## Mood
<img width="200" src="https://media0.giphy.com/media/TIiDh0ONXotAQpkw3f/giphy.gif?cid=5a38a5a2cea0tfjgne8sdpz1vz617kfqk02hnjrrjx34v08x&amp;ep=v1_gifs_search&amp;rid=giphy.gif&amp;ct=g" style="border:0;height:300px;width:300px"/>

Reviewed-on: #13
2026-07-20 16:40:53 +00:00
gitea-actions[bot] 891c526363 chore: bump api version to 34 [skip ci] 2026-07-16 18:39:42 +00:00
rmcampos 642eee6de1 feat: update docker tag image to fixed version. (#12)
## What

- Fixes the docker builder image.
- Closes #5

## Why

- Prevent breaking with new versions

## Mood

<img width="200" src="https://media1.giphy.com/media/ZIH0Slo6J6PQY/giphy.gif?cid=5a38a5a2fxikxj29q5nul4d9k1kh6u4doey1mujpjesj30uf&amp;ep=v1_gifs_search&amp;rid=giphy.gif&amp;ct=g" style="border:0;height:168px;width:300px"/>

Reviewed-on: #12
2026-07-16 18:37:03 +00:00
renovate-botandrmcampos 4b04bfbbcf chore: Configure Renovate (#8)
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

📚 See our [Reading List](https://docs.renovatebot.com/reading-list/) for relevant documentation you may be interested in reading.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---
### Detected Package Files

 * `client/Dockerfile` (dockerfile)
 * `client/Dockerfile.dev` (dockerfile)
 * `server/pom.xml` (maven)
 * `client/package.json` (npm)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Show only the Age and Confidence Merge Confidence badges for pull requests.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.
  - Ensure that every dependency pinned by digest and sourced from Forgejo contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from Gitea contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitHub.com and Github enterprise contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitLab.com contains a link to the commit-to-commit diff
  - Correctly link to the source code for golang.org/x packages
  - Link to pkg.go.dev/... for golang.org/x packages' title
  - Provide a link to octochangelog's improved breakdown for Renovate's changelogs

---

### What to Expect

With your current configuration, Renovate will create 16 Pull Requests:

<details>
<summary>chore(deps): update dependency eslint-plugin-import-x to v4.17.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/eslint-plugin-import-x-4.x-lockfile`
  - Merge into: `main`
  - Upgrade [eslint-plugin-import-x](https://github.com/un-ts/eslint-plugin-import-x) to `4.17.1`

</details>

<details>
<summary>chore(deps): update dependency eslint-plugin-jsdoc to v63.0.11</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/eslint-plugin-jsdoc-63.x-lockfile`
  - Merge into: `main`
  - Upgrade [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc) to `63.0.11`

</details>

<details>
<summary>chore(deps): update dependency i18next to v26.3.4</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/i18next-26.x-lockfile`
  - Merge into: `main`
  - Upgrade [i18next](https://github.com/i18next/i18next) to `26.3.4`

</details>

<details>
<summary>chore(deps): update dependency typescript-eslint to v8.62.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/typescript-eslint-monorepo`
  - Merge into: `main`
  - Upgrade [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint) to `8.62.1`

</details>

<details>
<summary>chore(deps): update dependency vite to v8.1.3</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/vite-8.x-lockfile`
  - Merge into: `main`
  - Upgrade [vite](https://github.com/vitejs/vite) to `8.1.3`

</details>

<details>
<summary>chore(deps): update caddy docker tag to v2.11.4</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/caddy-2.x`
  - Merge into: `main`
  - Upgrade caddy to `2.11.4-alpine`

</details>

<details>
<summary>chore(deps): update dependency @&#8203;types/node to v26.1.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/node-26.x-lockfile`
  - Merge into: `main`
  - Upgrade [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped) to `26.1.0`

</details>

<details>
<summary>chore(deps): update dependency com.puppycrawl.tools:checkstyle to v13.7.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/com.puppycrawl.tools-checkstyle-13.x`
  - Merge into: `main`
  - Upgrade [com.puppycrawl.tools:checkstyle](https://github.com/checkstyle/checkstyle) to `13.7.0`

</details>

<details>
<summary>chore(deps): update dependency eslint-plugin-n to v18.2.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/eslint-plugin-n-18.x-lockfile`
  - Merge into: `main`
  - Upgrade [eslint-plugin-n](https://github.com/eslint-community/eslint-plugin-n) to `18.2.1`

</details>

<details>
<summary>chore(deps): update dependency prettier to v3.9.4</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/prettier-3.x-lockfile`
  - Merge into: `main`
  - Upgrade [prettier](https://github.com/prettier/prettier) to `3.9.4`

</details>

<details>
<summary>chore(deps): update dependency react-router to v8.1.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/react-router-monorepo`
  - Merge into: `main`
  - Upgrade [react-router](https://github.com/remix-run/react-router) to `8.1.0`

</details>

<details>
<summary>chore(deps): update node.js to v22.23.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/node-22.x`
  - Merge into: `main`
  - Upgrade [node](https://github.com/nodejs/node) to `22.23-bookworm-slim`

</details>

<details>
<summary>fix(deps): update jjwt.version to v0.13.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/jjwt.version`
  - Merge into: `main`
  - Upgrade [io.jsonwebtoken:jjwt-gson](https://github.com/jwtk/jjwt) to `0.13.0`
  - Upgrade [io.jsonwebtoken:jjwt-impl](https://github.com/jwtk/jjwt) to `0.13.0`
  - Upgrade [io.jsonwebtoken:jjwt-api](https://github.com/jwtk/jjwt) to `0.13.0`

</details>

<details>
<summary>fix(deps): update spring boot to v4.1.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/spring-boot`
  - Merge into: `main`
  - Upgrade [org.springframework.boot:spring-boot-maven-plugin](https://github.com/spring-projects/spring-boot) to `4.1.0`
  - Upgrade [org.springframework.boot:spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) to `4.1.0`

</details>

<details>
<summary>chore(deps): update eslint monorepo to v10</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/major-eslint-monorepo`
  - Merge into: `main`
  - Upgrade [@eslint/js](https://github.com/eslint/eslint) to `^10.0.0`
  - Upgrade [eslint](https://github.com/eslint/eslint) to `^10.0.0`

</details>

<details>
<summary>chore(deps): update node.js to v24</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/node-24.x`
  - Merge into: `main`
  - Upgrade [node](https://github.com/nodejs/node) to `24.18-bookworm-slim`

</details>

🚸 PR creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See [docs for `prHourlyLimit`](https://docs.renovatebot.com/configuration-options/#prhourlylimit) for details.

---

 Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:e80b4e42a3043bc12fa0640db4bac392d2bf770acf841360d7c8ceeeac2ec1a9-->

Reviewed-on: #8
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
Co-committed-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-05 21:18:24 +00:00
rmcampos c9c2e97d06 docs: update changelog and add docker images logging to workflows
Main CI-Backend / Build & Push (push) Has been cancelled
Main CI-Frontend / Build & Push (push) Has been cancelled
2026-07-01 12:22:17 -03:00
rmcampos acb08f7e14 feat: make notes keep open when suddenly closed (#7)
Main CI-Frontend / Build & Push (push) Successful in 39s
## What

- New feature allowing open notes to be restore, if the tab is closed

## Why

- Making users' life easier

## Mood
<img width="200" src="https://media3.giphy.com/media/S5uASkxZ5eGcwf2vHM/giphy.gif?cid=36b14facu3gasrwzy8axqb43wcuo1tsdjvjcslyk9t9jzvab&ep=v1_gifs_search&rid=giphy.gif&ct=g"/>

Reviewed-on: #7
2026-07-01 15:17:41 +00:00
rmcampos 4164b7dd97 hotfix: condition to apply changes in PR deployment 2026-07-01 11:23:26 -03:00
rmcamposandClaude Sonnet 4.6 0fc1cc3d65 fix: align frontend image tag with baked VITE_BUILD version
Main CI-Frontend / Build & Push (push) Successful in 40s
PR and candidate builds now push with the app-v* tag at build time,
ensuring VITE_BUILD inside the image always matches its Docker tag.
Promote step reads the version from the image instead of generating
a new run_number-based tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:36:22 -03:00
rmcampos 3b591ac85e hotfix: check changes condition to apply tf again 2026-06-30 21:13:39 -03:00
rmcampos b1cece9138 hotfix: check changes condition to apply tf 2026-06-30 21:10:52 -03:00
rmcampos 1ff3ba964c feat: add draft notes (#6)
Main CI-Frontend / Build & Push (push) Successful in 28s
## What

- Add draft feature for notes and tasks.
- Update deploy workflows to use a single job.

## Why

- Draft is needed when the session expires and the user is editing.
- A single job is needed to avoid exposing a tfplan file

## Mood
<img width="200" src="https://media4.giphy.com/media/BwP0jedpcSOyOyIK2h/giphy.gif?cid=36b14facttlvjhc0remdjlv65reqb1qh98wy78m23ua0avsj&ep=v1_gifs_search&rid=giphy.gif&ct=g"/>

Reviewed-on: #6
2026-07-01 00:05:46 +00:00
rmcampos eea8313f30 hotfix: flyway issue in prod 2026-06-29 23:15:31 +02:00
gitea-actions[bot] 419a33f7fc chore: bump api version to 33 [skip ci] 2026-06-29 19:24:13 +00:00
rmcampos 169f07801a Security/critical fixes (#4)
Main CI-Frontend / Build & Push (push) Successful in 39s
Main CI-Backend / Build & Push (push) Successful in 45s
## What

- Addressing security issues: critical and not so critical (more fixed will be pushed soon)

## Why

- The app needs to be secure and safe.

## Mood
<img width="200" src="https://media2.giphy.com/media/CSpfd57m9WGHnxMWXm/100.webp?cid=36b14facw100seggylsefdj0ap2oopoux3bn3jn6qu59xggq&ep=v1_gifs_search&rid=100.webp&ct=g"/>

Reviewed-on: #4
2026-06-29 19:23:41 +00:00
rmcampos a55a80e916 chore: bump frontend dependencies (#3)
Main CI-Frontend / Build & Push (push) Successful in 32s
## What

- Bump frontend dependencies;
- Backend dependencies are not ready yet. Only Spring has a new version, but it is failing to run on GraalVM;

## Why

- Codebase hygiene;

## Mood

<img width="200" src="https://media0.giphy.com/media/Pn5crrMQi6WyI5tyI0/giphy.gif?cid=36b14fackrjl2p912ox9mnnn7m0rawm1okm5d8hnaorntlgn&ep=v1_gifs_trending&rid=giphy.gif&ct=g"/>

Reviewed-on: #3
2026-06-25 00:16:35 +00:00
rmcampos 6272650ef9 revert 54eeb66f0d
revert Update README.md
2026-06-17 17:43:18 +00:00
rmcampos 54eeb66f0d Update README.md 2026-06-17 17:42:02 +00:00
rmcampos a9f2b7b3a4 docs: upadte readme 2026-06-17 12:15:45 -03:00
rmcampos d6a7ee34ef docs: update changelog 2026-06-15 19:07:26 -03:00
gitea-actions[bot] ce76f94237 chore: bump api version to 32 [skip ci] 2026-06-15 21:55:02 +00:00
rmcampos 68f8000ed8 fix: main workflow files
Main CI-Frontend / Build & Push (push) Successful in 29s
Main CI-Backend / Build & Push (push) Successful in 4m5s
2026-06-15 18:54:19 -03:00
gitea-actions[bot] 10c11e8f0b chore: bump api version to 31 [skip ci] 2026-06-15 21:41:06 +00:00
rmcampos 0a35bc0f77 fix: docker tag for backend main
Main CI-Backend / Build & Push (push) Failing after 44s
2026-06-15 18:40:26 -03:00
gitea-actions[bot] 31b2c9da8e chore: bump api version to 30 [skip ci] 2026-06-15 21:36:31 +00:00
rmcampos 17bcdd53bf fix: checkout code for pushing changes using pat
Main CI-Backend / Build & Push (push) Failing after 39s
2026-06-15 18:35:48 -03:00
rmcampos 10e0bba9fb chore: bump backend spring version and fix gitea files (#2)
Main CI-Frontend / Build & Push (push) Successful in 57s
Main CI-Backend / Build & Push (push) Failing after 36s
# What

- Bumps backend dependencies, including Spring Boot to v4.1.0

# Why

- Service hygiene.

Reviewed-on: #2
2026-06-15 21:16:14 +00:00
rmcampos b93caa1397 feat: update main workflow files wih right changes 2026-06-15 16:11:22 -03:00
github-actions[bot] 894aec1ff7 docs: promote [Unreleased] to app-v2026.06.08.22 [skip ci] 2026-06-08 21:57:44 +00:00
rmcamposandGitHub 93efe09407 chore: bump client dependencies (#64)
* chore: bump client dependencies

* docs: update changelog file

* docs: update changelog

* feat: craete separate changelog files
2026-06-08 18:57:05 -03:00
rmcampos a88a6788c6 feat: add script to set confirmation email locally 2026-06-08 17:47:14 -03:00
rmcampos ae24edfa2a chore: update docker files to use local user 2026-06-08 17:46:54 -03:00
rmcampos 01577a773a docs: update changelog with latest changes 2026-06-08 17:46:32 -03:00
rmcamposandGitHub 931e361836 feat: update about page to reflect current features (#63) 2026-06-08 15:35:43 -03:00
rmcampos db0cdeda91 docs: add changelog file 2026-06-08 13:38:04 -03:00
github-actions[bot] 4d9b3e2986 chore: bump api version to 29 [skip ci] 2026-06-01 14:42:27 +00:00
rmcamposandGitHub bceb9a2e49 chore: bump backend and frontend dependencies (#61)
* chore: bump frontend dependencies

* chore: bump backend dependencies
2026-06-01 11:41:53 -03:00
github-actions[bot] 39cbe80582 chore: bump api version to 28 [skip ci] 2026-05-26 14:58:43 +00:00
rmcamposandGitHub 5fec30fea0 feat: add SDD and DDD filer for AI based development and Spec 001 implementation (#60)
* feat: add initial files and spec 1

* feat: add spec for rich text feature

* chore: restore file from broken AI session

* feat: add project constraints to ai

* chore: update spec feature prompt

* feat: add multiple tags support - wip

* test: fix test cases

* chore: move tag list to below the inputs

* chore: add ngrok files and updates

* fix: session issues and query using HQL

* test: fix test cases
2026-05-26 11:58:04 -03:00
github-actions[bot] a35c037f20 chore: bump api version to 27 [skip ci] 2026-05-19 01:27:08 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
f34c359720 Refresh auth session with server-authoritative current user data (#59)
* Initial plan

* feat: refresh and reload current user profile

Agent-Logs-Url: https://github.com/RMCampos/tasknote/sessions/3f01367a-baee-4bbe-8f66-f04da58d485f

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* test: fix time zone test issue

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-18 22:26:31 -03:00
github-actions[bot] 1bb59dcbc5 chore: bump api version to 26 [skip ci] 2026-05-18 20:15:27 +00:00
rmcamposGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
a6bdd104d5 feat: add last activity date time (#57)
* feat: add last activity date time

* chore: update missing files

* chore: improve last seen message to inclute timezone

* chore: remove the formatting from backend

* chore: fix client issues

* fix: add utd to formatting in the client

* fix lint issue

* chore: fix lastLogin always showing the current login

* fix: address sidebar and sign-in review comments

Agent-Logs-Url: https://github.com/RMCampos/tasknote/sessions/04a13c25-87ea-44cc-ac53-cfefd16e0262

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-18 17:15:01 -03:00
github-actions[bot] 9bf0f400fe chore: bump api version to 25 [skip ci] 2026-05-17 20:54:25 +00:00
183 changed files with 6119 additions and 4218 deletions
-23
View File
@@ -1,23 +0,0 @@
---
name: client-updater
description: A bot that updates the client software to the latest version.
tools: [run_shell_command, read_file, write_file, replace, list_directory, glob, grep_search, ask_user]
---
# Instructions
1. Navigate to the `client` directory.
2. Execute the following command to check for updates:
`npx npm-check-updates --target minor`
3. Display the resulting list of available minor updates to the user.
4. If there are packages to update:
- Run `npx npm-check-updates --target minor -u` to update `package.json`.
- Run `npm install` to update the `package-lock.json` and install the new versions.
5. Run the validation script to ensure stability:
`../tools/check-frontend.sh`
6. If the validation passes:
- Create a new branch (e.g., `update-deps-[date]`).
- Commit the changes to `package.json` and `package-lock.json`.
- Push and create a Pull Request (using `gh pr create` if available).
7. If validation fails (lint, build, or test issues):
- Diagnose the failure using `grep_search` and `read_file`.
- Fix the issues, then retry the validation and PR steps.
8. Report the final status and the PR link to the user.
-12
View File
@@ -1,12 +0,0 @@
description = "Create a formatted GitHub issue"
prompt = """
Act as a project manager. Based on the following input: {{args}}
Create a GitHub issue using the `gh` CLI tool with this exact structure:
- Title: [Feat/Bug] <short summary>
- Body: A detailed description, technical requirements, and acceptance criteria.
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
Once the user confirms the plan, execute the command:
`gh issue create --title "[Type] Title" --body "..." --label "..."
"""
+17 -10
View File
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push-app:
name: Build & Push App
runs-on: ubuntu-latest
runs-on: easynode-debian
permissions:
contents: read
packages: write
@@ -19,20 +19,19 @@ jobs:
ref: ${{ github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
run: docker buildx inspect --bootstrap
- name: Log in to GitHub Container Registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/app
images: rmcampos/tasknote-app
tags: |
type=raw,value=candidate
@@ -49,9 +48,17 @@ jobs:
with:
context: ./client
push: true
tags: ${{ steps.meta.outputs.tags }}
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
build-args: |
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Log pushed images
run: |
echo "Pushed images:"
echo " rmcampos/tasknote-app:candidate"
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
+20 -28
View File
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push-server:
name: Build & Push Server
runs-on: ubuntu-latest
runs-on: graalvm-25
permissions:
contents: read
packages: write
@@ -18,42 +18,34 @@ jobs:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Set lowercase repo name
id: repo
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set up Java
uses: actions/setup-java@v4
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
cache-dependency-path: 'server/pom.xml'
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Log in to GHCR
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Buildpack layers
uses: actions/cache@v4
with:
path: ~/.cache/reproducible-builds
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
restore-keys: |
${{ runner.os }}-buildpack-
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build Docker image
working-directory: ./server
run: |
# Use the dynamic repo name to prevent tagging errors
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
- name: Tag and push candidate
run: |
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
docker tag rmcampos/tasknote-api:latest rmcampos/tasknote-api:candidate
docker push rmcampos/tasknote-api:candidate
- name: Log pushed images
run: |
echo "Pushed images:"
echo " rmcampos/tasknote-api:latest"
echo " rmcampos/tasknote-api:candidate"
+84
View File
@@ -0,0 +1,84 @@
name: Backend CD
on:
workflow_dispatch:
push:
branches: [ main ]
paths:
- 'server/**'
- '.github/workflows/cd-backend.yml'
jobs:
build:
runs-on: graalvm-25
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="api-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Build Docker image with Spring Boot
working-directory: ./server
run: |
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: rmcampos/tasknote-api
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Tag and push Docker image
run: |
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
docker push docker.io/rmcampos/tasknote-api:latest
docker push docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
- name: Output Docker image URLs
run: |
echo ""
echo "=========================================="
echo "🚀 Docker Images Published Successfully!"
echo "=========================================="
echo ""
echo "API Image:"
echo " rmcampos/tasknote-api:${{ steps.version.outputs.tag }}"
echo " rmcampos/tasknote-api:latest"
echo ""
echo "=========================================="
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
+98
View File
@@ -0,0 +1,98 @@
name: Frontend CD
on:
workflow_dispatch:
push:
branches: [ main ]
paths:
- 'client/**'
- '.github/workflows/frontend-cd.yml'
jobs:
build:
runs-on: easynode-debian
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Set up Docker Buildx
run: |
docker buildx create --name container-builder --driver docker-container --use --bootstrap || \
docker buildx use container-builder
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: rmcampos/tasknote-app
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./client
push: true
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
build-args: |
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Output Docker image URLs
run: |
echo ""
echo "=========================================="
echo "🚀 Docker Images Published Successfully!"
echo "=========================================="
echo ""
echo "App Image:"
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
echo " rmcampos/tasknote-app:latest"
echo ""
echo "=========================================="
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
-174
View File
@@ -1,174 +0,0 @@
name: Main CD-Deploy to Prod
on:
workflow_dispatch:
inputs:
backend_image:
description: "Backend image tag (full image reference)"
required: false
frontend_image:
description: "Frontend image tag (full image reference)"
required: false
apply:
description: "Apply changes after plan"
required: false
default: "true"
workflow_run:
workflows: [ "Main CI-Backend", "Main CI-Frontend" ]
types: [ completed ]
jobs:
terraform-plan:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
outputs:
no_changes: ${{ steps.check-changes.outputs.no_changes }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
kubectl cluster-info
kubectl get namespace tasknote
- name: Determine deployment values
id: deploy-vars
run: |
backend_image="${{ github.event.inputs.backend_image }}"
frontend_image="${{ github.event.inputs.frontend_image }}"
latest_backend_tag_tmp="$(git tag --list 'api-v*' | sort -V | tail -n1)"
latest_backend_tag="${latest_backend_tag_tmp#api-v}"
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
if [ -z "$latest_backend_tag" ]; then
echo "No backend tag found matching [0-9]*" >&2
exit 1
fi
if [ -z "$latest_frontend_tag" ]; then
echo "No frontend tag found matching app-v*" >&2
exit 1
fi
if [ -z "$backend_image" ]; then
backend_image="ghcr.io/rmcampos/tasknote/api:$latest_backend_tag"
fi
if [ -z "$frontend_image" ]; then
frontend_image="ghcr.io/rmcampos/tasknote/app:$latest_frontend_tag"
fi
echo "Resolved backend_image=$backend_image"
echo "Resolved frontend_image=$frontend_image"
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
- name: Terraform Fmt -check -diff
working-directory: terraform
run: terraform fmt -check -diff
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Validate
working-directory: terraform
run: terraform validate
- name: Terraform Plan
id: check-changes
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
timeout 1m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "no_changes=true" >> "$GITHUB_OUTPUT"
echo "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "no_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/tfplan
terraform-apply:
runs-on: ubuntu-latest
needs: terraform-plan
if: >
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
&& needs.terraform-plan.outputs.no_changes == 'false'
environment:
name: production
url: https://tasknote.darkroasted.vps-kinghost.net
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Apply
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: timeout 1m terraform apply tfplan
-142
View File
@@ -1,142 +0,0 @@
name: Pull Request CD-Deploy to Staging
on:
workflow_dispatch:
workflow_run:
workflows: [ "Pull Request CI-Backend", "Pull Request CI-Frontend" ]
types: [ completed ]
jobs:
terraform-plan-stg:
name: Plan changs to staging
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
outputs:
no_changes: ${{ steps.check-changes.outputs.no_changes }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Show Terraform provider versions
working-directory: terraform-stg
run: terraform version
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
kubectl cluster-info
kubectl get namespace tasknote-stg
- name: Determine deployment values
id: deploy-vars
run: |
backend_image="ghcr.io/rmcampos/tasknote/api:candidate"
frontend_image="ghcr.io/rmcampos/tasknote/app:candidate"
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
- name: Terraform Fmt -check -diff
working-directory: terraform-stg
run: terraform fmt -check -diff
- name: Terraform Init
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Validate
working-directory: terraform-stg
run: terraform validate
- name: Terraform Plan
id: check-changes
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
timeout 3m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}" \
-var="deploy_version=${{ github.run_id }}"
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "no_changes=true" >> "$GITHUB_OUTPUT"
echo "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "no_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform-stg/tfplan
terraform-apply:
runs-on: ubuntu-latest
needs: terraform-plan-stg
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
environment:
name: staging
url: https://tasknote-stg.darkroasted.vps-kinghost.net
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform-stg
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Terraform Init
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Apply
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
KUBE_CONFIG_PATH: ~/.kube/config
run: timeout 1m terraform apply tfplan
-101
View File
@@ -1,101 +0,0 @@
name: Main CI-Backend
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'server/**/*.java'
- 'server/**/*.xml'
- 'server/pom.xml'
- '.github/workflows/main-server.yml'
jobs:
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set lowercase repo name
id: repo
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
- name: Increment version in pom.xml
id: version
working-directory: ./server
run: |
# Extract current version from pom.xml
CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)
echo "Current version: ${CURRENT_VERSION}"
# Increment version
NEW_VERSION=$((CURRENT_VERSION + 1))
echo "New version: ${NEW_VERSION}"
# Update pom.xml with new version
./mvnw versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupFiles=false -q
# Output for later steps
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/pom.xml
git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]"
git push
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Find PR number
id: find_pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
PR_NUMBER="candidate"
else
PR_NUMBER="pr-${PR_NUMBER}"
fi
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
- name: Promote Docker image
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }} \
ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.find_pr.outputs.tag }}
- name: Create and push Git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}"
git push origin api-v${{ steps.version.outputs.version }}
-83
View File
@@ -1,83 +0,0 @@
name: Main CI-Frontend
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'client/**/*.html'
- 'client/**/*.png'
- 'client/**/*.json'
- 'client/**/*.txt'
- 'client/**/*.ts'
- 'client/**/*.tsx'
- 'client/**/*.js'
- 'client/Dockerfile'
- 'client/Caddyfile'
- '.github/workflows/main-client.yml'
jobs:
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set lowercase repo name
id: repo
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Find PR number
id: find_pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
PR_NUMBER="candidate"
else
PR_NUMBER="pr-${PR_NUMBER}"
fi
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
- name: Promote Docker image
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:latest \
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.version.outputs.tag }} \
ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.find_pr.outputs.tag }}
- name: Create and push Git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
-130
View File
@@ -1,130 +0,0 @@
name: Pull Request CI-Backend
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
branches:
- 'main'
paths:
- 'server/**/*.java'
- 'server/**/*.xml'
- 'server/pom.xml'
- 'server/**/*.yml'
- '.github/workflows/server-ci.yml'
jobs:
run-checks:
name: Checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
cache-dependency-path: 'server/pom.xml'
- name: Run Check Style
working-directory: ./server
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
- name: Run build
working-directory: ./server
run: ./mvnw --no-transfer-progress clean compile -DskipTests
- name: Run tests
working-directory: ./server
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
needs: ["run-checks"]
permissions:
contents: read
deployments: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set lowercase repo name
id: repo
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
cache-dependency-path: 'server/pom.xml'
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Buildpack layers
uses: actions/cache@v4
with:
path: |
~/.cache/reproducible-builds
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
restore-keys: |
${{ runner.os }}-buildpack-
- name: Build Docker image with Spring Boot
working-directory: ./server
run: |
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
- name: Tag and push Docker image
run: |
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
- name: Create GitHub deployment for staging
if: ${{ github.event_name == 'pull_request' }}
uses: actions/github-script@v6
with:
script: |
const ref = context.payload.pull_request.head.sha;
const env = 'staging';
const resp = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
required_contexts: [],
environment: env,
description: `PR #${context.payload.pull_request.number} preview deployment`,
transient_environment: true,
auto_merge: false
});
// create a deployment status pointing to the staging URL
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: resp.data.id,
state: 'success',
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
});
-134
View File
@@ -1,134 +0,0 @@
name: Pull Request CI-Frontend
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
branches:
- 'main'
paths:
- 'client/**/*.html'
- 'client/**/*.png'
- 'client/**/*.json'
- 'client/**/*.txt'
- 'client/**/*.ts'
- 'client/**/*.tsx'
- 'client/**/*.js'
- 'client/Dockerfile'
- 'client/Caddyfile'
- '.github/workflows/client-ci.yml'
jobs:
run-checks:
name: Checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run lint
run: npm run lint
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Run tests
run: npm run test:no-watch
working-directory: ./client
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
needs: ["run-checks"]
permissions:
contents: write
packages: write
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/app
tags: |
type=raw,value=candidate
type=raw,value=pr-${{ github.event.pull_request.number }}
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./client
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Create GitHub deployment for staging
if: ${{ github.event_name == 'pull_request' }}
uses: actions/github-script@v6
with:
script: |
const ref = context.payload.pull_request.head.sha;
const env = 'staging';
const resp = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
required_contexts: [],
environment: env,
description: `PR #${context.payload.pull_request.number} preview deployment`,
transient_environment: true,
auto_merge: false
});
// create a deployment status pointing to the staging URL
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: resp.data.id,
state: 'success',
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
});
+74
View File
@@ -0,0 +1,74 @@
name: CI
on:
pull_request:
branches: ['**/*']
jobs:
build-backend:
name: Build Backend
runs-on: graalvm-25
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Run Check Style
working-directory: ./server
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
- name: Run build
working-directory: ./server
run: ./mvnw --no-transfer-progress clean compile -DskipTests
- name: Run tests
working-directory: ./server
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
build-frontend:
name: Build Frontend
runs-on: easynode-debian
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run lint
run: npm run lint
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Run tests
run: npm run test:no-watch
working-directory: ./client
+134
View File
@@ -0,0 +1,134 @@
name: Deploy to Prod
concurrency:
group: deploy-production
cancel-in-progress: true
on:
workflow_dispatch:
inputs:
backend_image:
description: "Backend image tag (full image reference)"
required: false
frontend_image:
description: "Frontend image tag (full image reference)"
required: false
apply:
description: "Apply changes after plan"
required: false
default: "true"
workflow_run:
workflows: [ "Backend CD", "Frontend CD" ]
types: [ completed ]
jobs:
terraform-plan:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: easynode-debian
outputs:
has_changes: ${{ steps.check-changes.outputs.has_changes }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
doppler run --config prd -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
kubectl cluster-info
kubectl get namespace tasknote
- name: Determine deployment values
id: deploy-vars
run: |
backend_image="${{ github.event.inputs.backend_image }}"
frontend_image="${{ github.event.inputs.frontend_image }}"
latest_backend_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"
echo "latest backend tag=$latest_backend_tag"
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
echo "latest frontend tag=$latest_frontend_tag"
if [ -z "$backend_image" ]; then
backend_image="docker.io/rmcampos/tasknote-api:$latest_backend_tag"
fi
if [ -z "$frontend_image" ]; then
frontend_image="docker.io/rmcampos/tasknote-app:$latest_frontend_tag"
fi
echo "Resolved backend_image=$backend_image"
echo "Resolved frontend_image=$frontend_image"
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
- name: Terraform Fmt -check -diff
working-directory: terraform
run: terraform fmt -check -diff
- name: Terraform Init
working-directory: terraform
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd -- terraform init -input=false
- name: Terraform Validate
working-directory: terraform
run: terraform validate
- name: Terraform Plan
id: check-changes
working-directory: terraform
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
BACKEND_IMAGE: ${{ steps.deploy-vars.outputs.backend_image }}
FRONTEND_IMAGE: ${{ steps.deploy-vars.outputs.frontend_image }}
run: |
doppler run --config prd -- bash -c '
TF_VAR_db_user="$DB_USER" \
TF_VAR_db_password="$DB_PASSWORD" \
TF_VAR_db_name="$DB_NAME" \
TF_VAR_security_key="$SECURITY_KEY" \
TF_VAR_mailgun_apikey="$MAILGUN_APIKEY" \
TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID" \
TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY" \
TF_VAR_backend_image="$BACKEND_IMAGE" \
TF_VAR_frontend_image="$FRONTEND_IMAGE" \
timeout 1m terraform plan -input=false -out=tfplan
'
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Terraform Apply
working-directory: terraform
if: steps.check-changes.outputs.has_changes == 'true'
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd -- timeout 2m terraform apply tfplan
+4 -4
View File
@@ -25,10 +25,10 @@
- Frontend local dev: run from `client/` with `npm start`; backend local dev: run from `server/` with `./mvnw spring-boot:run`.
## CI/CD and release behavior
- PR workflows (`.github/workflows/ci-pr-frontend.yml`, `.github/workflows/ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
- Main workflows (`.github/workflows/ci-main-frontend.yml`, `.github/workflows/ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
- Staging deploy workflow (`.github/workflows/cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
- Production deploy workflow (`.github/workflows/cd-main.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
- PR workflows (`.github/workflows/drop-ci-pr-frontend.yml`, `.github/workflows/drop-ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
- Main workflows (`.github/workflows/drop-ci-main-frontend.yml`, `.github/workflows/drop-ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
- Staging deploy workflow (`.github/workflows/drop-cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
- Production deploy workflow (`.github/workflows/deploy.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
- Infra wiring (secrets, services, ingress, image vars) is defined in `terraform/main.tf`; an alternative GCP target is under `terraform-gcp/`.
## Project conventions to preserve
+325
View File
@@ -0,0 +1,325 @@
# Changelog
All notable changes to Tasknote will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## 2026-08-07
### Added
- Link to the build number to point to the changelog file. (build 201)
### Changed
- All deps to latest version in client for patch target. (build 201)
- All deps to latest version in client for minor target. (build 201)
- Development files for ngrok locally. (build 201)
### Fixed
- Buildx error in build phase in CI. (build 201)
### Removed
- Lingering files from previous CI/CD workflows. (build 201)
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.08.07.201
```
---
## 2026-07-28
### Added
- Option to archive notes.
### Changed
- Notes should be archived before deleting.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.28.195
docker pull rmcampos/tasknote-api:api-v2026.07.28.194
```
## 2026-07-23
### Added
- Section for completed tasks in the home page..
- Icons in tasks and notes to differentiate them.
- Modal confirming before delete tasks and notes.
### Changed
- Completed tasks are now kept in the database, unless deleted.
- Buttons in home screen notes view to match the system design.
- Add task form to be easier to see and better structured.
- Delete my account buttons layout to match the system design.
- Loaded tasks now has a light yellow styling.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.23.190
docker pull rmcampos/tasknote-api:api-v2026.07.23.189
```
### Fixed
- Dropped the untagged tag from loading in the add notes and tasks form.
## 2026-07-22
### Added
- Button to save notes from the preview modal. Closes [#15](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/15)
### Changed
- Removed deployments to staging in PR pipelines. PR only runs CI now. Closes [#14](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/14)
### Removed
- Old files from project and moved scripts to `tools` folder.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.22.161
docker pull rmcampos/tasknote-api:api-v2026.07.22.169
```
## 2026-07-20
### Changed
- Labels in tasks due date to use the time ago format.
- Bumped all minor deps in the frontend.
### Fixed
- Background image position in landing, login and register pages.
### Removed
- React Date Picker dependency in favor of regular browser input date UI.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.20.161
```
## 2026-07-01
### Added
- Support for `Draft` notes and tasks.
- Memory for open notes in the home page, if a tab is closed, the app will remember.
### Fixed
- Frontend app build version release getting lost in workflows.
### Security
- Addressed a list of critical security issues including validations, logging, and passwords.
### Docker images
- `rmcampos/tasknote-app:app-v2026.07.01.140`
### Changed
- Bumped client minor and major dependencies.
### Docker images
- `rmcampos/tasknote-app:app-v2026.06.25.102`
## api-v32 && app-v2026.06.15.97 - 2026-06-15
### Changed
- Bumped Spring Boot to 4.0.7
- CI/CD workflow files updated to run on Gitea.
- Container registry switched to Docker Hub.
### Docker images
- [rmcampos/tasknote-api:32](https://hub.docker.com/layers/rmcampos/tasknote-api/32/images/sha256-4b719a08dbed4a9d4a6eece0059573954ee5193ab8247787fb0e30c037f6b1c6)
- [rmcampos/tasknote-app:app-v2026.06.15.97](https://hub.docker.com/layers/rmcampos/tasknote-app/app-v2026.06.15.97/images/sha256-945a215a7105e34f97ab8e43094092e157156c0b557364260c019c4036cf845d)
## [app-v2026.06.08.22](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.22) - 2026-06-08
### Added
- Shell Script to confirm new users using docker and sql;
### Changed
- Bumped frontend dependencies to latest versions;
- `@types/node` from `25.9.1` to `25.9.2`
- `dompurify` from `3.4.7` to `3.4.8`
- `i18next` from `26.3.0` to `26.3.1`
- `react` from `19.2.6` to `19.2.7`
- `react-dom` from `19.2.6` to `19.2.7`
- `react-router` from `7.16.0` to `7.17.0`
- `@types/react` from `19.2.15` to `19.2.17`
- `eslint-plugin-n` from `18.0.1` to `18.1.0`
- `typescript-eslint` from `8.60.0` to `8.61.0`
- Ngrok and Dev Docker composer files to run using local users id and group id (`UID` and `GID`);
## [app-v2026.06.08.21](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.21) - 2026-06-08
### Changed
- The About page to list all current features and tech stack. ([#62](https://github.com/RMCampos/tasknote/issues/62))
## [app-v2026.06.01.20] - 2026-06-01
### Changed
- Bumped backend and frontend dependencies to latest versions. (#61)
## [app-v2026.05.26.19] - 2026-05-26
### Added
- SDD and DDD specification files for AI-assisted development, including Spec 001 implementation. (#60)
## [app-v2026.05.19.18] - 2026-05-19
### Changed
- Auth session refresh now uses server-authoritative current user data instead of stale client state. (#59)
## [app-v2026.05.18.17] - 2026-05-18
### Added
- Last activity date/time is now tracked and displayed for tasks and notes. (#57)
## [app-v2026.05.17.16] - 2026-05-17
### Added
- Users must confirm their email address before they can log in. (#56)
- Migrated app to new domain with Traefik redirect middleware. (#53)
### Fixed
- Mailgun authentication error (401) when sending emails. (#55)
- New domain correctly allowed in CORS and CSP configuration. (#54)
## [app-v2026.05.13.15] - 2026-05-13
### Changed
- Dropped refresh token logic; auth now relies solely on short-lived access tokens, simplifying the session flow. (#51)
## [app-v2026.05.13.14] - 2026-05-13
### Added
- Cypress E2E tests covering Home, Task, and Notes management flows. (#46)
- Cypress E2E tests for authentication flows. (#39)
- Scheduled database backups. (#48)
### Fixed
- Premature route resolution before initial auth check completes. (#45)
- Blocked inline styles in CSP via SHA-256 hash in `style-src`. (#43)
- Frontend test warnings and errors. (#37)
### Changed
- Upgraded Vite to v8. (#35)
## [app-v2026.04.29.4] - 2026-04-29
### Added
- Gemini CLI commands and updated AI agent configuration.
### Changed
- Upgraded TypeScript to v6. (#34)
## [app-v2026.04.29.3] - 2026-04-29
### Changed
- Updated client dependencies to latest minor versions. (#33, #32)
## [app-v2026.04.16.1] - 2026-04-16
### Added
- Dev environment config files and scripts to run the app locally on VPS. (#29)
### Security
- Improved security posture and added protections against XSS attacks. (#30)
## [app-v2026.04.08.20] - 2026-04-08
### Added
- GitHub Actions workflow for building server candidate images.
### Changed
- Bumped Spring Boot to 4.0.5. (#27)
## [app-v2026.04.06.19] - 2026-04-06
### Added
- Kubernetes CD pipeline via GitHub Actions.
- Dev container for Java development.
- Workflow skips deployment when no source changes detected.
## [app-v2026.03.17.17] - 2026-03-17
### Fixed
- Missing email templates and configuration for new domain.
## [app-v2026.03.16.16] - 2026-03-16
### Fixed
- Backend memory leak and state management issues. (#23)
- Error messages not propagating back to client in Spring v4.
### Changed
- Updated error messages and translations for improved user feedback.
## [app-v2026.02.28.15] - 2026-02-28
### Added
- Public note sharing: users can share a note via a public link. (#22)
## [app-v2026.02.27.13] - 2026-02-27
### Added
- Source and Copy buttons in the note markdown preview modal. (#21)
### Changed
- Upgraded backend to Spring Boot 4.0.3 and Java 25. (#20)
## [app-v2026.02.05.5] - 2026-02-05
### Changed
- Improved markdown rendering and added home filter context.
- Added notes tags support throughout the app. (#13)
- Tag suggestion dropdown for Notes and Tasks. (#12)
### Fixed
- Backend null pointer exception.
- Filters now persist between actions in the Home view. (#9)
## [app-v2026.01.18.4] - 2026-01-18
### Added
- CI now uses GHCR (GitHub Container Registry) with unified version tagging for backend images.
### Changed
- Upgraded Spring Boot to 3.5.9.
### Fixed
- Docker image name casing issue causing deployment failures.
## [app-v2026.01.14.3] - 2026-01-14
### Changed
- Dropped Lombok dependency; bumped to Spring 3.5.9.
- Updated backend dependencies to latest versions.
## [app-v2025.12.15.1] - 2025-12-15
### Added
- Initial release with core task and note management features.
- Bruno API collections for local development.
[app-v2026.06.01.20]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.26.19...app-v2026.06.01.20
[app-v2026.05.26.19]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.19.18...app-v2026.05.26.19
[app-v2026.05.19.18]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.18.17...app-v2026.05.19.18
[app-v2026.05.18.17]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.17.16...app-v2026.05.18.17
[app-v2026.05.17.16]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.13.15...app-v2026.05.17.16
[app-v2026.05.13.15]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.13.14...app-v2026.05.13.15
[app-v2026.05.13.14]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.29.4...app-v2026.05.13.14
[app-v2026.04.29.4]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.29.3...app-v2026.04.29.4
[app-v2026.04.29.3]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.16.1...app-v2026.04.29.3
[app-v2026.04.16.1]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.08.20...app-v2026.04.16.1
[app-v2026.04.08.20]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.06.19...app-v2026.04.08.20
[app-v2026.04.06.19]: https://github.com/RMCampos/tasknote/compare/app-v2026.03.17.17...app-v2026.04.06.19
[app-v2026.03.17.17]: https://github.com/RMCampos/tasknote/compare/app-v2026.03.16.16...app-v2026.03.17.17
[app-v2026.03.16.16]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.28.15...app-v2026.03.16.16
[app-v2026.02.28.15]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.27.13...app-v2026.02.28.15
[app-v2026.02.27.13]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.05.5...app-v2026.02.27.13
[app-v2026.02.05.5]: https://github.com/RMCampos/tasknote/compare/app-v2026.01.18.4...app-v2026.02.05.5
[app-v2026.01.18.4]: https://github.com/RMCampos/tasknote/compare/app-v2026.01.14.3...app-v2026.01.18.4
[app-v2026.01.14.3]: https://github.com/RMCampos/tasknote/compare/app-v2025.12.15.1...app-v2026.01.14.3
[app-v2025.12.15.1]: https://github.com/RMCampos/tasknote/releases/tag/app-v2025.12.15.1
+10 -4
View File
@@ -9,7 +9,7 @@ If you want to contribute, please create a fork and a Merge Request. Take a look
## Steps to Contribute
1. Fork the Project
2. Clone it on your local (`git clone https://github.com/ricardo-campos-org/react-typescript-todolist`)
2. Clone it on your local (`git clone https://lightroasted.vps-kinghost.net/rmcampos/tasknote.git`)
3. Develop your amazing feature/changes
4. Make sure your name is set (`git config user.name 'YOUR NAME'; git config user.email 'YOUR EMAIL'`)
5. Commit your changes (`git commit -m 'Add some amazing feature'`)
@@ -24,19 +24,25 @@ The easiest way of having the app up and running is using [Docker](https://www.d
1. Start the database engine (PostgreSQL)
```sh
bash tools/run-docker-db.sh
task dev-run-db
```
2. Start the back-end engine (Java & Spring Boot)
```sh
bash tools/run-docker-server.sh
task dev-run-api
```
3. Start the app server
```sh
bash tools/run-docker-client.sh
task dev-run-web
```
> Remember to follow up logs with 'docker ps' and 'docker logs -f <name>'
Or start all services at once with Docker Compose:
```sh
docker compose -f docker-compose.dev.yml up -d
```
If everything went well, you can head to [http://localhost:5000](http://localhost:5000) and create your user.
## 🦾 Automation
+31 -123
View File
@@ -1,10 +1,9 @@
# TaskNote
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![React App CI](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
[![Server API CI](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
[![Build and Push App Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
[![Build and Push API Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
[![Frontend CI](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/ci-main-frontend.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-frontend.yml)
[![Backend CI](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/ci-main-backend.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-backend.yml)
[![Deploy](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/cd-main.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=cd-main.yml)
## 📋 Table of Contents
@@ -102,113 +101,50 @@ tasknote/
## 🚀 Getting Started
### Prerequisites
- **Docker & Docker Compose** (recommended for easy setup)
- **Node.js 20+** and **npm** (for frontend development)
- **Java 25+** and **Maven 3.6+** (for backend development)
- **PostgreSQL 15+** (if running without Docker)
- [Docker](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/)
- [Task](https://taskfile.dev) (`brew install go-task` / `npm install -g @go-task/cli`)
- [Doppler CLI](https://docs.doppler.com/docs/install-cli) (`brew install dopplerhq/cli/doppler`)
### Quick Start with Docker
1. **Clone the repository**
```bash
git clone https://github.com/rmcampos/tasknote.git
cd tasknote
```
### Setup
2. **Start the database**
```bash
bash tools/run-docker-db.sh
```
3. **Start the backend server**
```bash
bash tools/run-docker-server.sh
```
4. **Start the frontend application**
```bash
bash tools/run-docker-client.sh
```
5. **Access the application**
- Frontend: http://localhost:5000
## 🛠️ Development
### Frontend Development
```bash
cd client
npm install # Install dependencies
npm start # Start development server (port 5000)
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
npm run lint:fix # Fix ESLint issues
# 1. Authenticate with Doppler and link the project
doppler login
doppler setup # uses doppler.yaml to link to the shell-whats project
```
### Backend Development
### Running locally
```bash
cd server
./mvnw spring-boot:run # Start development server
./mvnw clean compile # Compile sources
./mvnw spring-boot:build-image # Build Docker image
./mvnw clean verify -Pnative # Build GraalVM native image
task dev-run
```
### Quality Checks
Run quality checks before submitting changes:
This exports the public vars from the `dev_tokens` Doppler config and starts the server in watch mode with secrets injected from `dev_secrets`. No `.env` file needed.
## Building the Docker images
```bash
bash tools/check-frontend.sh # Frontend linting, testing, coverage
bash tools/check-backend.sh # Backend compilation, tests, checkstyle
# Build the backend
task docker-build-api
# Build the frontend
task docker-build-web
```
## 🧪 Testing
## 🧪 Testing & Checks
### Frontend Testing
- **Framework**: Vitest with React Testing Library
- **Coverage**: Comprehensive test coverage with reports in `client/coverage/`
- **Commands**:
```bash
npm test # Run tests in watch mode
npm run test:coverage # Generate coverage report
```
```bash
./tools/check-frontend.sh
```
### Backend Testing
- **Unit Tests**: Fast, isolated tests with mocked dependencies
- **Integration Tests**: Full application context with test database
- **Coverage**: JaCoCo reporting with 75% minimum requirement
- **Commands**:
```bash
./mvnw test # Unit tests only
./mvnw clean verify -Ptests # All tests with coverage
```
## 📦 Deployment
### Production Deployment
The application supports multiple deployment strategies:
1. **Docker Containers** (recommended)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
2. **Traditional JAR Deployment**
```bash
cd server && ./mvnw clean package
java -jar target/tasknote-api.jar
```
3. **GraalVM Native Image** (for optimal performance)
```bash
cd server && ./mvnw clean verify -Pnative
./target/tasknote-api
```
### Environment Configuration
- Database connection via environment variables
- JWT secret configuration for production
- Email service configuration for notifications
- CORS settings for frontend domain
```bash
./tools/check-backend.sh
```
## 🤝 Contributing
@@ -250,34 +186,6 @@ We welcome contributions from the community! This project follows the **Fork & M
For detailed setup instructions and development workflows, see [CONTRIBUTING.md](CONTRIBUTING.md).
## 👨‍💻 Developer
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
### About the Developer
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
- **Frontend Development**: React, TypeScript, modern CSS, responsive design
- **Backend Development**: Java, Spring Boot, RESTful APIs, microservices
- **DevOps & Infrastructure**: Docker, CI/CD, cloud deployment, monitoring
- **Software Quality**: Testing strategies, code coverage, static analysis
- **Open Source**: Community engagement, documentation, maintainership
The TaskNote project represents a commitment to clean code, comprehensive testing, and user-centered design principles.
## 📞 Contact
For questions, suggestions, or collaboration opportunities:
- **Email**: Contact via GitHub issues or discussions
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
## 📄 License
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
+2 -5
View File
@@ -2,17 +2,14 @@
version: '3'
vars:
GREETING: Hello, World!
tasks:
docker-build-web:
desc: Build the tasknote-web prod-ready docker image, tagging it as candidate
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t ghcr.io/rmcampos/tasknote/app:latest ./client
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t rmcampos/tasknote-app:latest ./client
docker-build-api:
desc: Build the tasknote-api prod-ready docker image, tagging it as candidate
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
prod-up-web:
desc: Speed up the tasknote-web prod-like image, building it if required
+30
View File
@@ -0,0 +1,30 @@
# Architecture Principles
## Core Principles
- Keep solutions simple and focused on the problem at hand.
- Prefer readability and maintainability over cleverness.
- Small functions and modules that do one thing well.
- Use clear and descriptive names for variables, functions, and classes.
- Avoid premature optimization; optimize only when necessary.
- Avoid over-engineering; build only what is needed for the current problem.
- Clear boundaries between components to promote separation of concerns.
- Prefer to maintainability over perfection
## Delivery Principles
Prefer:
- small specs
- iterative delivery
- vertical slices of functionality
- simple APIs
- clear documentation
- understandable code
Avoid:
- giant upfront architecture
- premature optimization
- speculative abstractions
- over-engineering
- unnecessary complexity
- unnecessary microservices
+17
View File
@@ -0,0 +1,17 @@
# Domain Glossary
This glossary provides definitions for key terms and concepts related to the domain of artificial intelligence (AI). It serves as a reference for understanding the terminology used in AI research, development, and applications.
Use this file to maintain shared terminology.
Consistent terminology significantly improves:
- communication
- AI consistency
- implement quality
## General Terms
- **User**: Final application consumer, typically a human, who interacts with the AI system.
- **Developer**: Individual or team responsible for designing, building, and maintaining the system.
- **Stakeholder**: Any party with an interest in the system, including users, developers, business owners, and regulators.
- **Spec**: Incremental implementation plan for a specific feature or functionality, often documented as a set of requirements and design decisions.
+31
View File
@@ -0,0 +1,31 @@
# Project constraints
## Backend
Always:
- Use the `SERIAL` type for `id` columns when creating migration SQL files;
- Define a table PRIMARY KEY in the end of the table columns using the table name plus `_pk` and the needed columns;
- Import needed packages one by one;
- Create JavaDoc for public classes and methods;
- Run `./mvnw -Ptests test` to check possible check style issues or failing tests;
- Fix existing test cases, if needed;
- Update docker-compose.yml and github workflow files if a new variable or application.yml has changed;
Never:
- Use `ON DELETE CASCADE` in SQL scripts or migration files;
- Use star to import packages;
## Frontend
Always:
- Type variables according with their data type;
- Create helper functions in the helper directory;
- Update Dockerfile, docker-compose.yml and github workflow files if a new environment variable was added;
Never:
- Use the `any` type;
- Add new dependencies for small helpers or util functions, prefer implementing them;
+44
View File
@@ -0,0 +1,44 @@
# Project Vision
## Project Name
TaskNote
## Problem Statement
In today's fast-paced world, individuals and teams often struggle to keep track of their tasks, deadlines, and project
progress. Traditional task management tools can be overwhelming and lack the flexibility needed to adapt to different
workflows. This leads to decreased productivity, missed deadlines, and increased stress.
As for notes, people often take notes in various formats and locations, such as notebooks, sticky notes, or digital
documents. This scattered approach can make it difficult to find and organize information, leading to inefficiency and
frustration.
## Users
The primary users of TaskNote are:
- **Individuals**: People who want to manage their personal tasks and notes efficiently.
- **Casual**: Users who need a simple and intuitive tool for managing their tasks and notes without the complexity of traditional task management software.
- **Developers**: Individuals who want to manage their coding tasks and notes in a streamlined manner.
## Core Features
- **Authentication**: Allow users to create accounts and securely log in to access their tasks and notes.
- **Task Management**: Create, organize, and prioritize tasks with deadlines and reminders.
- **Note-Taking**: Capture and organize notes in a flexible format, allowing for easy retrieval and organization.
- **Collaboration**: Enable users to share notes with others for better collaboration and teamwork.
- **Search and Organization**: Provide powerful search capabilities and organizational tools to help users find and manage their tasks and notes efficiently.
- **Multi-Language Support**: Support multiple languages to cater to a global user base.
## General Constraints
- **GraalVM Compatibility**: The application must be compatible with GraalVM to leverage its performance benefits and support for multiple languages.
- **Mobile-first UI**: The user interface should be designed with a mobile-first approach to ensure a seamless experience across devices.
## Success Criteria
- Users can manage their tasks and notes efficiently, leading to increased productivity and reduced stress.
- Positive user feedback and high engagement with the application.
- Specs remain under control, with a clear roadmap for future features and improvements.
- Codebase remains maintainable and scalable, allowing for easy addition of new features and improvements over time.
+34
View File
@@ -0,0 +1,34 @@
# Tech Stack
## Frontend
- **React**: A popular JavaScript library for building user interfaces, particularly single-page applications.
- **TypeScript**: A superset of JavaScript that adds static typing, improving code quality and maintainability.
- **Bootstrap**: A widely used CSS framework for building responsive and mobile-first websites.
- **Fetch API**: A modern interface for making HTTP requests from the browser, used for communicating with the backend.
- **Vite**: A build tool that provides a fast development environment and optimized production builds for modern web applications.
## Backend
- **Java**: A widely used programming language known for its portability, performance, and extensive ecosystem.
- **Spring Boot**: A framework for building production-ready applications with Java, providing features like dependency injection, security, and data access.
- **Spring Security**: A framework for securing Java applications, providing authentication and authorization features to protect resources and manage user access.
- **GraalVM**: A high-performance runtime that supports multiple programming languages, allowing for efficient execution of Java applications and interoperability with other languages.
- **JPA (Java Persistence API)**: A specification for managing relational data in Java applications, providing a standard way to interact with databases using object-relational mapping (ORM).
## Database
- **PostgreSQL**: A powerful, open-source relational database management system known for its reliability and performance.
## Infrastructure
- **Docker**: A platform for developing, shipping, and running applications in containers, providing consistency across different environments.
- **Kubernetes**: An open-source container orchestration system for automating the deployment, scaling, and management of containerized applications.
- **Terraform**: An infrastructure as code tool that allows for the provisioning and management of cloud resources in a declarative manner.
- **VPS**: Virtual Private Server, a virtualized server that provides dedicated resources and control over the hosting environment for deploying applications.
## AI Tools
- **Crusher**: An AI tool for code generation and assistance, helping developers write code more efficiently and accurately.
- **GitHub Copilot**: An AI-powered code completion tool that provides suggestions and helps developers write code faster by leveraging machine learning models trained on a vast amount of code from GitHub repositories.
- **Gemini**: An AI tool for natural language processing and understanding, enabling developers to build applications that can process and generate human-like text.
- **Groq**: An AI tool for optimizing and accelerating machine learning models, providing efficient execution and improved performance for AI applications.
@@ -0,0 +1,50 @@
# Feedback Contract
This contract is used for:
- reviews
- implementation feedback
- architecture feedback
- iterative improvement
---
## 1. What Worked Well
Highlight:
- Good implementation choices
- Maintainability improvements
- Strong architectural decisions
## 2. Areas For Improvement
Identify:
- Unclear implementation
- Maintainability concerns
- Architectural inconsistencies
## 3. Risks
Surface:
- Technical debt
- Scalability concerns
- Workflow fragility
## 4. Recommendations
Provide:
- Practical improvements
- Simplification opportunities
- Next iteration suggestions
## Philosophy
Feedback should:
- Improve clarity
- Support maintainability
- Remain constructive
- Avoid unnecessary perfectionism
@@ -0,0 +1,68 @@
# Handoff Contract
This contract is used whenever work moves between roles.
Example:
- PM → Architect
- Architect → Developer
- Developer → Reviewer
---
## 1. Summary
Concise explanation of:
- Completed work
- Important decisions
- Implementation status
## 2. Completed Work
Explicitly list:
- Implemented features
- Completed tasks
- Validated decisions
## 3. Pending Work
List:
- Unfinished work
- Blockers
- Remaining implementation
## 4. Important Decisions
Document:
- Tradeoffs
- Architectural decisions
- Assumptions
- Simplifications
## 5. Risks
Identify:
- Technical concerns
- Unclear requirements
- Scalability limitations
- Possible regressions
## 6. Questions
List:
- Unresolved ambiguity
- Missing requirements
- Pending decisions
## 7. Recommended Next Step
Clearly explain:
- What should happen next
- Which role should act next
- What should be prioritized
@@ -0,0 +1,58 @@
# Question Contract
## Description
This contract is used when:
- Requirements are not clear
- Assumptions must be validated
- More information is needed to proceed
- Implementation direction is uncertain
## 1. Context
Explain:
- The background of the problem or task
- Why it is important to clarify the requirements or assumptions
- Related specs or documentation
- Implementation area
## 2. Questions
Clearly state:
- The specific questions that need to be answered
- What is unclear
- What assumptions are being made
- What decision needs to be made
## 3. Why It Matters
Explain:
- The potential impact of not clarifying the questions
- How it affects the implementation
- Architectural implications
- Performance implications
- User experience implications
- Workflow consequences
## 4. Suggested Options
Provide:
- Possible answers to the questions
- Pros and cons of each option
- Any relevant data or evidence to support the options
## 5. Philosophy
Good questions reduce:
- Hallucinations
- Unnecessary work
- Rework
- Implementation drift
- Misalignment with requirements
- Hidden assumptions
+56
View File
@@ -0,0 +1,56 @@
# Context Policy
AI systems perform significantly better when context is:
- structured
- concise
- consistent
- relevant
## Core Rules
### 1. Read Context Before Acting
Before:
- implementing features
- generating specs
- reviewing code
- proposing architecture
Always review:
- project vision
- architecture principles
- glossary
- relevant specs
---
### 2. Respect Shared Terminology
Use terminology consistently.
The glossary exists to:
- reduce ambiguity
- improve communication
- improve AI consistency
---
### 3. Avoid Context Overload
More context is NOT always better.
Prefer:
- focused context
- relevant files
- small scoped discussions
---
### 4. Surface Missing Context
If important context is missing:
- ask questions
- document assumptions
- identify ambiguity
Avoid silently inventing requirements.
+39
View File
@@ -0,0 +1,39 @@
# Handoff Rules
Every handoff between roles should include:
- completed work
- pending work
- risks
- important decisions
- open questions
- recommended next step
---
## Goals
Handoffs exist to:
- preserve continuity
- reduce ambiguity
- improve collaboration
- support incremental delivery
---
## Good Handoffs
Good handoffs are:
- concise
- explicit
- actionable
- context-aware
Avoid vague summaries.
---
## Important
Hidden assumptions are one of the biggest causes
of AI inconsistency and implementation drift.
+40
View File
@@ -0,0 +1,40 @@
# Orchestrator
The orchestrator coordinates:
- context
- specs
- architecture
- implementation
- review workflows
## Primary Goals
- maintain incremental progress
- reduce ambiguity
- preserve consistency
- support maintainability
---
## Orchestration Responsibilities
- identify next executable spec
- validate dependencies
- coordinate role transitions
- ensure contracts are respected
- maintain delivery momentum
---
## Workflow Philosophy
Prefer:
- small focused steps
- explicit transitions
- structured handoffs
- iterative delivery
Avoid:
- giant implementation phases
- uncontrolled context growth
- hidden assumptions
+71
View File
@@ -0,0 +1,71 @@
# AI Workflow
This repository follows a lightweight AI-native engineering workflow.
```text
Context → Specs → Architecture → Implementation → Review → Iteration
```
---
# 1. Context Definition
Goal: create shared understanding.
Typical artifacts:
- Project vision
- Glossary
- Personas
- Architecture principles
- Tech stack
Context quality directly impacts AI output quality.
# 2. Spec Generation
Goal: break work into small incremental deliverables.
Good specs are:
- Small
- Focused
- Testable
- Independently understandable
# 3. Architecture Validation
Goal: ensure sustainable technical direction.
Architect responsibilities:
- Validate boundaries
- Identify risks
- Reduce complexity
- Support maintainability
# 4. Implementation
Goal: deliver working software incrementally.
Developer responsibilities:
- Implement clearly
- Respect architecture
- Preserve maintainability
# 5. Testing & Review
Goal: validate correctness and maintainability.
Focus on:
- Spec completion
- Workflow correctness
- Architectural consistency
# 6. Iteration
Goal: continuously improve:
- Specs
- Workflows
- Context
- Implementation
AI-native engineering is iterative by nature.
+53
View File
@@ -0,0 +1,53 @@
# Role: Architect
You are responsible for maintaining technical clarity,
system boundaries, and implementation simplicity.
## Primary Responsibilities
- Define system boundaries
- Validate architecture decisions
- Reduce unnecessary complexity
- Identify technical risks
- Support maintainability
- Guide incremental delivery
---
## Prioritize
- clarity
- maintainability
- simplicity
- developer experience
- incremental progress
---
## Avoid
- overengineering
- speculative abstractions
- premature optimization
- architecture astronautics
---
## Expected Outputs
- architecture notes
- API guidance
- data flow recommendations
- technical decisions
- risk analysis
---
## Collaboration
Work closely with:
- PM for requirement clarification
- Developers for implementation guidance
- Reviewers for consistency validation
Your role is guidance and structure, not implementation ownership.
+49
View File
@@ -0,0 +1,49 @@
# Role: Developer
You are responsible for implementing specs incrementally,
clearly, and maintainably.
## Primary Responsibilities
- Implement specs
- Respect architecture boundaries
- Keep code maintainable
- Surface blockers early
- Preserve readability
---
## Engineering Philosophy
Prefer:
- explicit logic
- composable systems
- simple implementations
- incremental delivery
Avoid:
- unnecessary abstractions
- giant refactors
- hidden side effects
- premature optimization
---
## Before Implementation
Always review:
- project context
- architecture principles
- current spec
- related workflows
---
## Expected Outputs
- implementation code
- tests
- documentation
- migrations
- APIs
- technical notes
+40
View File
@@ -0,0 +1,40 @@
# Role: Product Manager
You are responsible for:
- reducing ambiguity
- clarifying requirements
- prioritizing delivery
- maintaining user focus
## Responsibilities
- refine specs
- clarify user value
- define acceptance criteria
- reduce scope ambiguity
- support incremental delivery
---
## Product Philosophy
Prefer:
- small focused specs
- clear workflows
- simple user journeys
- fast iteration
Avoid:
- oversized features
- vague requirements
- unnecessary complexity
---
## Success Criteria
A good spec should be:
- understandable
- implementable
- testable
- incremental
+36
View File
@@ -0,0 +1,36 @@
# Role: Reviewer
You are responsible for validating:
- quality
- maintainability
- spec completion
- architectural consistency
## Responsibilities
- Verify acceptance criteria
- Validate readability
- Identify technical debt
- Surface risks
- Recommend improvements
---
## Review Philosophy
Focus on:
- practical maintainability
- implementation clarity
- architecture consistency
Avoid:
- perfectionism
- unnecessary nitpicks
- overcomplicated suggestions
---
## Important
The goal is sustainable delivery,
not theoretical perfection.
+36
View File
@@ -0,0 +1,36 @@
# Role: Tester
You are responsible for validating:
- expected behavior
- acceptance criteria
- workflow correctness
- critical edge cases
## Responsibilities
- validate specs
- test workflows
- identify regressions
- surface inconsistencies
---
## Testing Philosophy
Focus on:
- critical workflows
- realistic scenarios
- maintainability
- clarity
Avoid:
- unnecessary exhaustive testing
- unrealistic edge cases
- overcomplicated testing strategies
---
## Important
The goal is confidence and reliability,
not perfect theoretical coverage.
+14
View File
@@ -0,0 +1,14 @@
# Skill: Create Handoff Contract
Good handoffs preserve:
- continuity
- implementation context
- architectural decisions
Include:
- summary
- completed work
- pending work
- risks
- questions
- recommended next step
+14
View File
@@ -0,0 +1,14 @@
# Skill: Review Code Quality
## Focus Areas
- readability
- maintainability
- consistency
- architectural alignment
## Avoid
- unnecessary nitpicks
- perfectionism
- speculative suggestions
+19
View File
@@ -0,0 +1,19 @@
# Skill: Write Spec
## Goal
Create small incremental implementation specs.
## Good Specs
Good specs:
- have clear goals
- define acceptance criteria
- remain focused
- avoid ambiguity
## Avoid
- giant specs
- vague requirements
- mixing unrelated concerns
+14
View File
@@ -0,0 +1,14 @@
# Skill: Write Tests
## Prioritize
- critical workflows
- expected behavior
- important edge cases
## Philosophy
Testing should improve:
- confidence
- reliability
- maintainability
+58
View File
@@ -0,0 +1,58 @@
# Spec: Support Multiple Tags for Tasks and Notes
## Goal
Enable users to associate multiple tags with both tasks and notes, enhancing organization and search capabilities.
---
## User Value
Users can categorize their tasks and notes more granularly, making it easier to find and filter information. This improves overall productivity and reduces the time spent searching for specific items.
---
## Requirements
- Users must be able to add multiple tags to a single task.
- Users must be able to add multiple tags to a single note.
- The system should support adding new tags and selecting existing tags.
- Display of multiple tags associated with tasks and notes in the UI.
- Backend API endpoints must be updated to support multiple tags for tasks and notes.
- Database schema must be updated to store multiple tags for tasks and notes.
---
## Acceptance Criteria
- [ ] When editing a task, a user can add more than one tag.
- [ ] When editing a note, a user can add more than one tag.
- [ ] All tags associated with a task are displayed when viewing the task.
- [ ] All tags associated with a note are displayed when viewing the note.
- [ ] Users can filter tasks by multiple tags.
- [ ] Users can filter notes by multiple tags.
- [ ] The API for tasks and notes allows for creation and update with multiple tags.
- [ ] The database correctly stores and retrieves multiple tags for tasks and notes.
---
## Dependencies
None.
---
## Risks
- **Data Migration**: Existing single-tag data will need to be migrated to the new multi-tag schema, potentially requiring a database migration script.
- **Performance Impact**: Storing and querying multiple tags might introduce performance overhead, especially for large datasets. This will need careful indexing and optimization.
- **UI Complexity**: Designing a user-friendly interface for managing multiple tags could add complexity to the frontend.
- **API backward compatibility**: Changes to the API might break existing clients if not handled carefully with versioning or clear migration paths.
---
## Notes
- Consider a many-to-many relationship between tasks/notes and tags in the database.
- Frontend tag input should allow for auto-completion of existing tags.
- The API should handle tag creation on the fly if a new tag is submitted.
@@ -0,0 +1,36 @@
# Spec 002: Markdown Formatting Toolbar for Notes
## Goal
Enhance the Note editing experience by adding a Markdown formatting toolbar to the note creation and editing interface. This provides a "rich text" editing capability while preserving the existing Markdown-based storage and rendering system.
## User Value
Users who are unfamiliar with Markdown syntax can easily format their notes (bold, italic, lists, etc.) using familiar UI controls. This reduces the cognitive load and makes the application more accessible to a broader audience without losing the power of Markdown for advanced users.
## Requirements
- **Toolbar Placement**: A horizontal toolbar should be placed immediately above the "Content" textarea in the Note creation/editing form.
- **Formatting Actions**: The toolbar must include buttons for the following actions:
- **Bold**: Wraps selection with `**`.
- **Italic**: Wraps selection with `_`.
- **Heading**: Prepends `### ` to the current line or selection.
- **Bullet List**: Prepends `- ` to the current line or selection.
- **Link**: Inserts a Markdown link template `[text](url)`.
- **Interaction Logic**:
- If text is selected, clicking a button should wrap/prefix the selection.
- If no text is selected, clicking a button should insert the Markdown symbols at the cursor position.
- The textarea should regain focus immediately after a toolbar button is clicked.
- **Styling**: The toolbar should use standard Bootstrap components (e.g., `ButtonGroup`) and icons (e.g., `react-bootstrap-icons`) to match the existing project aesthetic.
## Acceptance Criteria
- [ ] The toolbar is visible in the `NoteAdd` view for both adding a new note and editing an existing one.
- [ ] Clicking the **Bold** button wraps the selected text in the textarea with `**`.
- [ ] Clicking the **Italic** button wraps the selected text with `_`.
- [ ] Clicking the **Heading** button adds `### ` at the cursor or selection start.
- [ ] Clicking the **Bullet List** button adds `- ` at the start of the line.
- [ ] Clicking the **Link** button inserts `[](url)` or wraps selection as `[selection](url)`.
- [ ] The note can be saved successfully with the newly formatted content.
- [ ] The "Preview Markdown" modal correctly renders the formatted content.
## Risks
- **Cursor Management**: Maintaining or restoring cursor position/selection after formatting might be technically challenging in a standard HTML `textarea`.
- **Mobile UX**: A long toolbar might overflow on small screens, requiring careful responsive design (e.g., horizontal scrolling or wrapping).
- **Undo/Redo**: Standard browser undo/redo might behave unexpectedly if the textarea value is manipulated programmatically.
+33
View File
@@ -0,0 +1,33 @@
# Specs
Specs are the primary implementation units in this repository.
## Good Specs
Good specs are:
- small
- incremental
- independently understandable
- testable
- focused on one responsibility
---
## Recommended Structure
Each spec should contain:
- spec.md
- architecture.md
- tasks.md
- optional contracts/
---
## Recommended Size
Prefer:
- 3 to 8 specs maximum for dojo projects
Avoid:
- giant umbrella specs
- multi-week implementation specs
+43
View File
@@ -0,0 +1,43 @@
# Spec: <name>
## Goal
What are we building?
---
## User Value
Why does this matter?
---
## Requirements
- Requirement 1
- Requirement 2
---
## Acceptance Criteria
- [ ] Criteria 1
- [ ] Criteria 2
---
## Dependencies
List required previous specs.
---
## Risks
Identify possible implementation risks.
---
## Notes
Additional implementation guidance.
+16
View File
@@ -0,0 +1,16 @@
# Orchestration Prompt
Using the orchestrator workflow defined at `ai/orchestration/orchestrator.md`,
execute the first spec file, 001-add-multiple-tags.md.
Respect:
- architecture principles
- role responsibilities
- context policies
- handoff rules
Keep implementation:
- simple
- maintainable
- incremental
- production-like
+22
View File
@@ -0,0 +1,22 @@
# Spec for a feature prompt example
Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for this
feature: <brief description of the feature>.
Requirements:
- small scope
- independently implementable
- clear acceptance criteria
- incremental delivery
- strictly follow all constraints in `ai/context/project_constraints.md` during implementation
The spec file should include:
- goal
- user value
- requirements
- acceptance criteria
- risks
Name the spec file as `000-short-descriptive-name.md` similar to git branch naming convention, starting with 001, 002,
003, and so on, and place it in the appropriate directory under `ai/specs/`.
+18
View File
@@ -0,0 +1,18 @@
# Spec Generation Prompt
Based on the project context,
generate 3 to 8 small incremental specs.
Requirements:
- small scope
- independently implementable
- clear acceptance criteria
- incremental delivery
- avoid overengineering
Each spec should include:
- goal
- user value
- requirements
- acceptance criteria
- risks
+2 -2
View File
@@ -14,7 +14,7 @@ RUN npm i --ignore-scripts --no-update-notifier --omit=dev && \
# Deploy container
# Caddy serves static files
FROM caddy:2.10.2-alpine
FROM caddy:2.11.4-alpine
RUN apk add --no-cache ca-certificates curl
# Receive build number as argument, retain as environment variable
@@ -29,7 +29,7 @@ LABEL org.opencontainers.image.authors="Ricardo Campos <ricardompcampos@gmail.co
org.opencontainers.image.title="TaskNoteApp client" \
org.opencontainers.image.description="React Web app application" \
org.opencontainers.image.version="${SOURCE_PR}" \
org.opencontainers.image.source="https://github.com/ricardo-campos-org/react-typescript-todolist"
org.opencontainers.image.source="https://lightroasted.vps-kinghost.net/rmcampos/tasknote"
# Copy files and run formatting
COPY --from=build /app/dist/ /app/dist
+9 -4
View File
@@ -21,7 +21,7 @@ const mockTasks = [
dueDate: '2026-06-01',
dueDateFmt: 'Jun 1, 2026',
lastUpdate: '2026-05-01',
tag: 'personal',
tags: ['personal'],
urls: []
},
{
@@ -32,7 +32,7 @@ const mockTasks = [
dueDate: '',
dueDateFmt: '',
lastUpdate: '2026-05-02',
tag: 'work',
tags: ['work'],
urls: []
}
];
@@ -43,7 +43,7 @@ const mockNotes = [
title: 'Meeting notes',
description: 'Discuss quarterly goals',
url: null,
tag: 'work',
tags: ['work'],
lastUpdate: '2026-05-01',
shared: false,
shareToken: null
@@ -53,7 +53,7 @@ const mockNotes = [
title: 'Recipe',
description: 'Pasta carbonara recipe',
url: null,
tag: 'personal',
tags: ['personal'],
lastUpdate: '2026-05-02',
shared: false,
shareToken: null
@@ -69,6 +69,11 @@ describe('Home Management', () => {
body: { token: 'fake-jwt-token', ...mockUser }
}).as('refreshToken');
cy.intercept('GET', /\/rest\/users\/me/, {
statusCode: 200,
body: mockUser
}).as('getCurrentUser');
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
statusCode: 200,
body: mockTags
+7 -2
View File
@@ -17,7 +17,7 @@ const mockNote = {
title: 'Meeting notes',
description: 'Discuss quarterly goals',
url: null,
tag: 'work',
tags: ['work'],
lastUpdate: '2026-05-01',
shared: false,
shareToken: null
@@ -35,6 +35,11 @@ describe('Notes Management', () => {
body: { token: 'fake-jwt-token', ...mockUser }
}).as('refreshToken');
cy.intercept('GET', /\/rest\/users\/me/, {
statusCode: 200,
body: mockUser
}).as('getCurrentUser');
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
statusCode: 200,
body: ['work', 'personal']
@@ -84,7 +89,7 @@ describe('Notes Management', () => {
title: 'New note',
description: 'Some content',
url: null,
tag: '',
tags: [],
lastUpdate: '',
shared: false,
shareToken: null
+7 -2
View File
@@ -20,7 +20,7 @@ const mockTask = {
dueDate: '',
dueDateFmt: '',
lastUpdate: '2026-05-01',
tag: 'personal',
tags: ['personal'],
urls: []
};
@@ -36,6 +36,11 @@ describe('Task Management', () => {
body: { token: 'fake-jwt-token', ...mockUser }
}).as('refreshToken');
cy.intercept('GET', /\/rest\/users\/me/, {
statusCode: 200,
body: mockUser
}).as('getCurrentUser');
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
statusCode: 200,
body: ['personal', 'work']
@@ -82,7 +87,7 @@ describe('Task Management', () => {
dueDate: '',
dueDateFmt: '',
lastUpdate: '',
tag: '',
tags: [],
urls: []
}
}).as('createTask');
+1395 -1452
View File
File diff suppressed because it is too large Load Diff
+27 -28
View File
@@ -9,29 +9,28 @@
"node",
"nestjs"
],
"repository": "https://github.com/ricardo-campos-org/react-typescript-todolist",
"repository": "https://lightroasted.vps-kinghost.net/rmcampos/tasknote",
"private": true,
"dependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^25.7.0",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@types/node": "^26.1.2",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"bootstrap": "^5.3.8",
"dompurify": "^3.4.2",
"i18next": "^26.1.0",
"react": "^19.2.6",
"dompurify": "^3.4.13",
"i18next": "^26.3.6",
"react": "^19.2.8",
"react-bootstrap": "^2.10.10",
"react-bootstrap-icons": "^1.11.6",
"react-charts": "^3.0.0-beta.57",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router": "^8.3.0",
"react-router-bootstrap": "^0.26.3",
"remark-gfm": "^4.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.12"
"vite": "^8.2.1"
},
"scripts": {
"start": "vite --host",
@@ -66,30 +65,30 @@
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "^9.39.5",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.3",
"@types/jest": "^30.0.0",
"@types/react": "^19.2.14",
"@types/react": "^19.2.18",
"@types/react-router-bootstrap": "^0.26.8",
"@vitest/coverage-v8": "^4.1.6",
"cypress": "^15.14.2",
"eslint": "^9.39.4",
"@vitest/coverage-v8": "^4.1.10",
"cypress": "^15.20.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-jsdoc": "^62.9.0",
"eslint-plugin-n": "^18.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-jsdoc": "^63.3.3",
"eslint-plugin-n": "^18.2.2",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.6.0",
"globals": "^17.9.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"prettier": "^3.9.6",
"sass": "^1.102.0",
"source-map-support": "^0.5.21",
"typescript-eslint": "^8.59.3",
"vitest": "^4.1.6"
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.10"
}
}
-21
View File
@@ -1,21 +0,0 @@
# Must be unique in a given SonarQube instance
sonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
sonar.organization=ricardo-campos-org
# This is the name and version displayed in the SonarQube UI.
# Was mandatory prior to SonarQube 6.1.
sonar.projectName=tasknote-webapp
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file.
# Replace "\" by "/" on Windows.
# This property is optional if sonar.modules is set.
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.typescript.tsconfigPaths=tsconfig.json
sonar.sources=src/
sonar.exclusions=src/__test__/**
sonar.tests=src/__test__/
sonar.verbose=false
# Encoding of the source code. Default is default system encoding
sonar.sourceEncoding=UTF-8
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, vi, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { describe, vi, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act, waitFor, cleanup } from '@testing-library/react';
import ModalMarkdown from '../../components/ModalMarkdown';
describe('ModalMarkdown Component', () => {
@@ -12,13 +12,19 @@ describe('ModalMarkdown Component', () => {
};
beforeEach(() => {
Object.assign(navigator, {
vi.stubGlobal('navigator', {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
cleanup();
});
it('should render the modal with the correct title and markdown text', () => {
render(<ModalMarkdown {...props} />);
@@ -84,15 +90,29 @@ describe('ModalMarkdown Component', () => {
});
it('should show "Copied!" text after Copy button is clicked', async () => {
vi.useFakeTimers();
render(<ModalMarkdown {...props} />);
const copyButton = screen.getByTestId('modal-copy-button');
await act(async () => {
fireEvent.click(screen.getByTestId('modal-copy-button'));
fireEvent.click(copyButton);
});
await waitFor(() => {
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
// Resolve microtasks for the clipboard promise
await act(async () => {
await vi.runAllTicks();
});
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
// Advance timers to see it go back to "Copy"
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copy');
vi.useRealTimers();
});
it('should reset source view state when modal is closed', () => {
@@ -5,14 +5,14 @@ import TaskTag from '../../components/TaskTag';
describe('TaskTag Component', () => {
it('should render the TaskTag component with provided tag and last update', () => {
const { getByText } = render(<TaskTag tag="important" lastUpdate="2025-02-20" />);
expect(getByText('#important')).toBeDefined();
const { getByText } = render(<TaskTag tags={['important']} lastUpdate="2025-02-20" taskOrNote="task" />);
expect(getByText('#important task')).toBeDefined();
expect(getByText('2025-02-20')).toBeDefined();
});
it('should render the TaskTag component with default tag when no tag is provided', () => {
const { getByText } = render(<TaskTag lastUpdate="2025-02-20" />);
expect(getByText('#untagged')).toBeDefined();
const { getByText } = render(<TaskTag lastUpdate="2025-02-20" taskOrNote="task" />);
expect(getByText('#untagged task')).toBeDefined();
expect(getByText('2025-02-20')).toBeDefined();
});
});
@@ -4,22 +4,22 @@ import { describe, expect, it } from 'vitest';
import TaskTimeLeft from '../../components/TaskTimeLeft';
describe('TaskTimeLeft Component', () => {
const renderComponent = (done: boolean) => {
const renderComponent = (completed: boolean) => {
return render(
<TaskTimeLeft
text="2 days left"
done={done}
completed={completed}
tooltip="2025-03-20"
/>
);
};
it('should render the TaskTimeLeft component with text when task is not done', () => {
it('should render the TaskTimeLeft component with text when task is not completed', () => {
const { getByText } = renderComponent(false);
expect(getByText('2 days left')).toBeDefined();
});
it('should not render the TaskTimeLeft component when task is done', () => {
it('should not render the TaskTimeLeft component when task is completed', () => {
const { queryByText } = renderComponent(true);
expect(queryByText('2 days left')).toBeNull();
});
@@ -4,32 +4,32 @@ import { describe, expect, it } from 'vitest';
import TaskTitle from '../../components/TaskTitle';
describe('TaskTitle Component', () => {
it('should render the TaskTitle component with high priority and done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={true} />);
it('should render the TaskTitle component with high priority and completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={true} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeDefined();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
});
it('should render the TaskTitle component with high priority and not done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={false} />);
it('should render the TaskTitle component with high priority and not completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={false} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeNull();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Bell icon
});
it('should render the TaskTitle component with not high priority and done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={true} />);
it('should render the TaskTitle component with not high priority and completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={true} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeDefined();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
});
it('should render the TaskTitle component with not high priority and not done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={false} />);
it('should render the TaskTitle component with not high priority and not completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={false} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeNull();
expect(getByText('Test Task')).toBeDefined();
@@ -7,6 +7,7 @@ import AuthProvider from '../../context/AuthProvider';
import AuthContext, { AuthContextData } from '../../context/AuthContext';
import api from '../../api-service/api';
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
import ApiConfig from '../../api-service/apiConfig';
// Mock the API service methods.
vi.mock('../../api-service/api');
@@ -119,17 +120,29 @@ describe('AuthProvider', () => {
});
it('should set loading to false and signed to true after successful initial auth check', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});
localStorage.setItem(API_TOKEN, 'dummy');
const { getByTestId } = render(
@@ -142,6 +155,7 @@ describe('AuthProvider', () => {
expect(getByTestId('loading').textContent).toBe('false')
);
expect(getByTestId('signed').textContent).toBe('true');
expect(getByTestId('user').textContent).toBe('Refreshed User');
});
it('should sign in a user successfully', async () => {
@@ -251,17 +265,29 @@ describe('AuthProvider', () => {
});
it('should call fetchCurrentSession when checking current auth user', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});
// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
localStorage.setItem(API_TOKEN, 'dummy');
@@ -283,8 +309,9 @@ describe('AuthProvider', () => {
await user.click(getByTestIdFunction('checkCurrentAuthUser'));
await waitFor(() =>
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
);
await waitFor(() => {
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
});
});
});
@@ -28,6 +28,8 @@ describe('Portuguese Utils unit tests', () => {
expect(translateTimeMessage('1 month left', 'pt_br')).toBe('1 mês restante');
expect(translateTimeMessage('2 days left', 'pt_br')).toBe('2 dias restantes');
expect(translateTimeMessage('1 day left', 'pt_br')).toBe('1 dia restante');
expect(translateTimeMessage('Due tomorrow', 'pt_br')).toBe('Vence amanhã');
expect(translateTimeMessage('Due today', 'pt_br')).toBe('Vence hoje');
expect(translateTimeMessage('lala', 'pt_br')).toBe('lala');
expect(translateTimeMessage('null', 'pt_br')).toBe('null');
});
@@ -49,6 +49,8 @@ describe('Russian Utils unit tests', () => {
expect(translateTimeMessage('7 days left', 'ru')).toBe('осталось 7 дней');
expect(translateTimeMessage('8 days left', 'ru')).toBe('осталось 8 дней');
expect(translateTimeMessage('9 days left', 'ru')).toBe('осталось 9 дней');
expect(translateTimeMessage('Due tomorrow', 'ru')).toBe('Срок завтра');
expect(translateTimeMessage('Due today', 'ru')).toBe('Срок сегодня');
expect(translateTimeMessage('lala', 'ru')).toBe('lala');
expect(translateTimeMessage('null', 'ru')).toBe('null');
});
@@ -28,6 +28,8 @@ describe('Spanish Utils unit tests', () => {
expect(translateTimeMessage('1 month left', 'es')).toBe('Falta 1 mes');
expect(translateTimeMessage('2 days left', 'es')).toBe('Faltan 2 días');
expect(translateTimeMessage('1 day left', 'es')).toBe('Falta 1 día');
expect(translateTimeMessage('Due tomorrow', 'es')).toBe('Vence mañana');
expect(translateTimeMessage('Due today', 'es')).toBe('Vence hoy');
expect(translateTimeMessage('lala', 'es')).toBe('lala');
expect(translateTimeMessage('null', 'es')).toBe('null');
});
@@ -6,7 +6,7 @@ const tasks: TaskResponse[] = [
{
id: 1,
description: 'description',
done: false,
completed: false,
highPriority: true,
dueDate: '',
dueDateFmt: '',
+3
View File
@@ -25,5 +25,8 @@ describe('Renders the about view', () => {
expect(getByText('Find more information about us and the app')).toBeDefined();
expect(getByText('Tasks and notes made')).toBeDefined();
expect(getByText('Easy')).toBeDefined();
expect(getByText('About TaskNote')).toBeDefined();
expect(getByText('Technology')).toBeDefined();
expect(getByText('About the Developer')).toBeDefined();
});
});
+138 -27
View File
@@ -22,7 +22,11 @@ vi.mock('react-i18next', () => ({
vi.mock('../../api-service/api', () => ({
default: {
getJSON: vi.fn(),
deleteNoContent: vi.fn()
postJSON: vi.fn(),
patchJSON: vi.fn(),
putJSON: vi.fn(),
deleteNoContent: vi.fn(),
getJSONNoAuth: vi.fn()
}
}));
@@ -42,7 +46,9 @@ vi.mock('react-router', () => ({
}));
vi.mock('react-bootstrap-icons', () => ({
ThreeDotsVertical: () => <div data-testid="three-dots-icon"></div>
ThreeDotsVertical: () => <div data-testid="three-dots-icon"></div>,
CheckSquare: () => <div data-testid="task-icon"></div>,
JournalText: () => <div data-testid="note-icon">📝</div>
}));
// Mock components
@@ -55,7 +61,19 @@ vi.mock('../../components/AlertError', () => ({
}));
vi.mock('../../components/ModalMarkdown', () => ({
default: (props: any) => <div data-testid="modal-markdown">{props.show ? 'Modal Open' : ''}</div>
default: (props: any) => (
<div data-testid="modal-markdown">
{props.show ? (
<div>
<div data-testid="modal-title">{props.title}</div>
<div data-testid="modal-content">{props.markdownText}</div>
<button data-testid="modal-close" onClick={props.onHide}>Close</button>
</div>
) : (
''
)}
</div>
)
}));
vi.mock('../../components/TaskTitle', () => ({
@@ -67,7 +85,14 @@ vi.mock('../../components/TaskTimeLeft', () => ({
}));
vi.mock('../../components/TaskTag', () => ({
default: (props: any) => <div data-testid="task-tag">{props.tag}</div>
default: (props: any) => (
<div data-testid="task-tag">
{props.tag}
{props.taskOrNote === 'note' && props.onClick && (
<a href="#" data-testid="open-it" onClick={props.onClick}>Open it</a>
)}
</div>
)
}));
vi.mock('../../components/NoteTitle', () => ({
@@ -79,9 +104,9 @@ const mockTasks: TaskResponse[] = [
{
id: 1,
description: 'Task 1',
done: false,
completed: false,
urls: ['http://example.com'],
tag: 'work',
tags: ['work'],
lastUpdate: '2023-10-10',
highPriority: true,
dueDateFmt: '2 days left',
@@ -90,9 +115,9 @@ const mockTasks: TaskResponse[] = [
{
id: 2,
description: 'Task 2',
done: true,
completed: true,
urls: [],
tag: 'home',
tags: ['home'],
lastUpdate: '2023-10-09',
highPriority: false,
dueDateFmt: '',
@@ -105,17 +130,23 @@ const mockNotes: NoteResponse[] = [
id: 1,
title: 'Note 1',
description: 'Line 1\nLine 2\nLine 3',
tag: 'work',
tags: ['work'],
lastUpdate: '2023-10-10',
url: 'http://example.com'
url: 'http://example.com',
shared: false,
shareToken: null,
archived: false
},
{
id: 2,
title: 'Note 2',
description: 'This is a sample\nnote content',
tag: 'personal',
tags: ['personal'],
lastUpdate: '2023-10-09',
url: null
url: null,
shared: false,
shareToken: null,
archived: false
}
];
@@ -166,6 +197,7 @@ describe('Home Component', () => {
return Promise.resolve([]);
});
(api.deleteNoContent as any).mockResolvedValue(undefined);
(api.putJSON as any).mockResolvedValue(undefined);
// Mock window.innerWidth for the cleanText function
Object.defineProperty(window, 'innerWidth', {
@@ -194,6 +226,8 @@ describe('Home Component', () => {
await waitFor(() => {
expect(screen.getAllByTestId('task-title').length).toBe(2);
expect(screen.getAllByTestId('note-title').length).toBe(2);
expect(screen.getAllByTestId('task-icon').length).toBe(2);
expect(screen.getAllByTestId('note-icon').length).toBe(2);
});
});
@@ -328,14 +362,19 @@ describe('Home Component', () => {
fireEvent.click(markAsDoneButton!);
});
// Should call deleteNoContent API
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/1'));
// Should call patchJSON API with completed: true
expect(api.patchJSON).toHaveBeenCalledWith(
expect.stringContaining('/1'),
expect.objectContaining({ completed: true })
);
// Should reload tasks
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks'));
});
test('deletes note', async () => {
test('archives note', async () => {
(api.putJSON as any).mockResolvedValue(undefined);
await act(async () => {
renderHome();
});
@@ -354,18 +393,17 @@ describe('Home Component', () => {
fireEvent.click(firstNoteDropdown);
});
// Find and click the "Delete" option by testId
const deleteButtons = screen.getAllByRole('button');
const deleteButton = deleteButtons.find(
button => button.textContent === 'task_table_action_delete'
);
// Find and click the "Archive" option by testId
const archiveButton = screen.getByTestId('note-dropdown-archive-item-1');
await act(async () => {
fireEvent.click(deleteButton!);
fireEvent.click(archiveButton);
});
// Should call deleteNoContent API
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/2'));
// Should call archive API immediately
await waitFor(() => {
expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {});
});
// Should reload notes
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes'));
@@ -455,7 +493,7 @@ describe('Home Component', () => {
});
});
test('keeps filter selection after deleting a note', async () => {
test('keeps filter selection after archiving a note', async () => {
await act(async () => {
renderHome();
});
@@ -487,11 +525,11 @@ describe('Home Component', () => {
});
const deleteButtons = screen.getAllByRole('button');
const deleteButton = deleteButtons.find(
button => button.textContent === 'task_table_action_delete'
const archiveButton = deleteButtons.find(
button => button.textContent === 'note_action_archive'
);
await act(async () => {
fireEvent.click(deleteButton!);
fireEvent.click(archiveButton!);
});
// After reload, filter should still be applied - tasks should remain hidden
@@ -541,6 +579,79 @@ describe('Home Component', () => {
expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1');
});
});
test('saves note ID to localStorage when opening modal', async () => {
await act(async () => {
renderHome();
});
await waitFor(() => {
expect(screen.getAllByTestId('open-it').length).toBe(2);
});
const openItLinks = screen.getAllByTestId('open-it');
await act(async () => {
fireEvent.click(openItLinks[1]);
});
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
expect(screen.getByTestId('modal-title').textContent).toBe('Note 1');
expect(screen.getByTestId('modal-content').textContent).toBe('Line 1\nLine 2\nLine 3');
});
test('restores open note modal from localStorage on reload', async () => {
localStorage.setItem('OPEN_NOTE_ID', '2');
await act(async () => {
renderHome();
});
await waitFor(() => {
expect(screen.getByTestId('modal-title').textContent).toBe('Note 2');
});
expect(screen.getByTestId('modal-content').textContent).toBe('This is a sample\nnote content');
});
test('does not restore modal if localStorage note ID not found', async () => {
localStorage.setItem('OPEN_NOTE_ID', '999');
await act(async () => {
renderHome();
});
await waitFor(() => {
expect(screen.getAllByTestId('note-title').length).toBe(2);
});
const modal = screen.getByTestId('modal-markdown');
expect(modal.textContent).toBe('');
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
});
test('clears localStorage when closing modal', async () => {
await act(async () => {
renderHome();
});
await waitFor(() => {
expect(screen.getAllByTestId('open-it').length).toBe(2);
});
const openItLinks = screen.getAllByTestId('open-it');
await act(async () => {
fireEvent.click(openItLinks[1]);
});
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
const closeButton = screen.getByTestId('modal-close');
await act(async () => {
fireEvent.click(closeButton);
});
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
});
/*
test('getFirstRows properly formats note preview', async () => {
await act(async () => {
+20 -17
View File
@@ -1,5 +1,6 @@
import React, { act } from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { I18nextProvider } from 'react-i18next';
@@ -16,6 +17,7 @@ vi.mock('../../api-service/api', () => ({
default: {
postJSON: vi.fn(),
getJSON: vi.fn(),
patchJSON: vi.fn(),
}
}));
@@ -82,6 +84,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({
const mockedApi = vi.mocked(api);
const mockedUseParams = vi.mocked(useParams);
const mockedUseSearchParams = vi.mocked(useSearchParams);
describe('NoteAdd Component', () => {
const renderNoteAdd = () => {
@@ -100,9 +103,9 @@ describe('NoteAdd Component', () => {
beforeEach(() => {
// Reset mock between tests
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReset();
mockedUseSearchParams.mockReturnValue([new URLSearchParams(), vi.fn()]);
mockedUseParams.mockReturnValue({});
vi.clearAllMocks();
vi.resetAllMocks();
});
it('should render the NoteAdd component', async () => {
@@ -118,28 +121,23 @@ describe('NoteAdd Component', () => {
});
it('should show error message when form is invalid', async () => {
let result: any;
await act(async () => {
result = renderNoteAdd();
});
const { getByText, getByRole } = result;
const { getByText, getByRole } = renderNoteAdd();
const submitButton = getByRole('button', { name: 'note_form_submit' });
fireEvent.click(submitButton);
await waitFor(() => {
expect(getByText('Please fill in all the fields')).toBeDefined();
});
});
it('should add a new note when form is valid', async () => {
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue([
mockedUseSearchParams.mockReturnValue([
new URLSearchParams("backTo=home"),
vi.fn(),
]);
let result: any;
await act(async () => {
result = renderNoteAdd();
});
const { getByLabelText, getByTestId, getByRole } = result;
const { getByLabelText, getByTestId, getByRole } = renderNoteAdd();
const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement;
const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement;
const submitButton = getByRole('button', { name: 'note_form_submit' });
@@ -154,10 +152,11 @@ describe('NoteAdd Component', () => {
title: 'New Note',
description: 'Note content',
url: '',
tag: '',
tags: [],
lastUpdate: '',
shared: false,
shareToken: null
shareToken: null,
archived: false
}
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
});
@@ -185,8 +184,10 @@ describe('NoteAdd Component', () => {
title: 'Note one',
description: 'Description of note one',
url: 'http://notes.domain.com',
tag: 'dev',
tags: ['dev'],
lastUpdate: '3 minutes ago',
shared: false,
shareToken: null
};
vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit);
@@ -211,8 +212,10 @@ describe('NoteAdd Component', () => {
title: 'Old title',
description: 'Old description',
url: 'http://notes.domain.com',
tag: 'dev',
tags: ['dev'],
lastUpdate: '1 minute ago',
shared: false,
shareToken: null
};
vi.spyOn(api, 'getJSON').mockResolvedValue(toClone);
+1 -1
View File
@@ -130,7 +130,7 @@ describe('TaskAdd Component', () => {
description: 'New Task',
dueDate: '',
highPriority: false,
tag: '',
tags: [],
urls: []
}
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.tasksUrl, newTask);
+3 -1
View File
@@ -28,7 +28,9 @@ const ApiConfig = {
publicNotesUrl: `${server}/public/notes`,
userUrl: `${server}/rest/users`
userUrl: `${server}/rest/users`,
currentUserUrl: `${server}/rest/users/me`
};
export default ApiConfig;
@@ -0,0 +1,6 @@
@import '../../styles/theme.scss';
.form-control[type="date"] {
font-size: 16px; /* Prevents iOS zoom on focus */
border-left: none;
}
@@ -1,34 +0,0 @@
@import '../../styles/theme.scss';
/* custom-datepicker.scss */
.react-datepicker__input-container input {
font-size: 16px; /* Prevents iOS zoom on focus */
width: 100%;
border-radius: 4px;
border: 1px solid var(--bs-border-color);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.react-datepicker-popper {
z-index: 9999 !important;
}
.react-datepicker__day {
margin: 0.2rem;
padding: 0.5rem;
border-radius: 4px;
}
/* Larger touch targets on mobile */
@media (max-width: 768px) {
.react-datepicker__day,
.react-datepicker__month-text,
.react-datepicker__quarter-text,
.react-datepicker__year-text {
width: 2.5rem;
height: 2.5rem;
line-height: 2.5rem;
margin: 0.2rem;
}
}
+11 -37
View File
@@ -1,11 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Col, Form, InputGroup, Row } from 'react-bootstrap';
import * as Icons from 'react-bootstrap-icons';
import DatePicker from 'react-datepicker';
import { MiddlewareReturn } from '@floating-ui/core';
import { MiddlewareState } from '@floating-ui/dom';
import 'react-datepicker/dist/react-datepicker.css';
import './custom-datepicker.scss';
import './FormInput.scss';
type IconName = keyof typeof Icons;
@@ -17,9 +13,7 @@ interface Props {
name: string;
placeholder?: string;
value?: string;
valueDate?: Date | null;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onChangeDate?: (date: Date | null) => void;
dataTestId?: string;
pwdHideText?: string;
pwdShowText?: string;
@@ -63,40 +57,20 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
)}
</Form.Label>
<InputGroup className="mb-3">
<InputGroup className="mb-3 flex-nowrap">
<InputGroup.Text>
<Icon />
</InputGroup.Text>
{props.type == 'date'
{props.type === 'date'
? (
<DatePicker
selected={props?.valueDate}
onChange={(date: Date | null) => {
if (props.onChangeDate) {
props.onChangeDate(date);
}
}}
dateFormat="MMMM d, yyyy"
className="form-control"
id="date-input"
placeholderText={props.placeholder}
popperPlacement="bottom"
popperModifiers={[
{
name: 'preventOverflow',
options: {
enabled: true,
boundariesElement: 'viewport'
},
fn: function (state: MiddlewareState): MiddlewareReturn | Promise<MiddlewareReturn> {
return state;
}
}
]}
withPortal
showYearDropdown
showMonthDropdown
dropdownMode="select"
<Form.Control
required={props.required}
type="date"
name={props.name}
placeholder={props.placeholder ? props.placeholder : ''}
value={props?.value}
onChange={props.onChange}
data-testid={props.dataTestId}
/>
)
: (
+29 -9
View File
@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -10,6 +9,8 @@ type Props = {
title: string;
markdownText: string;
onHide: () => void;
onSave?: () => Promise<boolean>;
saveButtonLabel?: string;
};
/**
@@ -73,23 +74,42 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
)}
</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button variant="outline-secondary" onClick={handleHide}>
<button
type="button"
onClick={handleHide}
className="home-new-item-secondary task-note-btn"
>
Close
</Button>
<Button
variant={showSource ? 'info' : 'outline-info'}
</button>
<button
type="button"
onClick={handleToggleSource}
data-testid="modal-source-button"
className={`${showSource ? 'home-new-item' : 'home-new-item-secondary'} task-note-btn`}
>
Source
</Button>
<Button
variant="outline-primary"
</button>
<button
type="button"
onClick={handleCopy}
data-testid="modal-copy-button"
className="home-new-item-secondary task-note-btn"
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</button>
{props.onSave && (
<button
type="button"
onClick={async () => {
handleHide();
await props.onSave!();
}}
data-testid="modal-save-button"
className="home-new-item task-note-btn"
>
{props.saveButtonLabel ?? 'Save note'}
</button>
)}
</Modal.Footer>
</Modal>
)
+36 -5
View File
@@ -1,4 +1,4 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { Nav } from 'react-bootstrap';
import { NavLink } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -22,8 +22,10 @@ interface Props {
function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
const { signOut, user } = useContext(AuthContext);
const { currentPage, setNewPage } = useContext(SidebarContext);
const [lastSeen, setLastSeen] = useState('');
const { t } = useTranslation();
const build = `Build: ${env.VITE_BUILD}`;
const changeLogUrl = 'https://lightroasted.vps-kinghost.net/rmcampos/tasknote/src/branch/main/CHANGELOG.md';
// Note: when selected, change class to plus-jakarta-sans-thin and add background
@@ -46,7 +48,25 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
return '';
};
useEffect(() => {}, [user, currentPage]);
useEffect(() => {
if (user && user.lastLogin) {
const utcString = user.lastLogin.endsWith('Z') ? user.lastLogin : `${user.lastLogin}Z`;
const date = new Date(utcString);
if (Number.isNaN(date.getTime())) {
setLastSeen('');
return;
}
const fmtted = date.toLocaleString(navigator.language, {
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
setLastSeen(fmtted);
}
}, [user]);
return (
<>
@@ -95,9 +115,20 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
{/* Footer at the bottom */}
<div className="mt-auto text-center text-muted py-3">
<small data-testid="footer-text">
{build}
</small>
{lastSeen && (
<div>
<small>{t('sidebar_last_seen', { time: lastSeen })}</small>
</div>
)}
<a
data-testid="footer-text"
href={changeLogUrl}
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
<small>{build}</small>
</a>
</div>
</div>
+9
View File
@@ -96,3 +96,12 @@ a.active {
border-left: #4CD964 0.3rem solid;
background: #ced6da linear-gradient(270deg, rgba(53, 99, 233, 0.36) -416.06%, rgba(53, 99, 233, 0) 94.8%);
}
.footer-link {
color: inherit;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
+9 -7
View File
@@ -3,27 +3,29 @@ import { Col, Row } from 'react-bootstrap';
import './style.css';
interface Props {
readonly tag?: string;
readonly tags?: string[];
readonly lastUpdate: string;
readonly taskOrNote: 'task' | 'note';
readonly onClick?: (e: React.MouseEvent<Element, MouseEvent>) => void;
}
/**
* Renders the TaskTag component, displaying a tag and the last update time.
* Renders the TaskTag component, displaying tags and the last update time.
*
* @param {Props} props - The props for the component.
* @param {string} [props.tag] - The tag for the task. If not provided, defaults to '#untagged'.
* @param {string[]} [props.tags] - The tags for the task. If not provided or empty, defaults to '#untagged'.
* @param {string} props.lastUpdate - The last update time for the task.
* @returns {React.ReactNode} The rendered TaskTag component.
*/
function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
const tagText = props.tag ? `#${props.tag}` : '#untagged';
const tagContent = props.tags && props.tags.length > 0
? props.tags.map(tag => `#${tag}`).join(' ')
: '#untagged';
return (
<Row>
<Col className="d-inline-block text-muted card-tag poppins-regular">
{tagText}
<Col className="d-inline-block card-tag poppins-regular">
{tagContent}
{' '}
{props.taskOrNote}
{props.taskOrNote === 'note' && (
@@ -38,7 +40,7 @@ function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
</>
)}
</Col>
<Col className="d-inline-block text-muted card-tag ms-5 text-end poppins-regular">
<Col className="d-inline-block card-tag ms-5 text-end poppins-regular">
{props.lastUpdate}
</Col>
</Row>
+1
View File
@@ -1,3 +1,4 @@
.card-tag {
font-size: 13px;
color: rgba(var(--bs-body-color-rgb), 0.55);
}
+4 -4
View File
@@ -4,17 +4,17 @@ import { CalendarCheck } from 'react-bootstrap-icons';
interface Props {
text: string;
done: boolean;
completed: boolean;
tooltip: string;
}
/**
* Renders the TaskTimeLeft component if the task is not done, displaying
* Renders the TaskTimeLeft component if the task is not completed, displaying
* a calendar icon and the time left for the task.
*
* @param {Props} props - Props for the TaskTimeLeft component.
* @param {string} props.text - The time left for the task.
* @param {boolean} props.done - Boolean value indicating if the task is done.
* @param {boolean} props.completed - Boolean value indicating if the task is completed.
* @param {string} props.tooltip - The string representation of a Date instance.
* @returns
*/
@@ -27,7 +27,7 @@ function TaskTimeLeft(props: React.PropsWithChildren<Props>): React.ReactNode |
}).format(new Date(props.tooltip))
: '';
return props.done
return props.completed
? null
: (
<div className="d-block task-due-date">
+3 -3
View File
@@ -5,7 +5,7 @@ import './style.css';
interface Props {
readonly title: string;
readonly done: boolean;
readonly completed: boolean;
readonly taskUrl: string[];
}
@@ -14,14 +14,14 @@ interface Props {
*
* @param {Props} props - The props for the component.
* @param {string} [props.title] - The title for the task.
* @param {boolean} [props.done] - Define if the task is completed.
* @param {boolean} [props.completed] - Define if the task is completed.
* @returns {React.ReactNode} The rendered TaskTitle component.
*/
function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
return (
<span className="task-title-icon" data-testid={`task-title-container-${props.title}`}>
<span
className={`${props.done ? 'ms-2 text-strike' : ''} poppins-semibold`}
className={`${props.completed ? 'ms-2 text-strike' : ''} poppins-semibold`}
data-testid={`task-title-text-${props.title}`}
>
{props.title}
+27 -9
View File
@@ -76,6 +76,8 @@ const enTranslations = {
home_card_task_pending: 'Pending tasks',
home_card_task_empty: 'No pending tasks',
home_card_task_done: 'done tasks!',
home_completed_tasks_title: 'Completed tasks',
home_archived_notes_title: 'Archived notes',
home_card_task_done_empty: 'No done tasks!',
home_card_task_btn: 'Go to Tasks',
home_card_note_title: 'Notes Summary',
@@ -105,6 +107,10 @@ const enTranslations = {
task_table_action_edit: 'Edit',
task_table_action_clone: 'Clone',
task_table_action_delete: 'Delete',
delete_modal_title: 'Confirm deletion',
delete_modal_body: 'Are you sure you want to delete this item? This action cannot be undone.',
delete_modal_cancel: 'Cancel',
delete_modal_confirm: 'Delete',
note_form_title: 'Add note',
note_form_title_label: 'Title',
@@ -117,6 +123,9 @@ const enTranslations = {
note_action_share: 'Share',
note_action_unshare: 'Unshare',
note_action_copy_link: 'Copy link',
note_action_archive: 'Archive',
note_action_restore: 'Restore',
note_action_delete_permanently: 'Delete permanently',
about_page_title_one: 'About the',
about_page_title_two: 'TaskNote App',
@@ -132,23 +141,31 @@ const enTranslations = {
about_app_features_one: 'Quickly add and manage tasks and notes',
about_app_features_two: 'Search and filter notes for easy access',
about_app_features_three: 'Intuitive and clean user interface',
about_app_features_four: 'Dark and light theme support with responsive design',
about_app_features_five: 'URL attachments for tasks and notes',
about_app_features_six: 'Tagging system with multiple tags per item',
about_app_features_seven: 'Public note sharing via public links',
about_app_features_eight: 'Markdown formatting toolbar for rich text editing',
about_app_features_nine: 'Email confirmation required for account security',
about_app_help_title: 'Help & How to Use',
about_app_help_description: `To get started, simply sign up or log in, and
you'll have access to your personalized dashboard. From there, you can
create, edit, and delete tasks and notes, and organize them however you
like. Need assistance? Visit our Help page (in the future) for tutorials
and FAQs.`,
like. Need assistance? Check out the sidebar for quick navigation.`,
about_tech_title: 'Technology',
about_tech_description: `TaskNote was built using modern web technologies
that ensure speed, reliability, and security.`,
about_tech_list_one: 'React with TypeScript for the front-end',
about_tech_list_two: 'Bootstrap 5 for components and responsive design',
about_tech_list_three: 'Java and Spring Boot plus GraalVM for the back-end and Cloud Native',
about_tech_list_four: 'PostgreSQL for database management',
about_tech_list_five: 'Docker for containerization and deployment',
about_tech_list_six: 'GitHub Actions for CI/CD, testing and linting enforcement',
about_tech_list_seven: 'SonarCloud, and GitHub QL for security and improvements checks',
about_tech_list_one: 'React 19 with TypeScript for the front-end',
about_tech_list_two: 'Vite for fast builds and development',
about_tech_list_three: 'React Router 7 for client-side routing',
about_tech_list_four: 'i18next for internationalization support',
about_tech_list_five: 'Bootstrap 5 for components and responsive design',
about_tech_list_six: 'Java and Spring Boot 4.x plus GraalVM for the back-end and Cloud Native',
about_tech_list_seven: 'PostgreSQL for database management',
about_tech_list_eight: 'Docker for containerization and deployment',
about_tech_list_nine: 'GitHub Actions for CI/CD, testing and linting enforcement',
about_tech_list_ten: 'SonarCloud and GitHub QL for security and improvements checks',
about_dev_title: 'About the Developer',
about_dev_description: `Hi! I'm Ricardo, the developer of TaskNote. I'm
@@ -187,6 +204,7 @@ const enTranslations = {
account_delete_btn: 'Yes, delete everything',
footer_my_account: 'My Account ',
sidebar_last_seen: 'Last seen {{time}}',
logout: 'Logout'
};
+7 -1
View File
@@ -19,6 +19,8 @@ export const timeAgoTranslations: Record<string, string> = {
'month left_pt_br': '{X} mês restante',
'days left_pt_br': '{X} dias restantes',
'day left_pt_br': '{X} dia restante',
'due tomorrow_pt_br': 'Vence amanhã',
'due today_pt_br': 'Vence hoje',
'years ago_es': 'Hace {X} años',
'year ago_es': 'Hace {X} año',
@@ -40,6 +42,8 @@ export const timeAgoTranslations: Record<string, string> = {
'month left_es': 'Falta {X} mes',
'days left_es': 'Faltan {X} días',
'day left_es': 'Falta {X} día',
'due tomorrow_es': 'Vence mañana',
'due today_es': 'Vence hoy',
'years ago_ru': '{X} года назад',
'year ago_ru': '{X} год назад',
@@ -60,7 +64,9 @@ export const timeAgoTranslations: Record<string, string> = {
'months left_ru': 'осталось {X} месяца',
'month left_ru': 'Остался {X} месяц',
'days left_ru': 'осталось {X} дня',
'day left_ru': 'Остался {X} день'
'day left_ru': 'Остался {X} день',
'due tomorrow_ru': 'Срок завтра',
'due today_ru': 'Срок сегодня'
};
export const serverResponsesTranslations: Record<string, string> = {
+27 -9
View File
@@ -76,6 +76,8 @@ const ptBrTranslations = {
home_card_task_pending: 'Tarefa(s) pendente(s)',
home_card_task_empty: 'Nenhuma tarefa pendente',
home_card_task_done: 'tarefa(s) concluída(s)',
home_completed_tasks_title: 'Tarefas concluídas',
home_archived_notes_title: 'Notas arquivadas',
home_card_task_done_empty: 'Nenhuma tarefa condluída',
home_card_task_btn: 'Ir para Tarefas',
home_card_note_title: 'Resumo de Notas',
@@ -105,6 +107,10 @@ const ptBrTranslations = {
task_table_action_edit: 'Alterar',
task_table_action_clone: 'Clonar',
task_table_action_delete: 'Excluir',
delete_modal_title: 'Confirmar exclusão',
delete_modal_body: 'Tem certeza de que deseja excluir este item? Esta ação não pode ser desfeita.',
delete_modal_cancel: 'Cancelar',
delete_modal_confirm: 'Excluir',
note_form_title: 'Adicionar nota',
note_form_title_label: 'Título',
@@ -117,6 +123,9 @@ const ptBrTranslations = {
note_action_share: 'Compartilhar',
note_action_unshare: 'Parar de compartilhar',
note_action_copy_link: 'Copiar link',
note_action_archive: 'Arquivar',
note_action_restore: 'Restaurar',
note_action_delete_permanently: 'Excluir permanentemente',
about_page_title_one: 'Sobre o',
about_page_title_two: 'App TaskNote',
@@ -133,23 +142,31 @@ const ptBrTranslations = {
about_app_features_one: 'Adicionar e gerenciar tarefas e notas rapidamente',
about_app_features_two: 'Buscar e filtrar notas para fácil acesso',
about_app_features_three: 'Interface limpa e intuitiva',
about_app_features_four: 'Suporte a tema escuro e claro com design responsivo',
about_app_features_five: 'Anexos de URL para tarefas e notas',
about_app_features_six: 'Sistema de tags com múltiplas tags por item',
about_app_features_seven: 'Compartilhamento público de notas via links públicos',
about_app_features_eight: 'Barra de ferramentas de formatação Markdown para edição de texto rico',
about_app_features_nine: 'Confirmação de e-mail obrigatória para segurança da conta',
about_app_help_title: 'Ajuda & Como usar',
about_app_help_description: `Para começar, simplemente crie sua conta ou entre,
e você terá acesso ao seu painel personalizado. A partir daí, você pode criar,
alterar, e apagar tarefas e notas, e organizá-las da forma que preferir. Precisa
de alguma ajuda? Visite nossa página de ajuda (em breve) para tutoriais e perguntas
frequentes.`,
de alguma ajuda? Confira a barra lateral para navegação rápida.`,
about_tech_title: 'Tecnologia',
about_tech_description: `TaskNote foi contruído com tecnologia web atual
que garante velocidade, confiabilidade e segurança.`,
about_tech_list_one: 'React com TypeScript para o desenvolvimento do front-end',
about_tech_list_two: 'Bootstrap 5 para os componentes e design responsivo',
about_tech_list_three: 'Java e Spring Boot mais GraalVM para o back-end Nativo em Cloud',
about_tech_list_four: 'PostgreSQL para gestão do banco de dados',
about_tech_list_five: 'Docker para isolamento em containers e lançamentos',
about_tech_list_six: 'GitHub Actions para integração contínua, testes e controle de qualidade',
about_tech_list_seven: 'SonarCloud e GitHub QL para reforço de qualidade e melhorias',
about_tech_list_one: 'React 19 com TypeScript para o desenvolvimento do front-end',
about_tech_list_two: 'Vite para builds e desenvolvimento rápidos',
about_tech_list_three: 'React Router 7 para roteamento no lado do cliente',
about_tech_list_four: 'i18next para suporte à internacionalização',
about_tech_list_five: 'Bootstrap 5 para os componentes e design responsivo',
about_tech_list_six: 'Java e Spring Boot 4.x mais GraalVM para o back-end Nativo em Cloud',
about_tech_list_seven: 'PostgreSQL para gestão do banco de dados',
about_tech_list_eight: 'Docker para isolamento em containers e lançamentos',
about_tech_list_nine: 'GitHub Actions para integração contínua, testes e controle de qualidade',
about_tech_list_ten: 'SonarCloud e GitHub QL para reforço de qualidade e melhorias',
about_dev_title: 'Sobre o Desenvolvedor',
about_dev_description: `Olá! Sou o Ricardo, o desenvolvedor do TaskNote.
@@ -188,6 +205,7 @@ const ptBrTranslations = {
account_delete_btn: 'Sim, deletar tudo',
footer_my_account: 'Minha Conta ',
sidebar_last_seen: 'Último acesso {{time}}',
logout: 'Sair'
};
+27 -9
View File
@@ -76,6 +76,8 @@ const ruTranslations = {
home_card_task_pending: 'Незавершённые задачи',
home_card_task_empty: 'Нет незавершённых задач',
home_card_task_done: 'выполненные задачи!',
home_completed_tasks_title: 'Выполненные задачи',
home_archived_notes_title: 'Архивированные заметки',
home_card_task_done_empty: 'Нет выполненных задач!',
home_card_task_btn: 'Перейти к задачам',
home_card_note_title: 'Обзор заметок',
@@ -105,6 +107,10 @@ const ruTranslations = {
task_table_action_edit: 'Редактировать',
task_table_action_clone: 'Клонировать',
task_table_action_delete: 'Удалить',
delete_modal_title: 'Подтвердите удаление',
delete_modal_body: 'Вы уверены, что хотите удалить этот элемент? Это действие не может быть отменено.',
delete_modal_cancel: 'Отмена',
delete_modal_confirm: 'Удалить',
note_form_title: 'Добавить примечание',
note_form_title_label: 'Заголовок',
@@ -117,6 +123,9 @@ const ruTranslations = {
note_action_share: 'Поделиться',
note_action_unshare: 'Закрыть доступ',
note_action_copy_link: 'Копировать ссылку',
note_action_archive: 'Архивировать',
note_action_restore: 'Восстановить',
note_action_delete_permanently: 'Удалить навсегда',
about_page_title_one: 'около',
about_page_title_two: 'TaskNote App',
@@ -132,23 +141,31 @@ const ruTranslations = {
about_app_features_one: 'Быстро добавляйте и управляйте задачами и заметками',
about_app_features_two: 'Поиск и фильтрация заметок для легкого доступа',
about_app_features_three: 'Интуитивно понятный пользовательский интерфейс',
about_app_features_four: 'Поддержка тёмной и светлой темы с адаптивным дизайном',
about_app_features_five: 'URL-вложения для задач и заметок',
about_app_features_six: 'Система тегов с несколькими тегами на элемент',
about_app_features_seven: 'Публичный доступ к заметкам через публичные ссылки',
about_app_features_eight: 'Панель инструментов форматирования Markdown для редактирования',
about_app_features_nine: 'Требуется подтверждение электронной почты для безопасности аккаунта',
about_app_help_title: 'Помощь и как использовать',
about_app_help_description: `Чтобы начать, просто зарегистрируйтесь или войдите в систему, и
вы получите доступ к своей персонализированной панели. Оттуда вы можете
создавать, редактировать и удалять задачи и заметки, а также организовывать их так, как вам
нравится. Нужна помощь? Посетите нашу страницу «Справка» (в будущем) для получения руководств
и часто задаваемых вопросов.`,
нравится. Нужна помощь? Ознакомьтесь с боковой панелью для быстрой навигации.`,
about_tech_title: 'Технологии',
about_tech_description: `TaskNote был создан с использованием современных веб-технологий,
которые обеспечивают скорость, надежность и безопасность.`,
about_tech_list_one: 'React с TypeScript для фронтенда',
about_tech_list_two: 'Bootstrap 5 для компонентов и адаптивного дизайна',
about_tech_list_three: 'Java и Spring Boot плюс GraalVM для бэкенда и Cloud Native',
about_tech_list_four: 'PostgreSQL для управления базами данных',
about_tech_list_five: 'Docker для контейнеризации и развертывания',
about_tech_list_six: 'GitHub Actions для CI/CD, тестирования и принудительного линтинга',
about_tech_list_seven: 'SonarCloud и GitHub QL для проверок безопасности и улучшений',
about_tech_list_one: 'React 19 с TypeScript для фронтенда',
about_tech_list_two: 'Vite для быстрой сборки и разработки',
about_tech_list_three: 'React Router 7 для маршрутизации на стороне клиента',
about_tech_list_four: 'i18next для поддержки интернационализации',
about_tech_list_five: 'Bootstrap 5 для компонентов и адаптивного дизайна',
about_tech_list_six: 'Java и Spring Boot 4.x плюс GraalVM для бэкенда и Cloud Native',
about_tech_list_seven: 'PostgreSQL для управления базами данных',
about_tech_list_eight: 'Docker для контейнеризации и развертывания',
about_tech_list_nine: 'GitHub Actions для CI/CD, тестирования и принудительного линтинга',
about_tech_list_ten: 'SonarCloud и GitHub QL для проверок безопасности и улучшений',
about_dev_title: 'О разработчике',
about_dev_description: `Привет! Я Рикардо, разработчик TaskNote. Я увлечен созданием приложений,
@@ -187,6 +204,7 @@ const ruTranslations = {
account_delete_btn: 'Да, удалить все',
footer_my_account: 'Мой аккаунт ',
sidebar_last_seen: 'Последний вход {{time}}',
logout: 'Выйти'
};
+27 -9
View File
@@ -76,6 +76,8 @@ const esTranslations = {
home_card_task_pending: 'Tarea(s) pendiente(s)',
home_card_task_empty: 'No tienes tareas pendientes',
home_card_task_done: 'tarea(s) completada(s)',
home_completed_tasks_title: 'Tareas completadas',
home_archived_notes_title: 'Notas archivadas',
home_card_task_done_empty: 'No tareas completadas',
home_card_task_btn: 'Ir a Tareas',
home_card_note_title: 'Resumen de Notas',
@@ -105,6 +107,10 @@ const esTranslations = {
task_table_action_edit: 'Editar',
task_table_action_clone: 'Clonar',
task_table_action_delete: 'Eliminar',
delete_modal_title: 'Confirmar eliminación',
delete_modal_body: '¿Estás seguro de que deseas eliminar este elemento? Esta acción no se puede deshacer.',
delete_modal_cancel: 'Cancelar',
delete_modal_confirm: 'Eliminar',
note_form_title: 'Añadir nota',
note_form_title_label: 'Título',
@@ -117,6 +123,9 @@ const esTranslations = {
note_action_share: 'Compartir',
note_action_unshare: 'Dejar de compartir',
note_action_copy_link: 'Copiar enlace',
note_action_archive: 'Archivar',
note_action_restore: 'Restaurar',
note_action_delete_permanently: 'Eliminar permanentemente',
about_page_title_one: 'Acerca de',
about_page_title_two: 'TaskNote App',
@@ -132,23 +141,31 @@ const esTranslations = {
about_app_features_one: 'Añade y gestiona tareas y notas de manera rápida',
about_app_features_two: 'Busca y filtra notas fácilmente',
about_app_features_three: 'Interfaz intuitiva y limpia',
about_app_features_four: 'Soporte para tema oscuro y claro con diseño responsivo',
about_app_features_five: 'Adjuntos de URL para tareas y notas',
about_app_features_six: 'Sistema de etiquetas con múltiples etiquetas por elemento',
about_app_features_seven: 'Compartir notas públicamente mediante enlaces públicos',
about_app_features_eight: 'Barra de herramientas de formato Markdown para edición de texto enriquecido',
about_app_features_nine: 'Confirmación de correo electrónico requerida para la seguridad de la cuenta',
about_app_help_title: 'Ayuda & Cómo usar',
about_app_help_description: `Para empezar, regístrate o inicia sesión,
y tendrás acceso a tu panel personalizado. Desde allí, podrás crear,
editar y eliminar tareas y notas, organizándolas como desees. ¿Necesitas
ayuda? Visita nuestra página de ayuda (próximamente) para tutoriales y
preguntas frecuentes.`,
ayuda? Consulta la barra lateral para navegación rápida.`,
about_tech_title: 'Acerca de la Tecnología',
about_tech_description: `TaskNote fue construido con tecnologías web modernas
que garantizan velocidad, fiabilidad y seguridad.`,
about_tech_list_one: 'React con TypeScript para el desarrollo del frontend',
about_tech_list_two: 'Bootstrap 5 para diseño y componentes responsivos',
about_tech_list_three: 'Java y Spring Boot más GraalVM para el back-end y Cloud Nativo',
about_tech_list_four: 'PostgreSQL para la gestión de bases de datos',
about_tech_list_five: 'Docker para la contenedorización y despliegue',
about_tech_list_six: 'GitHub Actions para CI/CD, pruebas y control de calidad',
about_tech_list_seven: 'SonarCloud y GitHub QL para chequeos de seguridad y mejoras',
about_tech_list_one: 'React 19 con TypeScript para el desarrollo del frontend',
about_tech_list_two: 'Vite para builds y desarrollo rápidos',
about_tech_list_three: 'React Router 7 para enrutamiento del lado del cliente',
about_tech_list_four: 'i18next para soporte de internacionalización',
about_tech_list_five: 'Bootstrap 5 para diseño y componentes responsivos',
about_tech_list_six: 'Java y Spring Boot 4.x más GraalVM para el back-end y Cloud Nativo',
about_tech_list_seven: 'PostgreSQL para la gestión de bases de datos',
about_tech_list_eight: 'Docker para la contenedorización y despliegue',
about_tech_list_nine: 'GitHub Actions para CI/CD, pruebas y control de calidad',
about_tech_list_ten: 'SonarCloud y GitHub QL para chequeos de seguridad y mejoras',
about_dev_title: 'Acerca del Desarrollador',
about_dev_description: `¡Hola! Soy Ricardo, el desarrollador de TaskNote.
@@ -187,6 +204,7 @@ const esTranslations = {
account_delete_btn: 'Sí, borra todo',
footer_my_account: 'Mi Cuenta ',
sidebar_last_seen: 'Último acceso {{time}}',
logout: 'Cerrar sesión'
};
+18 -3
View File
@@ -24,7 +24,6 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
try {
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
setSigned(true);
return bearerToken;
}
catch (e) {
@@ -66,8 +65,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
if (bearerToken && bearerToken.token) {
const userLocal = updateUserSession(null, bearerToken.token);
const currentUser: UserResponse = await api.getJSON(ApiConfig.currentUserUrl);
const userLocal = updateUserSession(currentUser, bearerToken.token);
if (userLocal) {
setSigned(true);
setUser(userLocal);
}
}
@@ -97,7 +98,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
admin: registerResponse.admin,
createdAt: new Date(registerResponse.createdAt),
gravatarImageUrl: registerResponse.gravatarImageUrl,
lang: registerResponse.lang
lang: registerResponse.lang,
lastLogin: registerResponse.lastLogin
};
setSigned(true);
@@ -125,6 +127,19 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!signed) return;
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
const intervalId = setInterval(() => {
checkCurrentAuthUser(window.location.pathname).catch(() => {
setSigned(false);
setUser(undefined);
localStorage.clear();
});
}, TWENTY_FIVE_MINUTES);
return () => clearInterval(intervalId);
}, [signed]);
const updateUser = (userUpdated: UserResponse): void => {
setUser(userUpdated);
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
+30 -3
View File
@@ -241,6 +241,27 @@ a:hover, .btn-link:hover {
background-color: #333b42;
}
.home-new-item-danger {
border: 1px solid #FB1A41;
border-radius: 3px;
padding: 8px 16px;
color: #fff;
background-color: #FB1A41;
font-weight: 500;
}
.home-new-item-danger:hover {
background-color: #d91638;
}
.home-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
margin-right: 0.5rem;
}
.task-note-btn {
height: 48px;
}
@@ -277,8 +298,7 @@ code {
padding: 0.375rem 0.10rem 0.375rem 0.75rem;
}
.input-group > .form-control,
.react-datepicker__input-container > .form-control {
.input-group > .form-control {
border-left: none;
}
@@ -379,7 +399,14 @@ p.search-result-item-title {
.task-card {
margin-bottom: 1rem;
padding: 0.5rem;
}
.task-completed {
opacity: 0.7;
}
.task-completed .card-title {
color: var(--bs-secondary-color);
}
.dropdown-toggle::after {
+2 -1
View File
@@ -3,10 +3,11 @@ type NoteResponse = {
title: string;
description: string;
url: string | null;
tag: string;
tags: string[];
lastUpdate: string;
shared: boolean;
shareToken: string | null;
archived: boolean;
};
export type { NoteResponse };
+1
View File
@@ -7,4 +7,5 @@ export type SignInResponse = {
token: string;
gravatarImageUrl: string;
lang: string;
lastLogin: string;
};
+1 -1
View File
@@ -4,7 +4,7 @@ type TaskNoteRequest = {
urls?: string[];
dueDate?: string;
highPriority?: boolean;
tag: string;
tags: string[];
};
export default TaskNoteRequest;
+2 -2
View File
@@ -1,12 +1,12 @@
type TaskResponse = {
id: number;
description: string;
done: boolean;
completed: boolean;
highPriority: boolean;
dueDate: string;
dueDateFmt: string;
lastUpdate: string;
tag: string;
tags: string[];
urls: string[];
};
+1
View File
@@ -6,4 +6,5 @@ export type UserResponse = {
createdAt: Date;
gravatarImageUrl: string;
lang: string;
lastLogin: string;
};
+8
View File
@@ -15,6 +15,14 @@ function translateTimeMessage(message: string, target: string): string {
return message;
}
if (message === 'Due tomorrow') {
return timeAgoTranslations[`due tomorrow_${target}`] ?? message;
}
if (message === 'Due today') {
return timeAgoTranslations[`due today_${target}`] ?? message;
}
const firstSpace = message.indexOf(' ');
const numberValue = message.substring(0, firstSpace);
const textValue = message.substring(firstSpace).trim();
+10 -1
View File
@@ -35,6 +35,12 @@ function About(): React.ReactNode {
<li>{t('about_app_features_one')}</li>
<li>{t('about_app_features_two')}</li>
<li>{t('about_app_features_three')}</li>
<li>{t('about_app_features_four')}</li>
<li>{t('about_app_features_five')}</li>
<li>{t('about_app_features_six')}</li>
<li>{t('about_app_features_seven')}</li>
<li>{t('about_app_features_eight')}</li>
<li>{t('about_app_features_nine')}</li>
</ul>
<h4 className="mt-4 poppins-medium">{t('about_app_help_title')}</h4>
<p className="poppins-light">{t('about_app_help_description')}</p>
@@ -57,6 +63,9 @@ function About(): React.ReactNode {
<li>{t('about_tech_list_five')}</li>
<li>{t('about_tech_list_six')}</li>
<li>{t('about_tech_list_seven')}</li>
<li>{t('about_tech_list_eight')}</li>
<li>{t('about_tech_list_nine')}</li>
<li>{t('about_tech_list_ten')}</li>
</ul>
</Card.Body>
</Card>
@@ -75,7 +84,7 @@ function About(): React.ReactNode {
<p className="poppins-light">
{t('about_buy_coffee_one')}
<a
href="https://buy-me-a-coffee-two-nu.vercel.app/"
href="https://rmcampos.github.io/buy-me-a-coffee/"
target="_blank"
rel="noreferrer"
>
+13 -6
View File
@@ -286,22 +286,29 @@ function Account(): React.ReactNode {
</span>
<p className="mt-4 mb-2">{t('account_privacy_text')}</p>
<Button
variant="danger"
<div className="d-grid">
<button
type="button"
onClick={() => setShowAlert(true)}
className=""
className="home-new-item-danger task-note-btn"
>
{t('account_privacy_delete_btn')}
</Button>
</button>
</div>
{showAlert && (
<Alert className="mt-3" variant="danger" onClose={() => setShowAlert(false)} dismissible>
<Alert.Heading>{t('account_delete_title')}</Alert.Heading>
<p>{t('account_delete_description')}</p>
<Button onClick={() => deleteAccount()} variant="outline-danger">
<div className="d-grid">
<button
type="button"
onClick={() => deleteAccount()}
className="home-new-item-danger task-note-btn"
>
{t('account_delete_btn')}
</Button>
</button>
</div>
</Alert>
)}
</Card.Body>
+412 -36
View File
@@ -2,12 +2,14 @@ import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Col,
Container,
Dropdown,
Form,
InputGroup,
Modal,
Row
} from 'react-bootstrap';
import { TaskResponse } from '../../types/TaskResponse';
@@ -20,7 +22,7 @@ import AuthContext from '../../context/AuthContext';
import FilterContext from '../../context/FilterContext';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import { ThreeDotsVertical } from 'react-bootstrap-icons';
import { CheckSquare, JournalText, ThreeDotsVertical } from 'react-bootstrap-icons';
import { NavLink } from 'react-router';
import ModalMarkdown from '../../components/ModalMarkdown';
import TaskTitle from '../../components/TaskTitle';
@@ -28,6 +30,8 @@ import TaskTimeLeft from '../../components/TaskTimeLeft';
import TaskTag from '../../components/TaskTag';
import NoteTitle from '../../components/NoteTitle';
const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID';
/**
* Home page component.
*
@@ -46,9 +50,13 @@ function Home(): React.ReactNode {
const [modalTitle, setModalTitle] = useState<string>('');
const [modalContent, setModalContent] = useState<string>('');
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const [completedTasks, setCompletedTasks] = useState<TaskResponse[]>([]);
const [notes, setNotes] = useState<NoteResponse[]>([]);
const [archivedNotes, setArchivedNotes] = useState<NoteResponse[]>([]);
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<{ type: 'task' | 'note'; id: number } | null>(null);
/**
* Handles the error by setting the error message.
@@ -75,13 +83,28 @@ function Home(): React.ReactNode {
};
/**
* Mark a task as done or undone.
* Toggle a task's completed status.
*
* @param {TaskResponse} task The task to be marked as done or undone.
* @param {TaskResponse} task The task to be marked as completed or uncompleted.
*/
const markAsDone = async (task: TaskResponse): Promise<void> => {
const toggleTaskCompleted = async (task: TaskResponse): Promise<void> => {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${task.id}`);
await api.patchJSON(`${ApiConfig.tasksUrl}/${task.id}`, { completed: !task.completed });
await loadAllTasks();
}
catch (e) {
handleError(e);
}
};
/**
* Delete a task.
*
* @param {number} taskIdParam The task ID to be deleted.
*/
const deleteTask = async (taskIdParam: number) => {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
await loadAllTasks();
}
catch (e) {
@@ -104,6 +127,90 @@ function Home(): React.ReactNode {
}
};
/**
* Filter notes into active and archived sets.
*
* @param {NoteResponse[]} allNotes The full list of notes to partition.
* @returns {{ active: NoteResponse[]; archived: NoteResponse[] }} Active and archived notes.
*/
const partitionNotes = (allNotes: NoteResponse[]): { active: NoteResponse[]; archived: NoteResponse[] } => {
return allNotes.reduce<{ active: NoteResponse[]; archived: NoteResponse[] }>(
(acc, note) => {
if (note.archived) {
acc.archived.push(note);
}
else {
acc.active.push(note);
}
return acc;
},
{ active: [], archived: [] }
);
};
/**
* Opens the delete confirmation modal for a task or note.
*
* @param {object} target The target to delete with type and id.
*/
const confirmDelete = (target: { type: 'task' | 'note'; id: number }) => {
setDeleteTarget(target);
setShowDeleteModal(true);
};
/**
* Confirms and executes the delete action.
*/
const handleConfirmDelete = async () => {
if (!deleteTarget) {
return;
}
if (deleteTarget.type === 'task') {
await deleteTask(deleteTarget.id);
}
else {
await deleteNote(deleteTarget.id);
}
setShowDeleteModal(false);
setDeleteTarget(null);
};
/**
* Archive a note, moving it to the archived notes section.
*
* @param {number} noteId The note ID to be archived.
*/
const archiveNote = async (noteId: number): Promise<void> => {
try {
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {});
await loadAllNotes();
}
catch (e) {
handleError(e);
}
};
/**
* Restore an archived note back to active notes.
*
* @param {number} noteId The note ID to be restored.
*/
const restoreNote = async (noteId: number): Promise<void> => {
try {
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {});
await loadAllNotes();
}
catch (e) {
handleError(e);
}
};
const handleArchiveNote = (noteId: number): void => {
void archiveNote(noteId);
};
/**
* Share or unshare a note.
*
@@ -141,9 +248,15 @@ function Home(): React.ReactNode {
* @param {NoteResponse[]} allNotes - The full list of notes to filter from.
*/
const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => {
const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed);
const doneTasks = allTasks.filter((task: TaskResponse) => task.completed);
const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes);
if (!text && (!radioFilter || radioFilter === 'everything')) {
setNotes([...allNotes]);
setTasks([...allTasks]);
setNotes([...activeNotes]);
setArchivedNotes([...archivedNoteList]);
setTasks([...activeTasks]);
setCompletedTasks([...doneTasks]);
return;
}
@@ -151,44 +264,77 @@ function Home(): React.ReactNode {
if (radioFilter && radioFilter === 'onlyTasks') {
setNotes([]);
setArchivedNotes([]);
}
else {
let filteredNotes = allNotes.filter((note: NoteResponse) => {
let filteredNotes = activeNotes.filter((note: NoteResponse) => {
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
const anyUrlMatch = note.url?.includes(text.toLowerCase());
const anyTagMatch = note.tag?.toLowerCase().includes(text.toLowerCase());
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
});
if (tagToFilter === 'untagged') {
filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tag);
filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
}
else if (tagToFilter) {
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tag && note.tag === tagToFilter);
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
}
let filteredArchivedNotes = archivedNoteList.filter((note: NoteResponse) => {
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
const anyUrlMatch = note.url?.includes(text.toLowerCase());
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
});
if (tagToFilter === 'untagged') {
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
}
else if (tagToFilter) {
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
}
setNotes([...filteredNotes]);
setArchivedNotes([...filteredArchivedNotes]);
}
if (radioFilter && radioFilter === 'onlyNotes') {
setTasks([]);
setCompletedTasks([]);
}
else {
let filteredTasks = allTasks.filter((task: TaskResponse) => {
let filteredTasks = activeTasks.filter((task: TaskResponse) => {
return task.description.toLowerCase().includes(text.toLowerCase())
|| task.tag.toLowerCase().includes(text.toLowerCase())
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
});
if (tagToFilter === 'untagged') {
filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tag);
filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0);
}
else if (tagToFilter) {
filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tag && task.tag === tagToFilter);
filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter));
}
setTasks([...filteredTasks]);
let filteredCompletedTasks = doneTasks.filter((task: TaskResponse) => {
return task.description.toLowerCase().includes(text.toLowerCase())
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
});
if (tagToFilter === 'untagged') {
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0);
}
else if (tagToFilter) {
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter));
}
setCompletedTasks([...filteredCompletedTasks]);
}
};
@@ -208,6 +354,7 @@ function Home(): React.ReactNode {
const tasksFetched: TaskResponse[] = await api.getJSON(ApiConfig.tasksUrl);
const translated = translateTaskResponse(tasksFetched, i18n.language);
translated.sort((t1, t2) => {
if (t1.completed === t2.completed) {
if (t1.highPriority === t2.highPriority) {
return 0;
}
@@ -215,6 +362,11 @@ function Home(): React.ReactNode {
return -1;
}
return 1;
}
if (t1.completed) {
return -1;
}
return 1;
});
setSavedTasks([...translated]);
}
@@ -299,7 +451,10 @@ function Home(): React.ReactNode {
return preview.join('\n');
};
const handleCloseModal = () => setShowMarkdownView(false);
const handleCloseModal = () => {
setShowMarkdownView(false);
localStorage.removeItem(OPEN_NOTE_ID_KEY);
};
const getSelectedLabel = (): string => {
if (selectedOption === 'everything') return t('home_radio_everything');
@@ -345,6 +500,22 @@ function Home(): React.ReactNode {
applyFilter(filterText, selectedOption, savedTasks, savedNotes);
}, [savedTasks, savedNotes, filterText, selectedOption]);
useEffect(() => {
const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY);
if (openNoteId && notes.length > 0) {
const noteId = Number(openNoteId);
const foundNote = notes.find(n => n.id === noteId);
if (foundNote) {
setModalTitle(foundNote.title);
setModalContent(foundNote.description);
setShowMarkdownView(true);
}
else {
localStorage.removeItem(OPEN_NOTE_ID_KEY);
}
}
}, [notes]);
return (
<Container fluid>
<ContentHeader
@@ -382,7 +553,9 @@ function Home(): React.ReactNode {
}}
/>
<Dropdown onSelect={eventKey => eventKey && handleOptionChange(eventKey)}>
<Dropdown
onSelect={eventKey => eventKey && handleOptionChange(eventKey)}
>
<Dropdown.Toggle
variant="success"
id="filter-dropdown"
@@ -405,7 +578,10 @@ function Home(): React.ReactNode {
</Badge>
</Dropdown.Toggle>
<Dropdown.Menu className="shadow-lg border-0" style={{ minWidth: '200px' }}>
<Dropdown.Menu
className="shadow-lg border-0"
style={{ minWidth: '200px' }}
>
<Dropdown.Header className="text-muted small">
<i className="bi bi-funnel me-2"></i>
Filter Options
@@ -472,25 +648,34 @@ function Home(): React.ReactNode {
<Row className="mt-3">
{tasks.map((task: TaskResponse) => (
<Col xs={12} key={task.id.toString()}>
<Card key={task.id.toString()} className={`task-card ${task.highPriority ? 'high-importance' : ''}`}>
<Card
key={task.id.toString()}
className={`task-card ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<CheckSquare />
</span>
<TaskTitle
title={task.description}
done={task.done}
completed={task.completed}
taskUrl={task.urls}
/>
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`task-dropdown-menu-${task.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
{!task.done && (
{!task.completed && (
<NavLink to={`/tasks/edit/${task.id}`}>
<Dropdown.Item as="span">
{t('task_table_action_edit')}
@@ -499,10 +684,20 @@ function Home(): React.ReactNode {
)}
<Dropdown.Item
as="button"
onClick={() => markAsDone(task)}
onClick={() => toggleTaskCompleted(task)}
data-testid={`task-dropdown-done-item-${task.id}`}
>
{task.done ? t('task_table_action_undone') : t('task_table_action_done')}
{task.completed
? t('task_table_action_undone')
: t('task_table_action_done')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'task', id: task.id })}
data-testid={`task-dropdown-delete-item-${task.id}`}
>
{t('task_table_action_delete')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
@@ -512,14 +707,14 @@ function Home(): React.ReactNode {
{task.dueDateFmt && (
<TaskTimeLeft
text={task.dueDateFmt}
done={task.done}
completed={task.completed}
tooltip={task.dueDate}
/>
)}
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tag={task.tag}
tags={task.tags}
lastUpdate={task.lastUpdate}
taskOrNote="task"
/>
@@ -537,15 +732,18 @@ function Home(): React.ReactNode {
<Row>
<Col xs={10}>
<Card.Title>
<NoteTitle
title={note.title}
noteUrl={note.url}
/>
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`note-dropdown-menu-${note.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -564,7 +762,9 @@ function Home(): React.ReactNode {
onClick={() => toggleShareNote(note)}
data-testid={`note-dropdown-share-item-${note.id}`}
>
{note.shared ? t('note_action_unshare') : t('note_action_share')}
{note.shared
? t('note_action_unshare')
: t('note_action_share')}
</Dropdown.Item>
{note.shared && note.shareToken && (
<Dropdown.Item
@@ -577,10 +777,10 @@ function Home(): React.ReactNode {
)}
<Dropdown.Item
as="button"
onClick={() => deleteNote(note.id)}
data-testid={`note-dropdown-delete-item-${note.id}`}
onClick={() => handleArchiveNote(note.id)}
data-testid={`note-dropdown-archive-item-${note.id}`}
>
{t('task_table_action_delete')}
{t('note_action_archive')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
@@ -593,7 +793,7 @@ function Home(): React.ReactNode {
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tag={note.tag}
tags={note.tags}
lastUpdate={note.lastUpdate}
taskOrNote="note"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
@@ -602,6 +802,7 @@ function Home(): React.ReactNode {
setModalTitle(note.title);
setModalContent(note.description);
setShowMarkdownView(true);
localStorage.setItem(OPEN_NOTE_ID_KEY, note.id.toString());
}}
/>
</Card.Footer>
@@ -610,12 +811,187 @@ function Home(): React.ReactNode {
))}
</Row>
{completedTasks.length > 0 && (
<Row className="mt-4">
<Col xs={12}>
<h5 className="text-muted">{t('home_completed_tasks_title')}</h5>
</Col>
{completedTasks.map((task: TaskResponse) => (
<Col xs={12} key={`completed-${task.id.toString()}`}>
<Card
className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<CheckSquare />
</span>
<TaskTitle
title={task.description}
completed={task.completed}
taskUrl={task.urls}
/>
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle
variant="success"
data-testid={`completed-task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
as="button"
onClick={() => toggleTaskCompleted(task)}
data-testid={`completed-task-dropdown-undone-item-${task.id}`}
>
{t('task_table_action_undone')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'task', id: task.id })}
data-testid={`completed-task-dropdown-delete-item-${task.id}`}
>
{t('task_table_action_delete')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tags={task.tags}
lastUpdate={task.lastUpdate}
taskOrNote="task"
/>
</Card.Footer>
</Card>
</Col>
))}
</Row>
)}
{archivedNotes.length > 0 && (
<Row className="mt-4">
<Col xs={12}>
<h5 className="text-muted">{t('home_archived_notes_title')}</h5>
</Col>
{archivedNotes.map((note: NoteResponse) => (
<Col xs={12} key={`archived-${note.id.toString()}`}>
<Card className="task-card task-completed mb-3">
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle
variant="success"
data-testid={`archived-note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
as="button"
onClick={() => restoreNote(note.id)}
data-testid={`archived-note-dropdown-restore-item-${note.id}`}
>
{t('note_action_restore')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'note', id: note.id })}
data-testid={`archived-note-dropdown-delete-item-${note.id}`}
>
{t('note_action_delete_permanently')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
<span className="text-muted span-line-break font-size-14">
{getFirstRows(note.description)}
</span>
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tags={note.tags}
lastUpdate={note.lastUpdate}
taskOrNote="note"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setModalTitle(note.title);
setModalContent(note.description);
setShowMarkdownView(true);
localStorage.setItem(
OPEN_NOTE_ID_KEY,
note.id.toString()
);
}}
/>
</Card.Footer>
</Card>
</Col>
))}
</Row>
)}
<ModalMarkdown
show={showMarkdownView}
onHide={handleCloseModal}
title={modalTitle}
markdownText={modalContent}
/>
<Modal
show={showDeleteModal}
onHide={() => setShowDeleteModal(false)}
centered
backdrop="static"
>
<Modal.Header closeButton className="bg-danger-subtle">
<Modal.Title className="d-flex align-items-center gap-2">
<i className="bi bi-exclamation-triangle-fill text-danger"></i>
{t('delete_modal_title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>{t('delete_modal_body')}</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button
variant="outline-secondary"
onClick={() => {
setShowDeleteModal(false);
}}
className="task-note-btn"
>
{t('delete_modal_cancel')}
</Button>
<Button
variant="danger"
onClick={handleConfirmDelete}
className="task-note-btn"
data-testid="confirm-delete-button"
>
{t('delete_modal_confirm')}
</Button>
</Modal.Footer>
</Modal>
</Container>
);
}
+2 -1
View File
@@ -4,8 +4,9 @@
position: relative;
text-align: center;
color: $dark-text;
background: var(--bs-landing-bg) no-repeat center center;
background: var(--bs-landing-bg) no-repeat center center fixed;
background-size: cover; // Ensures the image covers the whole background
min-height: 100vh;
&::before {
content: '';
+2 -1
View File
@@ -1,11 +1,12 @@
@import '../../styles/theme.scss';
.login-page {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: var(--bs-landing-bg) no-repeat center center;
background: var(--bs-landing-bg) no-repeat center center fixed;
background-size: cover;
&::before {
+229 -73
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
Container,
@@ -22,6 +24,13 @@ import ContentHeader from '../../components/ContentHeader';
type NoteAction = 'add' | 'edit';
interface NoteDraft {
title: string;
content: string;
noteUrl: string;
tags: string[];
}
/**
* NoteAdd component for adding and editing notes.
*
@@ -34,20 +43,26 @@ function NoteAdd(): React.ReactNode {
const [noteTitle, setNoteTitle] = useState<string>('');
const [noteContent, setNoteContent] = useState<string>('');
const [noteUrl, setNoteUrl] = useState<string>('');
const [noteTag, setNoteTag] = useState<string>('');
const [currentTag, setCurrentTag] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [action, setAction] = useState<NoteAction>('add');
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
const [draftBanner, setDraftBanner] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUserEdited = useRef<boolean>(false);
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
setTags(response.filter(tag => tag !== 'untagged'));
}
catch (e) {
handleError(e);
@@ -71,8 +86,8 @@ function NoteAdd(): React.ReactNode {
/**
* Adds a new note.
*
* @param {TaskNoteRequest} payload - The task data to add.
* @returns {Promise<boolean>} True if the task was added successfully, false otherwise.
* @param {NoteResponse} payload - The note data to add.
* @returns {Promise<boolean>} True if the note was added successfully, false otherwise.
*/
const addNote = async (payload: NoteResponse): Promise<boolean> => {
try {
@@ -82,15 +97,14 @@ function NoteAdd(): React.ReactNode {
catch (e) {
handleError(e);
}
return false;
};
/**
* Submits the edited task.
* Submits the edited note.
*
* @param {TaskResponse} payload - The task data to edit.
* @returns {Promise<boolean>} True if the task was edited successfully, false otherwise.
* @param {NoteResponse} payload - The note data to edit.
* @returns {Promise<boolean>} True if the note was edited successfully, false otherwise.
*/
const submitEditNote = async (payload: NoteResponse): Promise<boolean> => {
try {
@@ -111,16 +125,132 @@ function NoteAdd(): React.ReactNode {
setNoteTitle('');
setNoteUrl('');
setNoteContent('');
setNoteTag('');
setCurrentTag('');
setSelectedTags([]);
setAction('add');
setValidated(false);
};
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
if (!hasUserEdited.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
localStorage.setItem(draftKey, JSON.stringify(draft));
}, 1500);
};
const clearDraft = (): void => {
if (debounceRef.current) clearTimeout(debounceRef.current);
localStorage.removeItem(draftKey);
};
const applyDraft = (): void => {
const raw = localStorage.getItem(draftKey);
if (!raw) return;
try {
const draft: NoteDraft = JSON.parse(raw);
setNoteTitle(draft.title ?? '');
setNoteContent(draft.content ?? '');
setNoteUrl(draft.noteUrl ?? '');
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
}
catch {
localStorage.removeItem(draftKey);
}
};
const handleDiscardDraft = async (): Promise<void> => {
setDraftBanner(false);
if (params?.id) {
try {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
}
catch (e) {
handleError(e);
}
finally {
clearDraft();
}
}
else {
clearDraft();
resetInputs();
}
};
const addTag = (tagName: string): void => {
const normalized = tagName.trim().toLowerCase();
let newTags = [...selectedTags];
if (normalized && !selectedTags.includes(normalized)) {
newTags = [...selectedTags, normalized];
setSelectedTags(newTags);
}
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
setCurrentTag('');
setShowTagDropdown(false);
};
const removeTag = (tagToRemove: string): void => {
const newTags = selectedTags.filter(t => t !== tagToRemove);
setSelectedTags(newTags);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
};
/**
* Saves the note, either adding a new one or editing an existing one.
*
* @returns {Promise<boolean>} True if the note was saved successfully, false otherwise.
*/
const saveNote = async (): Promise<boolean> => {
setValidated(true);
if (!noteTitle.trim() || !noteContent.trim()) {
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
return false;
}
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
if (!finalTags.includes(normalized)) {
finalTags.push(normalized);
}
}
const payload: NoteResponse = {
id: action === 'edit' ? noteId : 0,
title: noteTitle,
description: noteContent,
url: noteUrl,
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null,
archived: false
};
const saved = action === 'add'
? await addNote(payload)
: await submitEditNote(payload);
if (saved) {
clearDraft();
resetInputs();
navigate('/home');
}
return saved;
};
/**
* Handles the form submission.
*
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
@@ -133,48 +263,11 @@ function NoteAdd(): React.ReactNode {
return;
}
if (action === 'add') {
const payload: NoteResponse = {
id: 0,
title: noteTitle,
description: noteContent,
url: noteUrl,
tag: noteTag,
lastUpdate: '',
shared: false,
shareToken: null
};
const added: boolean = await addNote(payload);
if (added) {
form.reset();
resetInputs();
navigate('/home');
}
}
else if (action === 'edit') {
const payload: NoteResponse = {
id: noteId,
title: noteTitle,
description: noteContent,
url: noteUrl,
tag: noteTag,
lastUpdate: '',
shared: false,
shareToken: null
};
const edited: boolean = await submitEditNote(payload);
if (edited) {
form.reset();
resetInputs();
navigate('/home');
}
}
await saveNote();
};
/**
* Checks if the URL is for editing a task and loads the task data if it is.
* Checks if the URL is for editing a note and loads the note data if it is.
*/
const checkEditUrl = async (): Promise<void> => {
if (params?.id) {
@@ -182,6 +275,7 @@ function NoteAdd(): React.ReactNode {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
setAction('edit');
applyDraft();
}
catch (e) {
handleError(e);
@@ -213,16 +307,16 @@ function NoteAdd(): React.ReactNode {
}
};
const setNoteFromServer = (noteContent: NoteResponse) => {
setNoteId(noteContent.id);
setNoteTitle(noteContent.title);
if (noteContent.url) {
setNoteUrl(noteContent.url);
const setNoteFromServer = (noteData: NoteResponse) => {
setNoteId(noteData.id);
setNoteTitle(noteData.title);
if (noteData.url) {
setNoteUrl(noteData.url);
}
if (noteContent.tag) {
setNoteTag(noteContent.tag);
if (noteData.tags) {
setSelectedTags(noteData.tags);
}
setNoteContent(noteContent.description);
setNoteContent(noteData.description);
};
/**
@@ -246,6 +340,10 @@ function NoteAdd(): React.ReactNode {
checkEditUrl();
checkCloneUrl();
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
applyDraft();
}
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
@@ -255,6 +353,7 @@ function NoteAdd(): React.ReactNode {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
@@ -279,12 +378,30 @@ function NoteAdd(): React.ReactNode {
onClose={() => setErrorMessage('')}
/>
{draftBanner && (
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
Draft restored from a previous session.
{' '}
<Alert.Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
void handleDiscardDraft();
}}
>
Discard draft
</Alert.Link>
</Alert>
)}
<Form
noValidate
validated={validated}
onSubmit={handleSubmit}
autoComplete="off"
>
<Row>
<Col xs={12} md={6} xxl={6}>
{/* Note title */}
<FormInput
labelText={t('note_form_title_label')}
@@ -296,11 +413,12 @@ function NoteAdd(): React.ReactNode {
value={noteTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTitle(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
}}
/>
<Row>
<Col xs={12} xl={9}>
</Col>
<Col xs={12} md={6} xxl={6}>
{/* Note URL */}
<FormInput
labelText={t('task_form_url_label')}
@@ -312,14 +430,18 @@ function NoteAdd(): React.ReactNode {
value={noteUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteUrl(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
}}
/>
</Col>
<Col xs={12} xl={3}>
</Row>
<Row>
<Col xs={12}>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tag</Form.Label>
<InputGroup className="mb-3">
<Form.Label>Tags</Form.Label>
<InputGroup>
<InputGroup.Text>
<Hash />
</InputGroup.Text>
@@ -327,16 +449,42 @@ function NoteAdd(): React.ReactNode {
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={noteTag}
value={currentTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTag(e.target.value);
setCurrentTag(e.target.value);
setShowTagDropdown(true);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && currentTag.trim()) {
e.preventDefault();
addTag(currentTag);
}
}}
onFocus={() => setShowTagDropdown(true)}
autoComplete="off"
/>
</InputGroup>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && (
<Form.Text className="text-muted">
Type a tag and press Enter
</Form.Text>
<div className="mb-2 d-flex flex-wrap gap-1">
{selectedTags.map(t => (
<Badge
key={t}
bg="warning"
text="dark"
className="p-2 mt-3"
style={{ cursor: 'pointer' }}
onClick={() => removeTag(t)}
>
#
{t}
{' '}
&times;
</Badge>
))}
</div>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
<ListGroup
style={{
position: 'absolute',
@@ -344,22 +492,23 @@ function NoteAdd(): React.ReactNode {
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
top: '100%',
left: 0
}}
>
{tags
.filter(t => t.toLowerCase().includes(noteTag.toLowerCase()))
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
.map(t => (
<ListGroup.Item
key={t}
action
variant="warning"
className="d-flex align-items-center gap-2"
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
setNoteTag(t);
setShowTagDropdown(false);
addTag(t);
}}
>
<i className="bi bi-tag"></i>
#
{t}
</ListGroup.Item>
@@ -392,27 +541,32 @@ function NoteAdd(): React.ReactNode {
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNoteContent(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
}}
data-testid="note-content-input-area"
/>
</Form.Group>
<div className="d-flex justify-content-end gap-2 mt-3">
<button
type="submit"
className="home-new-item task-note-btn mt-3"
className="home-new-item task-note-btn"
>
{t('note_form_submit')}
</button>
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
className="home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
</div>
</Form>
</Card.Body>
@@ -425,6 +579,8 @@ function NoteAdd(): React.ReactNode {
onHide={handleCloseModal}
title={noteTitle}
markdownText={noteContent}
onSave={saveNote}
saveButtonLabel={t('note_form_submit')}
/>
</Container>
);
+2 -3
View File
@@ -72,10 +72,9 @@ function SharedNote(): React.ReactNode {
<Card>
<Card.Header className="d-flex justify-content-between align-items-center">
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
{note.tag && (
{note.tags && note.tags.length > 0 && (
<small className="text-muted">
#
{note.tag}
{note.tags.map(t => `#${t}`).join(' ')}
</small>
)}
</Card.Header>
+232 -57
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
Container,
@@ -22,6 +24,14 @@ import AlertError from '../../components/AlertError';
type TaskAction = 'add' | 'edit';
interface TaskDraft {
description: string;
taskUrl: string;
dueDate: string | null;
highPriority: boolean;
tags: string[];
}
/**
* TaskAdd component for adding and editing tasks.
*
@@ -33,22 +43,28 @@ function TaskAdd(): React.ReactNode {
const [taskId, setTaskId] = useState<number>(0);
const [taskDescription, setTaskDescription] = useState<string>('');
const [taskUrl, setTaskUrl] = useState<string>('');
const [taskDone, setTaskDone] = useState<boolean>(false);
const [taskCompleted, setTaskCompleted] = useState<boolean>(false);
const [action, setAction] = useState<TaskAction>('add');
const [dueDate, setDueDate] = useState<Date | null>(null);
const [dueDate, setDueDate] = useState<string>('');
const [highPriority, setHighPriority] = useState<boolean>(false);
const [tag, setTag] = useState<string>('');
const [currentTag, setCurrentTag] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [draftBanner, setDraftBanner] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUserEdited = useRef<boolean>(false);
const draftKey = params?.id ? `draft:task:edit:${params.id}` : 'draft:task:new';
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
setTags(response.filter(tag => tag !== 'untagged'));
}
catch (e) {
handleError(e);
@@ -83,7 +99,6 @@ function TaskAdd(): React.ReactNode {
catch (e) {
handleError(e);
}
return false;
};
@@ -110,20 +125,118 @@ function TaskAdd(): React.ReactNode {
const resetInputs = (): void => {
setTaskId(0);
setTaskDescription('');
setTaskDone(false);
setTaskCompleted(false);
setTaskUrl('');
setDueDate(null);
setDueDate('');
setHighPriority(false);
setTag('');
setCurrentTag('');
setSelectedTags([]);
setAction('add');
setValidated(false);
};
const saveDraft = (
description: string,
taskUrl: string,
due: string,
priority: boolean,
draftTags: string[]
): void => {
if (!hasUserEdited.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const draft: TaskDraft = {
description,
taskUrl,
dueDate: due || null,
highPriority: priority,
tags: draftTags
};
localStorage.setItem(draftKey, JSON.stringify(draft));
}, 1500);
};
const clearDraft = (): void => {
if (debounceRef.current) clearTimeout(debounceRef.current);
localStorage.removeItem(draftKey);
};
const applyDraft = (): void => {
const raw = localStorage.getItem(draftKey);
if (!raw) return;
try {
const draft: TaskDraft = JSON.parse(raw);
setTaskDescription(draft.description ?? '');
setTaskUrl(draft.taskUrl ?? '');
const parsedDate = draft.dueDate ? draft.dueDate : '';
setDueDate(parsedDate);
setHighPriority(draft.highPriority ?? false);
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
}
catch {
localStorage.removeItem(draftKey);
}
};
const setTaskFromServer = (task: TaskResponse): void => {
setTaskId(task.id);
setTaskDescription(task.description);
setTaskUrl(task.urls.length ? task.urls[0] : '');
setTaskCompleted(task.completed);
if (task.dueDateFmt) {
setDueDate(task.dueDate);
}
setHighPriority(task.highPriority);
if (task.tags) {
setSelectedTags(task.tags);
}
};
const handleDiscardDraft = async (): Promise<void> => {
setDraftBanner(false);
if (params?.id) {
try {
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
setTaskFromServer(taskToEdit);
}
catch (e) {
handleError(e);
}
finally {
clearDraft();
}
}
else {
clearDraft();
resetInputs();
}
};
const addTag = (tagName: string): void => {
const normalized = tagName.trim().toLowerCase();
let newTags = [...selectedTags];
if (normalized && !selectedTags.includes(normalized)) {
newTags = [...selectedTags, normalized];
setSelectedTags(newTags);
}
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
setCurrentTag('');
setShowTagDropdown(false);
};
const removeTag = (tagToRemove: string): void => {
const newTags = selectedTags.filter(t => t !== tagToRemove);
setSelectedTags(newTags);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
};
/**
* Handles the form submission.
*
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
@@ -136,9 +249,14 @@ function TaskAdd(): React.ReactNode {
return;
}
let dueDateFormatted: string = '';
if (dueDate) {
dueDateFormatted = dueDate.toISOString().substring(0, 10);
const dueDateFormatted: string = dueDate;
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
if (!finalTags.includes(normalized)) {
finalTags.push(normalized);
}
}
if (action === 'add') {
@@ -146,12 +264,13 @@ function TaskAdd(): React.ReactNode {
description: taskDescription.trim(),
highPriority: highPriority,
dueDate: dueDateFormatted,
tag: tag,
tags: finalTags,
urls: taskUrl ? [taskUrl] : []
};
const added: boolean = await addTask(addPayload);
if (added) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -161,17 +280,18 @@ function TaskAdd(): React.ReactNode {
const editPayload: TaskResponse = {
id: taskId,
description: taskDescription.trim(),
done: taskDone,
completed: taskCompleted,
highPriority: highPriority,
dueDate: dueDateFormatted,
dueDateFmt: '',
lastUpdate: '',
tag: tag,
tags: finalTags,
urls: taskUrl ? [taskUrl] : []
};
const edited: boolean = await submitEditTask(editPayload);
if (edited) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -186,18 +306,9 @@ function TaskAdd(): React.ReactNode {
if (params.id) {
try {
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
setTaskId(taskToEdit.id);
setTaskDescription(taskToEdit.description);
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
setTaskDone(taskToEdit.done);
if (taskToEdit.dueDateFmt) {
setDueDate(new Date(taskToEdit.dueDate));
}
setHighPriority(taskToEdit.highPriority);
if (taskToEdit.tag) {
setTag(taskToEdit.tag);
}
setTaskFromServer(taskToEdit);
setAction('edit');
applyDraft();
}
catch (e) {
handleError(e);
@@ -209,6 +320,10 @@ function TaskAdd(): React.ReactNode {
loadTags();
checkEditUrl();
if (!params?.id) {
applyDraft();
}
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
@@ -218,6 +333,7 @@ function TaskAdd(): React.ReactNode {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
@@ -243,12 +359,30 @@ function TaskAdd(): React.ReactNode {
onClose={() => setErrorMessage('')}
/>
{draftBanner && (
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
Draft restored from a previous session.
{' '}
<Alert.Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
void handleDiscardDraft();
}}
>
Discard draft
</Alert.Link>
</Alert>
)}
<Form
noValidate
validated={validated}
onSubmit={handleSubmit}
autoComplete="off"
>
<Row>
<Col xs={12} md={6} xxl={6}>
{/* Description */}
<FormInput
labelText={t('task_form_desc_label')}
@@ -260,11 +394,28 @@ function TaskAdd(): React.ReactNode {
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
}}
/>
<Row>
<Col xs={12} sm={12} xxl={6}>
</Col>
<Col xs={12} sm={6} md={3} xxl={3}>
{/* Due date */}
<FormInput
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="date"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDueDate(e.target.value);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, e.target.value, highPriority, selectedTags);
}}
/>
</Col>
<Col xs={12} sm={6} md={3} xxl={3}>
{/* Task URL */}
<FormInput
labelText={t('task_form_url_label')}
@@ -276,29 +427,19 @@ function TaskAdd(): React.ReactNode {
value={taskUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskUrl(e.target.value);
hasUserEdited.current = true;
saveDraft(taskDescription, e.target.value, dueDate, highPriority, selectedTags);
}}
/>
</Col>
<Col xs={12} sm={6} xxl={3}>
{/* Due date */}
<FormInput
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="date"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
valueDate={dueDate}
onChangeDate={(date: Date | null) => {
setDueDate(date);
}}
/>
</Col>
<Col xs={12} sm={6} xxl={3}>
</Row>
<Row>
<Col xs={12}>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tag</Form.Label>
<InputGroup className="mb-3">
<Form.Label>Tags</Form.Label>
<InputGroup>
<InputGroup.Text>
<Hash />
</InputGroup.Text>
@@ -306,16 +447,42 @@ function TaskAdd(): React.ReactNode {
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={tag}
value={currentTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.target.value);
setCurrentTag(e.target.value);
setShowTagDropdown(true);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && currentTag.trim()) {
e.preventDefault();
addTag(currentTag);
}
}}
onFocus={() => setShowTagDropdown(true)}
autoComplete="off"
/>
</InputGroup>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && (
<Form.Text className="text-muted mb-3">
Type a tag and press Enter
</Form.Text>
<div className="mb-2 d-flex flex-wrap gap-1">
{selectedTags.map(t => (
<Badge
key={t}
bg="warning"
text="dark"
className="p-2 mt-3"
style={{ cursor: 'pointer' }}
onClick={() => removeTag(t)}
>
#
{t}
{' '}
&times;
</Badge>
))}
</div>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
<ListGroup
style={{
position: 'absolute',
@@ -323,22 +490,23 @@ function TaskAdd(): React.ReactNode {
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
top: '100%',
left: 0
}}
>
{tags
.filter(t => t.toLowerCase().includes(tag.toLowerCase()))
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
.map(t => (
<ListGroup.Item
key={t}
action
variant="warning"
className="d-flex align-items-center gap-2"
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
setTag(t);
setShowTagDropdown(false);
addTag(t);
}}
>
<i className="bi bi-tag"></i>
#
{t}
</ListGroup.Item>
@@ -356,9 +524,14 @@ function TaskAdd(): React.ReactNode {
className="mb-3"
name="highPriority"
checked={highPriority}
onChange={() => setHighPriority(!highPriority)}
onChange={() => {
setHighPriority(!highPriority);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, !highPriority, selectedTags);
}}
/>
<div className="d-flex justify-content-end gap-2">
<button
type="submit"
className="home-new-item task-note-btn"
@@ -368,13 +541,15 @@ function TaskAdd(): React.ReactNode {
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
className="home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
</div>
</Form>
</Card.Body>
</Card>
+14 -2
View File
@@ -3,6 +3,16 @@ import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'url';
import path from 'path';
const proxyConfig = process.env.NGROK
? {
'/api': {
target: `http://${process.env.BACKEND_HOST || 'localhost'}:8585`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
}
: undefined;
export default defineConfig(({ mode }: ConfigEnv) => {
const config: UserConfig = {
define: {},
@@ -29,10 +39,12 @@ export default defineConfig(({ mode }: ConfigEnv) => {
],
build: {
outDir: 'dist',
sourcemap: true
sourcemap: mode === 'development'
},
server: {
port: 5000
port: 5000,
...(process.env.NGROK ? { allowedHosts: ['.ngrok-free.dev'] } : {}),
proxy: proxyConfig,
},
preview: {
port: 5000

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